Files
paseo/packages/app/src/composer/attachments/workspace.tsx
T
Mohamed BoudraandGitHub 3163e212fb 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
2026-06-12 13:43:01 +08:00

374 lines
12 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native";
import type {
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import { AttachmentPill } from "@/components/attachment-pill";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import {
isWorkspaceAttachment,
isPullRequestContextAttachment,
userAttachmentsOnly,
workspaceAttachmentToSubmitAttachment,
} from "@/attachments/workspace-attachment-utils";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useClearReviewDraft } from "@/review/store";
type TranslationFn = ReturnType<typeof useTranslation>["t"];
interface WorkspaceAttachmentBindingInput {
normalAttachments: UserComposerAttachment[];
workspaceAttachments: readonly WorkspaceComposerAttachment[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
}
interface RemoveWorkspaceAttachmentInput {
selectedAttachments: readonly ComposerAttachment[];
index: number;
}
interface OpenWorkspaceAttachmentInput {
attachment: ComposerAttachment;
}
interface CompleteSubmitInput {
result: "noop" | "queued" | "submitted" | "failed";
outgoingAttachments: readonly ComposerAttachment[];
}
interface ComposerWorkspaceAttachmentBinding {
selectedAttachments: ComposerAttachment[];
buildOutgoingAttachments: (normalAttachments: UserComposerAttachment[]) => ComposerAttachment[];
removeAttachment: (input: RemoveWorkspaceAttachmentInput) => boolean;
openAttachment: (input: OpenWorkspaceAttachmentInput) => boolean;
clearSentAttachments: (attachments: readonly ComposerAttachment[]) => void;
completeSubmit: (input: CompleteSubmitInput) => void;
resetSuppression: () => void;
}
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "browser_element") {
return JSON.stringify({
type: "browser_element",
url: attachment.attachment.url,
selector: attachment.attachment.selector,
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
});
}
if (isPullRequestContextAttachment(attachment)) {
return JSON.stringify({
kind: attachment.kind,
id: attachment.id,
});
}
return JSON.stringify({
type: "review",
cwd: attachment.attachment.cwd,
mode: attachment.attachment.mode,
baseRef: attachment.attachment.baseRef ?? null,
reviewDraftKey: attachment.reviewDraftKey,
comments: attachment.attachment.comments.map((comment) => ({
filePath: comment.filePath,
side: comment.side,
lineNumber: comment.lineNumber,
body: comment.body,
})),
});
}
function removeWorkspaceAttachmentsMatching(selectedKey: string): void {
const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState();
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
const nextAttachments = attachments.filter(
(attachment) => getAttachmentKey(attachment) !== selectedKey,
);
if (nextAttachments.length !== attachments.length) {
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
}
}
}
function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void {
const sentContextKeys = attachments.filter(isPullRequestContextAttachment).map(getAttachmentKey);
for (const key of sentContextKeys) {
removeWorkspaceAttachmentsMatching(key);
}
}
function getContextSourceLabel(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "github.pull_request_check") {
return "Check logs";
}
if (attachment.kind === "github.pull_request_comment") {
return "Comment";
}
return "Review";
}
function getPillLabel(attachment: WorkspaceComposerAttachment, t: TranslationFn): string {
if (attachment.kind === "browser_element") {
return t("composer.attachments.browserElement", { tag: attachment.attachment.tag });
}
if (isPullRequestContextAttachment(attachment)) {
return `${getContextSourceLabel(attachment)} · ${attachment.title}`;
}
return attachment.commentCount === 1
? t("message.attachments.reviewOne")
: t("message.attachments.reviewMany", { count: attachment.commentCount });
}
function getOpenAccessibilityLabel(
attachment: WorkspaceComposerAttachment,
t: TranslationFn,
): string {
if (attachment.kind === "browser_element") {
return t("composer.attachments.openBrowserElement");
}
if (isPullRequestContextAttachment(attachment)) {
return "Open context attachment";
}
return t("composer.attachments.openReview");
}
function getRemoveAccessibilityLabel(
attachment: WorkspaceComposerAttachment,
t: TranslationFn,
): string {
if (attachment.kind === "browser_element") {
return t("composer.attachments.removeBrowserElement");
}
if (isPullRequestContextAttachment(attachment)) {
return "Remove context attachment";
}
return t("composer.attachments.removeReview");
}
function renderPillIcon(attachment: WorkspaceComposerAttachment): ReactElement {
if (attachment.kind === "browser_element") {
return <ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
}
if (isPullRequestContextAttachment(attachment)) {
return <ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
}
return <ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
}
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
return (
<WorkspaceAttachmentPill
key={`workspace:${getAttachmentKey(args.attachment)}`}
{...args}
attachment={args.attachment}
/>
);
}
function useWorkspaceAttachmentBinding({
normalAttachments,
workspaceAttachments,
onOpenWorkspaceAttachment,
}: WorkspaceAttachmentBindingInput): ComposerWorkspaceAttachmentBinding {
const clearReviewDraft = useClearReviewDraft();
const [suppressedKeys, setSuppressedKeys] = useState<readonly string[]>([]);
const workspaceAttachmentKeys = useMemo(
() => workspaceAttachments.map(getAttachmentKey),
[workspaceAttachments],
);
const activeWorkspaceAttachments = useMemo(
() =>
workspaceAttachments.filter(
(attachment, index) => !suppressedKeys.includes(workspaceAttachmentKeys[index] ?? ""),
),
[suppressedKeys, workspaceAttachmentKeys, workspaceAttachments],
);
const selectedAttachments = useMemo<ComposerAttachment[]>(
() =>
activeWorkspaceAttachments.length > 0
? [...normalAttachments, ...activeWorkspaceAttachments]
: normalAttachments,
[activeWorkspaceAttachments, normalAttachments],
);
useEffect(() => {
setSuppressedKeys((current) => {
const next = current.filter((suppressedKey) =>
workspaceAttachmentKeys.includes(suppressedKey),
);
return next.length === current.length ? current : next;
});
}, [workspaceAttachmentKeys]);
const buildOutgoingAttachments = useCallback(
(attachments: UserComposerAttachment[]): ComposerAttachment[] =>
activeWorkspaceAttachments.length > 0
? [...attachments, ...activeWorkspaceAttachments]
: attachments,
[activeWorkspaceAttachments],
);
const suppressWorkspaceAttachment = useCallback((attachment: WorkspaceComposerAttachment) => {
const key = getAttachmentKey(attachment);
setSuppressedKeys((current) => (current.includes(key) ? current : [...current, key]));
}, []);
const clearSentAttachments = useCallback(
(attachments: readonly ComposerAttachment[]) => {
for (const attachment of attachments) {
if (attachment.kind === "review") {
clearReviewDraft({ key: attachment.reviewDraftKey });
}
}
removeSentContextAttachments(attachments);
},
[clearReviewDraft],
);
const removeAttachment = useCallback(
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
const selected = current[index];
if (isWorkspaceAttachment(selected)) {
if (selected.kind === "browser_element" || isPullRequestContextAttachment(selected)) {
const selectedKey = getAttachmentKey(selected);
removeWorkspaceAttachmentsMatching(selectedKey);
return true;
}
suppressWorkspaceAttachment(selected);
return true;
}
return false;
},
[suppressWorkspaceAttachment],
);
const openAttachment = useCallback(
({ attachment }: OpenWorkspaceAttachmentInput) => {
if (!isWorkspaceAttachment(attachment) || attachment.kind !== "review") {
return false;
}
onOpenWorkspaceAttachment?.(attachment);
return true;
},
[onOpenWorkspaceAttachment],
);
const resetSuppression = useCallback(() => {
setSuppressedKeys([]);
}, []);
const completeSubmit = useCallback(
({ result, outgoingAttachments }: CompleteSubmitInput) => {
if (result === "submitted") {
clearSentAttachments(outgoingAttachments);
}
if (result === "queued" || result === "submitted") {
resetSuppression();
}
},
[clearSentAttachments, resetSuppression],
);
return {
selectedAttachments,
buildOutgoingAttachments,
removeAttachment,
openAttachment,
clearSentAttachments,
completeSubmit,
resetSuppression,
};
}
interface RenderWorkspaceAttachmentPillArgs {
attachment: WorkspaceComposerAttachment;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
}
interface WorkspaceAttachmentPillProps extends Omit<
RenderWorkspaceAttachmentPillArgs,
"attachment"
> {
attachment: WorkspaceComposerAttachment;
}
function WorkspaceAttachmentPill({
attachment,
index,
disabled,
onOpen,
onRemove,
}: WorkspaceAttachmentPillProps) {
const { t } = useTranslation();
const label = getPillLabel(attachment, t);
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
return (
<AttachmentPill
testID="composer-review-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={getOpenAccessibilityLabel(attachment, t)}
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
disabled={disabled}
>
<View style={styles.pillBody}>
<View style={styles.pillIcon}>{renderPillIcon(attachment)}</View>
<Text style={styles.pillText} numberOfLines={1}>
{label}
</Text>
</View>
</AttachmentPill>
);
}
export const composerWorkspaceAttachment = {
is: isWorkspaceAttachment,
renderPill,
toSubmitAttachment: workspaceAttachmentToSubmitAttachment,
userAttachmentsOnly,
useBinding: useWorkspaceAttachmentBinding,
};
const styles = StyleSheet.create((theme: Theme) => ({
pillBody: {
minHeight: 48,
maxWidth: 260,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
pillIcon: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
pillText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
})) as unknown as Record<string, object>;
const ThemedMousePointer2 = withUnistyles(MousePointer2);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const ThemedFileText = withUnistyles(FileText);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });