Attach pull request feedback to chat (#1400)

* feat(app): add pull request panel context actions

Attach pull request comments, reviews, and failed check details to chat context. The PR panel now lives in its own module so the activity, query, and context policies have one home.

* fix(server): mark capped check details as truncated

* fix(app): preserve concurrent PR panel attachments

* fix(server): preserve annotation truncation state

* fix(app): keep matrix check attachments distinct

* fix(app): streamline pull request context attachments

* fix(app): simplify pull request feedback rendering

* feat(app): group pull request review threads

* fix(app): tidy pull request panel boundaries

* fix: use worktree cwd for dev services

* fix(app): keep git query keys UI-free

* feat(app): collapse resolved comments and add chat actions to headers

* fix: address PR feedback and Playwright flakes

* fix(app): hide check chat action without attachment scope

* fix(server): dedupe inline review timeline comments
This commit is contained in:
Mohamed Boudra
2026-06-12 13:43:01 +08:00
committed by GitHub
parent c5da7505da
commit 3163e212fb
64 changed files with 6934 additions and 1939 deletions
@@ -1,6 +1,8 @@
import { describe, expect, test } from "vitest";
import {
CheckoutGithubGetCheckDetailsRequestSchema,
CheckoutGithubGetCheckDetailsResponseSchema,
CheckoutGithubSetAutoMergeRequestSchema,
CheckoutGithubSetAutoMergeResponseSchema,
CheckoutPrMergeRequestSchema,
@@ -87,6 +89,47 @@ describe("checkout PR schemas", () => {
});
});
test("parses optional GitHub check identifiers on PR checks", () => {
expect(
CheckoutPrStatusSchema.parse({
number: 993,
url: "https://github.com/getpaseo/paseo/pull/993",
title: "Expose failed check logs",
state: "open",
baseRefName: "main",
headRefName: "phase-6",
isMerged: false,
checks: [
{
name: "server tests",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
checkRunId: 12345,
workflowRunId: 456,
},
{
name: "legacy context",
status: "success",
url: "https://example.com/context",
},
],
}).checks,
).toEqual([
{
name: "server tests",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
checkRunId: 12345,
workflowRunId: 456,
},
{
name: "legacy context",
status: "success",
url: "https://example.com/context",
},
]);
});
test.each(["merge", "squash", "rebase"] as const)(
"accepts %s as a PR merge method",
(mergeMethod) => {
@@ -171,6 +214,103 @@ describe("checkout PR schemas", () => {
).toEqual(payload);
});
test("accepts GitHub check details requests and responses", () => {
expect(
CheckoutGithubGetCheckDetailsRequestSchema.parse({
type: "checkout.github.get_check_details.request",
cwd: "/tmp/repo",
repoOwner: "getpaseo",
repoName: "paseo",
checkRunId: 12345,
workflowRunId: 456,
requestId: "request-check-details",
}),
).toEqual({
type: "checkout.github.get_check_details.request",
cwd: "/tmp/repo",
repoOwner: "getpaseo",
repoName: "paseo",
checkRunId: 12345,
workflowRunId: 456,
requestId: "request-check-details",
});
expect(
CheckoutGithubGetCheckDetailsResponseSchema.parse({
type: "checkout.github.get_check_details.response",
payload: {
cwd: "/tmp/repo",
success: true,
details: {
checkRunId: 12345,
workflowRunId: 456,
name: "server tests",
status: "completed",
conclusion: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
output: {
title: "Tests failed",
summary: "1 failure",
text: "Assertion failed",
},
annotations: [
{
path: "packages/server/src/index.ts",
startLine: 10,
endLine: 12,
annotationLevel: "failure",
message: "Expected true",
},
],
failedJobs: [
{
jobId: 789,
name: "test",
status: "completed",
conclusion: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
logTail: "last line",
logTruncated: false,
},
],
truncated: true,
},
error: null,
requestId: "request-check-details",
},
}).payload.details,
).toMatchObject({
checkRunId: 12345,
workflowRunId: 456,
failedJobs: [{ jobId: 789, logTail: "last line" }],
truncated: true,
});
});
test("rejects invalid GitHub check details request identities", () => {
const request = {
type: "checkout.github.get_check_details.request",
cwd: "/tmp/repo",
repoOwner: "getpaseo",
repoName: "paseo",
checkRunId: 12345,
requestId: "request-check-details",
};
expect(() =>
CheckoutGithubGetCheckDetailsRequestSchema.parse({ ...request, repoOwner: "../owner" }),
).toThrow();
expect(() =>
CheckoutGithubGetCheckDetailsRequestSchema.parse({ ...request, repoName: "" }),
).toThrow();
expect(() =>
CheckoutGithubGetCheckDetailsRequestSchema.parse({ ...request, checkRunId: 0 }),
).toThrow();
expect(() =>
CheckoutGithubGetCheckDetailsRequestSchema.parse({ ...request, workflowRunId: 1.5 }),
).toThrow();
});
test("accepts the GitHub auto-merge server_info feature flag", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
@@ -186,4 +326,18 @@ describe("checkout PR schemas", () => {
checkoutGithubSetAutoMerge: true,
});
});
test("accepts the GitHub check details server_info feature flag", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "srv_test",
features: {
githubCheckDetails: true,
},
}).features,
).toEqual({
githubCheckDetails: true,
});
});
});
@@ -126,6 +126,77 @@ describe("pull request timeline message schemas", () => {
]);
});
test("parses optional author avatar, author URL, and inline comment location fields", () => {
const parsed = PullRequestTimelineResponseSchema.parse({
type: "pull_request_timeline_response",
payload: {
items: [
{
id: "review-1",
kind: "review",
author: "octocat",
authorUrl: "https://github.com/octocat",
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
body: "Looks good",
createdAt: 1710000000000,
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
reviewState: "approved",
},
{
id: "thread-comment-1",
kind: "comment",
author: "hubot",
authorUrl: "https://github.com/hubot",
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
body: "Inline note",
createdAt: 1710000001000,
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
location: {
path: "packages/app/src/git/pull-request-panel/data.ts",
line: 24,
startLine: 20,
threadId: "PRRT_1",
isResolved: true,
isOutdated: false,
},
},
],
},
});
expect(parsed.payload.items).toEqual([
{
id: "review-1",
kind: "review",
author: "octocat",
authorUrl: "https://github.com/octocat",
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
body: "Looks good",
createdAt: 1710000000000,
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
reviewState: "approved",
},
{
id: "thread-comment-1",
kind: "comment",
author: "hubot",
authorUrl: "https://github.com/hubot",
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
body: "Inline note",
createdAt: 1710000001000,
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
location: {
path: "packages/app/src/git/pull-request-panel/data.ts",
line: 24,
startLine: 20,
threadId: "PRRT_1",
isResolved: true,
isOutdated: false,
},
},
]);
});
test("parses response through the outbound message union", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "pull_request_timeline_response",
@@ -161,6 +232,91 @@ describe("pull request timeline message schemas", () => {
expect(parsed.type).toBe("pull_request_timeline_response");
});
test("an old client timeline schema parses new daemon avatar and inline location fields", () => {
const oldClientPullRequestTimelineResponseSchema = z.object({
type: z.literal("pull_request_timeline_response"),
payload: z.object({
cwd: z.string(),
prNumber: z.number().nullable(),
items: z.array(
z.discriminatedUnion("kind", [
z.object({
id: z.string(),
kind: z.literal("review"),
author: z.string(),
body: z.string(),
createdAt: z.number(),
url: z.string(),
reviewState: z.enum(["approved", "changes_requested", "commented"]),
}),
z.object({
id: z.string(),
kind: z.literal("comment"),
author: z.string(),
body: z.string(),
createdAt: z.number(),
url: z.string(),
}),
]),
),
truncated: z.boolean(),
error: z.unknown().nullable(),
requestId: z.string(),
githubFeaturesEnabled: z.boolean(),
}),
});
const newDaemonPayload = PullRequestTimelineResponseSchema.parse({
type: "pull_request_timeline_response",
payload: {
cwd: "/tmp/repo",
prNumber: 42,
items: [
{
id: "comment-1",
kind: "comment",
author: "octocat",
authorUrl: "https://github.com/octocat",
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
body: "Inline note",
createdAt: 1710000000000,
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
location: {
path: "src/file.ts",
line: 12,
threadId: "PRRT_1",
},
},
],
truncated: false,
error: null,
requestId: "request-compat",
githubFeaturesEnabled: true,
},
});
expect(oldClientPullRequestTimelineResponseSchema.parse(newDaemonPayload)).toEqual({
type: "pull_request_timeline_response",
payload: {
cwd: "/tmp/repo",
prNumber: 42,
items: [
{
id: "comment-1",
kind: "comment",
author: "octocat",
body: "Inline note",
createdAt: 1710000000000,
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
},
],
truncated: false,
error: null,
requestId: "request-compat",
githubFeaturesEnabled: true,
},
});
});
test("normalizes future error kinds to unknown", () => {
const parsed = PullRequestTimelineResponseSchema.parse({
type: "pull_request_timeline_response",
+95
View File
@@ -1455,6 +1455,18 @@ export const CheckoutGithubSetAutoMergeRequestSchema = z.object({
requestId: z.string(),
});
const GitHubRepoSegmentSchema = z.string().regex(/^[A-Za-z0-9._-]+$/);
export const CheckoutGithubGetCheckDetailsRequestSchema = z.object({
type: z.literal("checkout.github.get_check_details.request"),
cwd: z.string(),
repoOwner: GitHubRepoSegmentSchema,
repoName: GitHubRepoSegmentSchema,
checkRunId: z.number().int().positive(),
workflowRunId: z.number().int().positive().optional(),
requestId: z.string(),
});
export const CheckoutPrStatusRequestSchema = z.object({
type: z.literal("checkout_pr_status_request"),
cwd: z.string(),
@@ -1924,6 +1936,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPrCreateRequestSchema,
CheckoutPrMergeRequestSchema,
CheckoutGithubSetAutoMergeRequestSchema,
CheckoutGithubGetCheckDetailsRequestSchema,
CheckoutPrStatusRequestSchema,
PullRequestTimelineRequestSchema,
CheckoutSwitchBranchRequestSchema,
@@ -2151,6 +2164,8 @@ export const ServerInfoStatusPayloadSchema = z
.object({
providersSnapshot: z.boolean().optional(),
checkoutGithubSetAutoMerge: z.boolean().optional(),
// COMPAT(githubCheckDetails): added in v0.1.92, remove gate after 2026-12-08.
githubCheckDetails: z.boolean().optional(),
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
@@ -3045,6 +3060,8 @@ export const CheckoutPrStatusSchema = z.object({
url: z.string().nullable(),
workflow: z.string().optional(),
duration: z.string().optional(),
checkRunId: z.number().optional(),
workflowRunId: z.number().optional(),
}),
)
.optional()
@@ -3190,6 +3207,58 @@ export const CheckoutGithubSetAutoMergeResponseSchema = z.object({
}),
});
const CheckoutGithubCheckAnnotationSchema = z.object({
path: z.string().optional(),
startLine: z.number().optional(),
endLine: z.number().optional(),
annotationLevel: z.string().optional(),
message: z.string().optional(),
title: z.string().optional(),
rawDetails: z.string().optional(),
});
const CheckoutGithubCheckJobSchema = z.object({
jobId: z.number(),
name: z.string(),
status: z.string().nullable().optional(),
conclusion: z.string().nullable().optional(),
url: z.string().nullable().optional(),
logTail: z.string().optional(),
logTruncated: z.boolean().optional(),
});
export const CheckoutGithubCheckDetailsSchema = z.object({
checkRunId: z.number(),
workflowRunId: z.number().nullable().optional(),
name: z.string(),
status: z.string().nullable().optional(),
conclusion: z.string().nullable().optional(),
url: z.string().nullable().optional(),
detailsUrl: z.string().nullable().optional(),
output: z
.object({
title: z.string().nullable().optional(),
summary: z.string().nullable().optional(),
text: z.string().nullable().optional(),
})
.nullable()
.optional(),
annotations: z.array(CheckoutGithubCheckAnnotationSchema).optional().default([]),
failedJobs: z.array(CheckoutGithubCheckJobSchema).optional().default([]),
truncated: z.boolean().optional().default(false),
});
export const CheckoutGithubGetCheckDetailsResponseSchema = z.object({
type: z.literal("checkout.github.get_check_details.response"),
payload: z.object({
cwd: z.string(),
success: z.boolean(),
details: CheckoutGithubCheckDetailsSchema.nullable().optional().default(null),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
export const CheckoutPrStatusResponseSchema = z.object({
type: z.literal("checkout_pr_status_response"),
payload: CheckoutPrStatusPayloadSchema,
@@ -3225,6 +3294,8 @@ const PullRequestTimelineReviewItemSchema = z.object({
id: z.string().optional().default(""),
kind: z.literal("review"),
author: z.string().optional().default("unknown"),
authorUrl: z.string().nullable().optional(),
avatarUrl: z.string().nullable().optional(),
body: z.string().optional().default(""),
createdAt: z.number().optional().default(0),
url: z.string().optional().default(""),
@@ -3238,9 +3309,25 @@ const PullRequestTimelineCommentItemSchema = z.object({
id: z.string().optional().default(""),
kind: z.literal("comment"),
author: z.string().optional().default("unknown"),
authorUrl: z.string().nullable().optional(),
avatarUrl: z.string().nullable().optional(),
body: z.string().optional().default(""),
createdAt: z.number().optional().default(0),
url: z.string().optional().default(""),
// GitHub review id this inline comment belongs to; lets clients nest review
// threads under their parent review. Absent on issue comments and on
// timelines from daemons that predate the field.
reviewId: z.string().optional(),
location: z
.object({
path: z.string(),
line: z.number().optional(),
startLine: z.number().optional(),
threadId: z.string().optional(),
isResolved: z.boolean().optional(),
isOutdated: z.boolean().optional(),
})
.optional(),
});
export const PullRequestTimelineItemSchema = z.preprocess(
@@ -3786,6 +3873,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPrCreateResponseSchema,
CheckoutPrMergeResponseSchema,
CheckoutGithubSetAutoMergeResponseSchema,
CheckoutGithubGetCheckDetailsResponseSchema,
CheckoutPrStatusResponseSchema,
PullRequestTimelineResponseSchema,
CheckoutSwitchBranchResponseSchema,
@@ -4057,6 +4145,13 @@ export type CheckoutGithubSetAutoMergeRequest = z.infer<
export type CheckoutGithubSetAutoMergeResponse = z.infer<
typeof CheckoutGithubSetAutoMergeResponseSchema
>;
export type CheckoutGithubGetCheckDetailsRequest = z.infer<
typeof CheckoutGithubGetCheckDetailsRequestSchema
>;
export type CheckoutGithubCheckDetails = z.infer<typeof CheckoutGithubCheckDetailsSchema>;
export type CheckoutGithubGetCheckDetailsResponse = z.infer<
typeof CheckoutGithubGetCheckDetailsResponseSchema
>;
export type PullRequestMergeable = z.infer<typeof CheckoutPrStatusSchema>["mergeable"];
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;