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:
@@ -41,14 +41,35 @@ export interface BrowserElementAttachment {
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
export type ComposerAttachment =
|
||||
export type PullRequestContextAttachmentKind =
|
||||
| "github.pull_request_comment"
|
||||
| "github.pull_request_review"
|
||||
| "github.pull_request_check";
|
||||
|
||||
interface PullRequestContextAttachmentFields {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
text: string;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export type PullRequestContextAttachment =
|
||||
| ({ kind: "github.pull_request_comment" } & PullRequestContextAttachmentFields)
|
||||
| ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields)
|
||||
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
|
||||
|
||||
export type UserComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "github_issue"; item: GitHubSearchItem }
|
||||
| { kind: "github_pr"; item: GitHubSearchItem }
|
||||
| { kind: "github_pr"; item: GitHubSearchItem };
|
||||
|
||||
export type WorkspaceComposerAttachment =
|
||||
| {
|
||||
kind: "browser_element";
|
||||
attachment: BrowserElementAttachment;
|
||||
}
|
||||
| PullRequestContextAttachment
|
||||
| {
|
||||
kind: "review";
|
||||
attachment: Extract<AgentAttachment, { type: "review" }>;
|
||||
@@ -56,15 +77,7 @@ export type ComposerAttachment =
|
||||
commentCount: number;
|
||||
};
|
||||
|
||||
export type UserComposerAttachment = Exclude<
|
||||
ComposerAttachment,
|
||||
{ kind: "review" } | { kind: "browser_element" }
|
||||
>;
|
||||
|
||||
export type WorkspaceComposerAttachment = Extract<
|
||||
ComposerAttachment,
|
||||
{ kind: "review" } | { kind: "browser_element" }
|
||||
>;
|
||||
export type ComposerAttachment = UserComposerAttachment | WorkspaceComposerAttachment;
|
||||
|
||||
export type AttachmentDataSource =
|
||||
| { kind: "bytes"; bytes: Uint8Array }
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ComposerAttachment, PullRequestContextAttachment } from "./types";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
userAttachmentsOnly,
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "./workspace-attachment-utils";
|
||||
|
||||
function contextAttachment(
|
||||
overrides: Partial<PullRequestContextAttachment> = {},
|
||||
): PullRequestContextAttachment {
|
||||
return {
|
||||
kind: "github.pull_request_comment",
|
||||
id: "comment-1",
|
||||
title: "Comment · octocat",
|
||||
subtitle: "Fix flaky build",
|
||||
text: "GitHub pull request comment\n\nLooks good.",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace attachment utilities", () => {
|
||||
it("treats pull request context as a workspace attachment", () => {
|
||||
expect(isWorkspaceAttachment(contextAttachment())).toBe(true);
|
||||
});
|
||||
|
||||
it("strips context attachments from user draft attachments", () => {
|
||||
const normalAttachment: ComposerAttachment = {
|
||||
kind: "github_issue",
|
||||
item: {
|
||||
kind: "issue",
|
||||
number: 12,
|
||||
title: "Bug",
|
||||
url: "https://github.com/getpaseo/paseo/issues/12",
|
||||
state: "open",
|
||||
body: "Bug report",
|
||||
labels: [],
|
||||
baseRefName: null,
|
||||
headRefName: null,
|
||||
},
|
||||
};
|
||||
|
||||
expect(userAttachmentsOnly([normalAttachment, contextAttachment()])).toEqual([
|
||||
normalAttachment,
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes context attachments as protocol text attachments", () => {
|
||||
expect(workspaceAttachmentToSubmitAttachment(contextAttachment())).toEqual({
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "Comment · octocat",
|
||||
text: "GitHub pull request comment\n\nLooks good.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,29 @@
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
PullRequestContextAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
|
||||
export function isPullRequestContextAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is PullRequestContextAttachment {
|
||||
return (
|
||||
attachment?.kind === "github.pull_request_comment" ||
|
||||
attachment?.kind === "github.pull_request_review" ||
|
||||
attachment?.kind === "github.pull_request_check"
|
||||
);
|
||||
}
|
||||
|
||||
export function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||
return (
|
||||
attachment?.kind === "review" ||
|
||||
attachment?.kind === "browser_element" ||
|
||||
isPullRequestContextAttachment(attachment)
|
||||
);
|
||||
}
|
||||
|
||||
export function userAttachmentsOnly(
|
||||
@@ -16,7 +31,9 @@ export function userAttachmentsOnly(
|
||||
): UserComposerAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||
attachment.kind !== "review" &&
|
||||
attachment.kind !== "browser_element" &&
|
||||
!isPullRequestContextAttachment(attachment),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,5 +48,13 @@ export function workspaceAttachmentToSubmitAttachment(
|
||||
text: attachment.attachment.formatted,
|
||||
};
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: attachment.title,
|
||||
text: attachment.text,
|
||||
};
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceComposerAttachment } from "./types";
|
||||
import {
|
||||
appendWorkspaceAttachment,
|
||||
buildWorkspaceAttachmentScopeKey,
|
||||
resetWorkspaceAttachmentsStore,
|
||||
useWorkspaceAttachmentsStore,
|
||||
@@ -46,6 +47,16 @@ function reviewAttachment(body: string): WorkspaceComposerAttachment {
|
||||
};
|
||||
}
|
||||
|
||||
function contextAttachment(id: string): WorkspaceComposerAttachment {
|
||||
return {
|
||||
kind: "github.pull_request_comment",
|
||||
id,
|
||||
title: "Comment · octocat",
|
||||
text: "GitHub pull request comment\n\nLooks good.",
|
||||
url: `https://github.com/getpaseo/paseo/pull/42#${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace attachments store", () => {
|
||||
it("scopes workspace attachments by server and workspace before cwd fallback", () => {
|
||||
expect(
|
||||
@@ -86,4 +97,45 @@ describe("workspace attachments store", () => {
|
||||
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("appends unique context attachments without dropping other workspace attachments", () => {
|
||||
const review = reviewAttachment("Please simplify this.");
|
||||
const context = contextAttachment("comment-1");
|
||||
|
||||
expect(appendWorkspaceAttachment([review], context)).toEqual([review, context]);
|
||||
});
|
||||
|
||||
it("dedupes repeated context attachments by provider, source, and id", () => {
|
||||
const original = contextAttachment("comment-1");
|
||||
const replacement = {
|
||||
...contextAttachment("comment-1"),
|
||||
title: "Comment · octocat updated",
|
||||
text: "Updated text",
|
||||
};
|
||||
|
||||
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
|
||||
});
|
||||
|
||||
it("adds a workspace attachment against the current scope state", () => {
|
||||
resetWorkspaceAttachmentsStore();
|
||||
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
const review = reviewAttachment("Please simplify this.");
|
||||
const context = contextAttachment("comment-1");
|
||||
|
||||
const addWorkspaceAttachment = useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment;
|
||||
useWorkspaceAttachmentsStore
|
||||
.getState()
|
||||
.setWorkspaceAttachments({ scopeKey, attachments: [review] });
|
||||
|
||||
addWorkspaceAttachment({ scopeKey, attachment: context });
|
||||
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toEqual([
|
||||
review,
|
||||
context,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,10 @@ interface WorkspaceAttachmentsStoreActions {
|
||||
scopeKey: string;
|
||||
attachments: readonly WorkspaceComposerAttachment[];
|
||||
}) => void;
|
||||
addWorkspaceAttachment: (input: {
|
||||
scopeKey: string;
|
||||
attachment: WorkspaceComposerAttachment;
|
||||
}) => void;
|
||||
clearWorkspaceAttachments: (input: { scopeKey: string }) => void;
|
||||
}
|
||||
|
||||
@@ -60,6 +64,35 @@ function areWorkspaceAttachmentsEqual(
|
||||
return left.every((attachment, index) => attachment === right[index]);
|
||||
}
|
||||
|
||||
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
|
||||
if (
|
||||
attachment.kind !== "github.pull_request_comment" &&
|
||||
attachment.kind !== "github.pull_request_review" &&
|
||||
attachment.kind !== "github.pull_request_check"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
kind: attachment.kind,
|
||||
id: attachment.id,
|
||||
});
|
||||
}
|
||||
|
||||
export function appendWorkspaceAttachment(
|
||||
current: readonly WorkspaceComposerAttachment[],
|
||||
attachment: WorkspaceComposerAttachment,
|
||||
): WorkspaceComposerAttachment[] {
|
||||
const contextKey = getContextAttachmentKey(attachment);
|
||||
if (contextKey === null) {
|
||||
return [...current, attachment];
|
||||
}
|
||||
|
||||
const next = current.filter(
|
||||
(currentAttachment) => getContextAttachmentKey(currentAttachment) !== contextKey,
|
||||
);
|
||||
return [...next, attachment];
|
||||
}
|
||||
|
||||
export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()((set) => ({
|
||||
attachmentsByScope: {},
|
||||
setWorkspaceAttachments: ({ scopeKey, attachments }) => {
|
||||
@@ -84,6 +117,21 @@ export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()(
|
||||
};
|
||||
});
|
||||
},
|
||||
addWorkspaceAttachment: ({ scopeKey, attachment }) => {
|
||||
set((state) => {
|
||||
const current = state.attachmentsByScope[scopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS;
|
||||
const attachments = appendWorkspaceAttachment(current, attachment);
|
||||
if (areWorkspaceAttachmentsEqual(current, attachments)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
attachmentsByScope: {
|
||||
...state.attachmentsByScope,
|
||||
[scopeKey]: attachments,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
clearWorkspaceAttachments: ({ scopeKey }) => {
|
||||
set((state) => {
|
||||
if (!state.attachmentsByScope[scopeKey]) {
|
||||
|
||||
Reference in New Issue
Block a user