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]) {
|
||||
|
||||
@@ -13,9 +13,12 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { PrPane } from "@/git/pr-pane";
|
||||
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
|
||||
import {
|
||||
formatPrTabLabel,
|
||||
PullRequestPane,
|
||||
PullRequestTabIcon,
|
||||
usePrPaneData,
|
||||
} from "@/git/pull-request-panel";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsFileExplorerOpen,
|
||||
@@ -33,6 +36,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
@@ -433,7 +437,11 @@ function SidebarContent({
|
||||
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab =
|
||||
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
|
||||
const prTabLabel = prPane.prNumber === null ? "" : `#${prPane.prNumber}`;
|
||||
const prTabLabel = formatPrTabLabel(prPane.prNumber);
|
||||
const workspaceAttachmentScopeKey = useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
|
||||
[serverId, workspaceId, workspaceRoot],
|
||||
);
|
||||
|
||||
const headerStyle = useMemo(
|
||||
() => [styles.header, { paddingRight: padding.right }],
|
||||
@@ -470,7 +478,7 @@ function SidebarContent({
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-pr"
|
||||
>
|
||||
<GitHubIcon
|
||||
<PullRequestTabIcon
|
||||
size={13}
|
||||
color={
|
||||
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
@@ -507,7 +515,14 @@ function SidebarContent({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "pr" && prPane.data && <PrPane data={prPane.data} />}
|
||||
{resolvedTab === "pr" && prPane.data && (
|
||||
<PullRequestPane
|
||||
serverId={serverId}
|
||||
cwd={workspaceRoot}
|
||||
data={prPane.data}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import Markdown, {
|
||||
type ASTNode,
|
||||
MarkdownIt,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
@@ -33,9 +22,6 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||
@@ -60,8 +46,6 @@ interface FilePreviewBodyProps {
|
||||
imagePreviewUri: string | null;
|
||||
}
|
||||
|
||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -132,299 +116,6 @@ function clampLineSelection(input: {
|
||||
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
||||
}
|
||||
|
||||
interface MarkdownInheritedTextProps {
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
style?: TextStyle;
|
||||
monoSurface?: boolean;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownInheritedText({
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
style: overrideStyle,
|
||||
monoSurface,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
children,
|
||||
}: MarkdownInheritedTextProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
return (
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
contentStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||
|
||||
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
|
||||
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface MarkdownListViewProps {
|
||||
baseStyle: ViewStyle;
|
||||
spacing: { marginTop: number; marginBottom: number };
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
|
||||
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface FilePreviewMarkdownLinkProps {
|
||||
href: string;
|
||||
inheritedStyles: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function FilePreviewMarkdownLink({
|
||||
href,
|
||||
inheritedStyles,
|
||||
linkStyle,
|
||||
onLinkPress,
|
||||
children,
|
||||
}: FilePreviewMarkdownLinkProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
if (!href) return;
|
||||
if (onLinkPress?.(href) === false) return;
|
||||
void openExternalUrl(href);
|
||||
}, [href, onLinkPress]);
|
||||
|
||||
return (
|
||||
<MarkdownInheritedText
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={linkStyle}
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownLinkHref(node: ASTNode): string {
|
||||
const href = node.attributes?.href;
|
||||
return typeof href === "string" ? href : "";
|
||||
}
|
||||
|
||||
function createFilePreviewMarkdownRules(): RenderRules {
|
||||
return {
|
||||
text: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
textgroup: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.textgroup}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={node.sourceInfo}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
>
|
||||
{node.content ?? ""}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
list_item: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
link: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
onLinkPress?: (url: string) => boolean,
|
||||
) => (
|
||||
<FilePreviewMarkdownLink
|
||||
key={node.key}
|
||||
href={getMarkdownLinkHref(node)}
|
||||
inheritedStyles={EMPTY_TEXT_STYLE}
|
||||
linkStyle={styles.link}
|
||||
onLinkPress={onLinkPress}
|
||||
>
|
||||
{children}
|
||||
</FilePreviewMarkdownLink>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const CodeLine = React.memo(function CodeLine({
|
||||
tokens,
|
||||
lineNumber,
|
||||
@@ -506,9 +197,6 @@ function FilePreviewBody({
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||
const markdownRules = useMemo(() => createFilePreviewMarkdownRules(), []);
|
||||
const isMarkdownFile =
|
||||
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
||||
|
||||
@@ -591,11 +279,7 @@ function FilePreviewBody({
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
>
|
||||
<AppearanceStyleBoundary>
|
||||
<Markdown style={markdownStyles} rules={markdownRules} markdownit={markdownParser}>
|
||||
{preview.content ?? ""}
|
||||
</Markdown>
|
||||
</AppearanceStyleBoundary>
|
||||
<MarkdownRenderer text={preview.content ?? ""} />
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
|
||||
@@ -39,7 +39,7 @@ interface MarkdownParagraphViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// Paragraph stays a <View>, not a <Text>, for layout fidelity. RN Android's
|
||||
// text engine *does* accept inline View children (TextInlineViewPlaceholderSpan
|
||||
|
||||
@@ -49,7 +49,7 @@ interface MarkdownParagraphViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// iOS-only: paragraph wraps in UITextView so the entire paragraph is one
|
||||
// native text view. That's what unlocks cross-inline drag selection — handles
|
||||
|
||||
@@ -50,7 +50,7 @@ interface MarkdownParagraphViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// Same shape as Android — paragraph is a View so block-level children (images)
|
||||
// keep their natural layout. Web text selection already spans nested inline
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
|
||||
|
||||
describe("splitHtmlishMarkdown", () => {
|
||||
const inlineImageBody = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
|
||||
"",
|
||||
"The middleware now trusts any browser-like User-Agent for unauthenticated requests.",
|
||||
"",
|
||||
"```ts",
|
||||
'const isBrowser = userAgent.includes("Mozilla");',
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
const multiDetailsBody = [
|
||||
"### Bot Review",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary><h3>Important Files Changed</h3></summary>",
|
||||
"",
|
||||
"- `packages/server/src/server/session.ts`",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary><h3>Security Findings</h3></summary>",
|
||||
"",
|
||||
"No blocking findings.",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
"<!-- bot_other_comments_section -->",
|
||||
'<sub>Reviews (8): Last reviewed commit: “revert: undo parser” | <a href="https://app.greptile.com">Re-trigger Greptile</a></sub>',
|
||||
].join("\n");
|
||||
|
||||
it("classifies linked HTML images as generic inline images, not block markdown images", () => {
|
||||
const [image, text] = splitHtmlishMarkdown(inlineImageBody);
|
||||
|
||||
expect(image).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/assets/priority.svg?v=9",
|
||||
flowsWithText: true,
|
||||
});
|
||||
expect(text).toEqual({
|
||||
kind: "markdown",
|
||||
text: [
|
||||
" Spoofed browser User-Agent allows access control bypass",
|
||||
"",
|
||||
"The middleware now trusts any browser-like User-Agent for unauthenticated requests.",
|
||||
"",
|
||||
"```ts",
|
||||
'const isBrowser = userAgent.includes("Mozilla");',
|
||||
"```",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("flags both images as flowsWithText when two inline images precede title text on the same line", () => {
|
||||
const twoImageBody = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/priority.svg" align="top"></a> <a href="#"><img alt="Security" src="https://example.com/security.svg" align="top"></a> **Title text here**',
|
||||
"",
|
||||
"Body paragraph.",
|
||||
].join("\n");
|
||||
|
||||
const parts = splitHtmlishMarkdown(twoImageBody);
|
||||
const [first, second, third] = parts;
|
||||
|
||||
expect(first).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/priority.svg",
|
||||
flowsWithText: true,
|
||||
});
|
||||
expect(second).toEqual({ kind: "markdown", text: " " });
|
||||
expect(third).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Security",
|
||||
src: "https://example.com/security.svg",
|
||||
flowsWithText: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps safe width and height attributes on inline images", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<img alt="Small" src="https://example.com/small.svg" width="18" height="12">',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Small",
|
||||
src: "https://example.com/small.svg",
|
||||
width: 18,
|
||||
height: 12,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps safe non-empty image links", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<a href="https://example.com/details"><img alt="Small" src="https://example.com/small.svg"></a>',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Small",
|
||||
src: "https://example.com/small.svg",
|
||||
href: "https://example.com/details",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves inline image parts inside details bodies", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<details><summary>Images</summary><a href="https://example.com/page"><img alt="Icon" src="https://example.com/icon.svg"></a> Inline text</details>',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "details",
|
||||
summary: "Images",
|
||||
body: "Inline text",
|
||||
bodyParts: [
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Icon",
|
||||
src: "https://example.com/icon.svg",
|
||||
href: "https://example.com/page",
|
||||
flowsWithText: true,
|
||||
},
|
||||
{ kind: "markdown", text: " Inline text" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not flag standalone inline images as flowing with text", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown('<img alt="Shot" src="https://example.com/shot.png">\n\nCaption below'),
|
||||
).toEqual([
|
||||
{ kind: "inlineImage", alt: "Shot", src: "https://example.com/shot.png" },
|
||||
{ kind: "markdown", text: "\n\nCaption below" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not flag mid-line inline images as flowing with text", () => {
|
||||
const [, image] = splitHtmlishMarkdown(
|
||||
'Before <img alt="Icon" src="https://example.com/icon.png"> after',
|
||||
);
|
||||
|
||||
expect(image).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Icon",
|
||||
src: "https://example.com/icon.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves ordinary markdown images on the markdown path", () => {
|
||||
const source = "";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("leaves unsafe HTML image sources inert", () => {
|
||||
const source = '<img alt="Bad" src="javascript:alert(1)">';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("removes raw image anchor and image tags from rendered markdown text", () => {
|
||||
const text = splitHtmlishMarkdown(inlineImageBody)
|
||||
.map((part) => (part.kind === "markdown" ? part.text : ""))
|
||||
.join("");
|
||||
|
||||
expect(text).not.toContain("<a ");
|
||||
expect(text).not.toContain("<img ");
|
||||
expect(text).not.toContain("</a>");
|
||||
});
|
||||
|
||||
it("unwraps sub text and strips HTML comments", () => {
|
||||
const parts = splitHtmlishMarkdown(multiDetailsBody);
|
||||
const tail = parts.at(-1);
|
||||
|
||||
expect(tail).toEqual({
|
||||
kind: "markdown",
|
||||
text: "\n\nReviews (8): Last reviewed commit: “revert: undo parser” | [Re-trigger Greptile](https://app.greptile.com)",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leak stray closing details tags across multiple details blocks", () => {
|
||||
const renderedText = splitHtmlishMarkdown(multiDetailsBody)
|
||||
.map((part) => {
|
||||
if (part.kind === "markdown") return part.text;
|
||||
if (part.kind === "details") return `${part.summary}\n${part.body}`;
|
||||
return part.alt;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
expect(renderedText).not.toContain("</details>");
|
||||
expect(renderedText).not.toContain("<!--");
|
||||
expect(splitHtmlishMarkdown(multiDetailsBody).slice(0, 4)).toMatchObject([
|
||||
{ kind: "markdown", text: "### Bot Review\n\n" },
|
||||
{ kind: "details", summary: "Important Files Changed" },
|
||||
{ kind: "markdown", text: "\n\n" },
|
||||
{ kind: "details", summary: "Security Findings" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves paragraph boundaries around title, prose, and code block", () => {
|
||||
const text = splitHtmlishMarkdown(inlineImageBody)
|
||||
.map((part) => (part.kind === "markdown" ? part.text : ""))
|
||||
.join("");
|
||||
|
||||
expect(text).toContain(
|
||||
"Spoofed browser User-Agent allows access control bypass\n\nThe middleware now trusts",
|
||||
);
|
||||
expect(text).toContain("unauthenticated requests.\n\n```ts\nconst isBrowser");
|
||||
});
|
||||
|
||||
it("keeps product-specific image language out of parser and renderer logic", () => {
|
||||
const parserSource = readFileSync(new URL("./html-ish.ts", import.meta.url), "utf8");
|
||||
const rendererSource = readFileSync(new URL("./renderer.tsx", import.meta.url), "utf8");
|
||||
const productionSource = `${parserSource}\n${rendererSource}`;
|
||||
|
||||
expect(productionSource).not.toMatch(/Greptile|badge|P0|P1|P2|greptile-static-assets/i);
|
||||
});
|
||||
|
||||
it("parses Greptile-style details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
"<details><summary><h3>Greptile Summary</h3></summary>Body markdown</details>",
|
||||
),
|
||||
).toEqual([{ kind: "details", summary: "Greptile Summary", body: "Body markdown" }]);
|
||||
});
|
||||
|
||||
it("keeps details inside fenced code as literal markdown", () => {
|
||||
const source = "```html\n<details><summary>Example</summary>Body</details>\n```";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("keeps details inside inline code as literal markdown", () => {
|
||||
const source = "Use `<details><summary>Example</summary>Body</details>` as an example.";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("still parses normal details outside code", () => {
|
||||
expect(splitHtmlishMarkdown("`code`\n<details><summary>Real</summary>Body</details>")).toEqual([
|
||||
{ kind: "markdown", text: "`code`\n" },
|
||||
{ kind: "details", summary: "Real", body: "Body" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves text before and after details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown("Before\n<details><summary>More</summary>Hidden</details>\nAfter"),
|
||||
).toEqual([
|
||||
{ kind: "markdown", text: "Before\n" },
|
||||
{ kind: "details", summary: "More", body: "Hidden" },
|
||||
{ kind: "markdown", text: "\nAfter" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses multiple details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
"<details><summary>One</summary>A</details><details><summary>Two</summary>B</details>",
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "details", summary: "One", body: "A" },
|
||||
{ kind: "details", summary: "Two", body: "B" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes br and simple code tags into markdown", () => {
|
||||
expect(normalizeHtmlishMarkdown("Line 1<br/>Line 2 <code>safe-value</code>")).toBe(
|
||||
"Line 1\nLine 2 `safe-value`",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves complex code tags inert instead of parsing HTML", () => {
|
||||
expect(normalizeHtmlishMarkdown('<code onclick="evil()"><script>x</script></code>')).toBe(
|
||||
'<code onclick="evil()"><script>x</script></code>',
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to inert markdown when details are unclosed", () => {
|
||||
const source = "<details><summary>Open</summary>Still open";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("falls back to inert markdown when summary is missing", () => {
|
||||
const source = "<details>Hidden</details>";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("does not execute or render unknown HTML as structured content", () => {
|
||||
const source =
|
||||
'<script>alert(1)</script><details onclick="evil()"><summary>Safe</summary><iframe src="x"></iframe></details>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{ kind: "markdown", text: "<script>alert(1)</script>" },
|
||||
{ kind: "details", summary: "Safe", body: '<iframe src="x"></iframe>' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,705 @@
|
||||
import { Parser } from "htmlparser2";
|
||||
|
||||
export interface MarkdownTextPart {
|
||||
kind: "markdown";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MarkdownDetailsPart {
|
||||
kind: "details";
|
||||
summary: string;
|
||||
body: string;
|
||||
bodyParts?: MarkdownDisplayPart[];
|
||||
}
|
||||
|
||||
export interface MarkdownInlineImagePart {
|
||||
kind: "inlineImage";
|
||||
alt: string;
|
||||
src: string;
|
||||
href?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** The image starts a line and text continues on that same line. */
|
||||
flowsWithText?: boolean;
|
||||
}
|
||||
|
||||
export type MarkdownDisplayPart = MarkdownTextPart | MarkdownDetailsPart | MarkdownInlineImagePart;
|
||||
|
||||
const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
|
||||
const BACKTICK_RUN_RE = /`+/g;
|
||||
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
|
||||
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
|
||||
const VOID_HTML_TAGS = new Set(["br", "img"]);
|
||||
|
||||
interface ProtectedMarkdownRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MarkdownImageDimensions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface HtmlTextToken {
|
||||
kind: "text";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface HtmlCommentToken {
|
||||
kind: "comment";
|
||||
}
|
||||
|
||||
interface HtmlTagToken {
|
||||
kind: "tag";
|
||||
name: string;
|
||||
closing: boolean;
|
||||
selfClosing: boolean;
|
||||
attributes: Record<string, string>;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
type HtmlToken = HtmlTextToken | HtmlCommentToken | HtmlTagToken;
|
||||
|
||||
interface InlineImageParseResult {
|
||||
part: MarkdownInlineImagePart;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MarkdownDelimiterMatch {
|
||||
index: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface HtmlishTokenParser {
|
||||
write(chunk: string): void;
|
||||
skip(length: number): void;
|
||||
end(): void;
|
||||
}
|
||||
|
||||
export function splitHtmlishMarkdown(source: string): MarkdownDisplayPart[] {
|
||||
return splitHtmlishTokens(tokenizeHtmlishMarkdown(source));
|
||||
}
|
||||
|
||||
function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
|
||||
const parts: MarkdownDisplayPart[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor];
|
||||
if (isOpenTag(token, "details")) {
|
||||
const closeIndex = findMatchingClose(tokens, cursor, "details");
|
||||
if (closeIndex !== null) {
|
||||
const details = parseDetailsTokens(tokens.slice(cursor + 1, closeIndex));
|
||||
if (details) {
|
||||
parts.push(details);
|
||||
cursor = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineImage = parseInlineImageAt(tokens, cursor);
|
||||
if (inlineImage) {
|
||||
parts.push(
|
||||
flowsWithFollowingText(tokens, cursor, inlineImage.end)
|
||||
? { ...inlineImage.part, flowsWithText: true }
|
||||
: inlineImage.part,
|
||||
);
|
||||
cursor = inlineImage.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
|
||||
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
|
||||
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
|
||||
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseInlineImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null {
|
||||
const token = tokens[start];
|
||||
if (token?.kind !== "tag" || token.closing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token.name === "img") {
|
||||
const image = imageTokenToInlineImage(token, undefined);
|
||||
return image ? { part: image, end: start + 1 } : null;
|
||||
}
|
||||
|
||||
if (token.name !== "a") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeIndex = findMatchingClose(tokens, start, "a");
|
||||
if (closeIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = getSingleImageChild(tokens.slice(start + 1, closeIndex));
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlineImage = imageTokenToInlineImage(image, safeHref(token.attributes.href));
|
||||
return inlineImage ? { part: inlineImage, end: closeIndex + 1 } : null;
|
||||
}
|
||||
|
||||
function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number): boolean {
|
||||
const previous = tokens[start - 1];
|
||||
// At line start if nothing precedes this image, or the preceding token ends with only
|
||||
// whitespace since the last newline (covers a bare space between two images on the same line).
|
||||
const atLineStart =
|
||||
previous === undefined ||
|
||||
(previous.kind === "text" && /(?:^|[\n\r])[ \t]*$/.test(previous.value));
|
||||
if (!atLineStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scan forward past same-line whitespace-only text tokens and inline images to find
|
||||
// the first substantive text token, without crossing a newline.
|
||||
let cursor = end;
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor];
|
||||
if (token === undefined) {
|
||||
break;
|
||||
}
|
||||
if (token.kind === "text") {
|
||||
const sameLine = token.value.split(/\r?\n/, 1)[0] ?? "";
|
||||
if (sameLine.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
// Whitespace-only on this line — keep scanning only if no newline was crossed.
|
||||
if (token.value.includes("\n") || token.value.includes("\r")) {
|
||||
return false;
|
||||
}
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
// An inline image tag — skip over it (the image itself and its possible wrapping close tag).
|
||||
if (token.kind === "tag") {
|
||||
const imageResult = parseInlineImageAt(tokens, cursor);
|
||||
if (imageResult) {
|
||||
cursor = imageResult.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (parseInlineImageAt(tokens, index)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const previous = parts.at(-1);
|
||||
if (previous?.kind === "markdown") {
|
||||
previous.text += text;
|
||||
return;
|
||||
}
|
||||
parts.push({ kind: "markdown", text });
|
||||
}
|
||||
|
||||
function parseDetailsTokens(tokens: HtmlToken[]): MarkdownDetailsPart | null {
|
||||
const summaryOpenIndex = findNextOpenTag(tokens, 0, "summary");
|
||||
if (summaryOpenIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryCloseIndex = findMatchingClose(tokens, summaryOpenIndex, "summary");
|
||||
if (summaryCloseIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryTokens = tokens.slice(summaryOpenIndex + 1, summaryCloseIndex);
|
||||
const bodyTokens = [...tokens.slice(0, summaryOpenIndex), ...tokens.slice(summaryCloseIndex + 1)];
|
||||
const summary = renderSummaryTokens(summaryTokens).trim();
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bodyParts = splitHtmlishTokens(bodyTokens);
|
||||
const body = renderBodyText(bodyParts);
|
||||
|
||||
return {
|
||||
kind: "details",
|
||||
summary,
|
||||
body: body.trim(),
|
||||
...(bodyParts.some((part) => part.kind !== "markdown")
|
||||
? { bodyParts: trimBodyParts(bodyParts) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function renderBodyText(parts: MarkdownDisplayPart[]): string {
|
||||
return parts.map((part) => (part.kind === "markdown" ? part.text : "")).join("");
|
||||
}
|
||||
|
||||
function trimBodyParts(parts: MarkdownDisplayPart[]): MarkdownDisplayPart[] {
|
||||
const trimmed = [...parts];
|
||||
const first = trimmed[0];
|
||||
if (first?.kind === "markdown") {
|
||||
first.text = first.text.trimStart();
|
||||
}
|
||||
const last = trimmed.at(-1);
|
||||
if (last?.kind === "markdown") {
|
||||
last.text = last.text.trimEnd();
|
||||
}
|
||||
return trimmed.filter((part) => part.kind !== "markdown" || part.text.length > 0);
|
||||
}
|
||||
|
||||
function renderSummaryTokens(tokens: HtmlToken[]): string {
|
||||
return stripSingleHeadingWrapper(renderInlineTokens(tokens));
|
||||
}
|
||||
|
||||
function stripSingleHeadingWrapper(text: string): string {
|
||||
const tokens = tokenizeHtmlishMarkdown(text.trim());
|
||||
if (tokens.length < 3) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const first = tokens[0];
|
||||
const last = tokens.at(-1);
|
||||
if (!isHeadingTag(first) || !isClosingTag(last, first.name)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return renderInlineTokens(tokens.slice(1, -1));
|
||||
}
|
||||
|
||||
function renderInlineTokens(tokens: HtmlToken[]): string {
|
||||
let output = "";
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "text") {
|
||||
output += token.value;
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "comment" || token.closing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.name === "br") {
|
||||
output += "\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.name === "img") {
|
||||
output += renderImageToken(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeIndex = token.selfClosing ? null : findMatchingClose(tokens, index, token.name);
|
||||
if (closeIndex === null) {
|
||||
output += renderUnknownTag(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const children = tokens.slice(index + 1, closeIndex);
|
||||
if (token.name === "a") {
|
||||
output += renderLinkToken(token, children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
if (token.name === "sub") {
|
||||
output += renderInlineTokens(children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
if (token.name === "code" && children.every((child) => child.kind === "text")) {
|
||||
output += `\`${renderInlineTokens(children)}\``;
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
const rawTag = token.raw;
|
||||
const tagName = token.name;
|
||||
if (isHeadingTag(token)) {
|
||||
output += renderInlineTokens(children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += `${rawTag}${renderInlineTokens(children)}</${tagName}>`;
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderImageToken(token: HtmlTagToken): string {
|
||||
const image = imageTokenToInlineImage(token, undefined);
|
||||
if (!image) {
|
||||
return token.raw;
|
||||
}
|
||||
|
||||
return ``;
|
||||
}
|
||||
|
||||
function renderLinkToken(token: HtmlTagToken, children: HtmlToken[]): string {
|
||||
const imageOnly = getSingleImageChild(children);
|
||||
if (imageOnly) {
|
||||
return renderImageToken(imageOnly);
|
||||
}
|
||||
|
||||
const label = renderInlineTokens(children);
|
||||
const href = token.attributes.href ?? "";
|
||||
if (!label || !SAFE_LINK_HREF_RE.test(href) || href === "#") {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `[${label}](${href})`;
|
||||
}
|
||||
|
||||
function imageTokenToInlineImage(
|
||||
token: HtmlTagToken,
|
||||
href: string | undefined,
|
||||
): MarkdownInlineImagePart | null {
|
||||
const src = token.attributes.src ?? "";
|
||||
if (!SAFE_IMAGE_SRC_RE.test(src)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "inlineImage",
|
||||
alt: token.attributes.alt ?? "",
|
||||
src,
|
||||
...(href ? { href } : {}),
|
||||
...parseImageDimensions(token.attributes),
|
||||
};
|
||||
}
|
||||
|
||||
function parseImageDimensions(attributes: Record<string, string>): MarkdownImageDimensions {
|
||||
return {
|
||||
...parseImageDimension("width", attributes.width),
|
||||
...parseImageDimension("height", attributes.height),
|
||||
};
|
||||
}
|
||||
|
||||
function parseImageDimension(key: "width" | "height", value: string | undefined) {
|
||||
if (!value || !/^\d+(?:\.\d+)?$/.test(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? { [key]: parsed } : {};
|
||||
}
|
||||
|
||||
function safeHref(href: string | undefined): string | undefined {
|
||||
if (!href || href === "#" || !SAFE_LINK_HREF_RE.test(href)) {
|
||||
return undefined;
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
function getSingleImageChild(tokens: HtmlToken[]): HtmlTagToken | null {
|
||||
const visible = tokens.filter((token) => token.kind !== "comment" && !isWhitespaceText(token));
|
||||
return visible.length === 1 && isOpenTag(visible[0], "img") ? visible[0] : null;
|
||||
}
|
||||
|
||||
function renderUnknownTag(token: HtmlTagToken): string {
|
||||
if (token.name === "script" || token.name === "style") {
|
||||
return "";
|
||||
}
|
||||
return token.raw;
|
||||
}
|
||||
|
||||
function escapeMarkdownImageAlt(value: string): string {
|
||||
return value.replace(/]/g, "\\]");
|
||||
}
|
||||
|
||||
function findNextOpenTag(tokens: HtmlToken[], start: number, name: string): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (isOpenTag(tokens[index], name)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMatchingClose(tokens: HtmlToken[], openIndex: number, name: string): number | null {
|
||||
let depth = 1;
|
||||
for (let index = openIndex + 1; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (isOpenTag(token, name) && !token.selfClosing) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (isClosingTag(token, name)) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isOpenTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && token.name === name && !token.closing;
|
||||
}
|
||||
|
||||
function isClosingTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && token.name === name && token.closing;
|
||||
}
|
||||
|
||||
function isHeadingTag(token: HtmlToken | undefined): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && isHeadingTagName(token.name) && !token.closing;
|
||||
}
|
||||
|
||||
function isHeadingTagName(name: string): boolean {
|
||||
return (
|
||||
name === "h1" ||
|
||||
name === "h2" ||
|
||||
name === "h3" ||
|
||||
name === "h4" ||
|
||||
name === "h5" ||
|
||||
name === "h6"
|
||||
);
|
||||
}
|
||||
|
||||
function isWhitespaceText(token: HtmlToken): boolean {
|
||||
return token.kind === "text" && token.value.trim() === "";
|
||||
}
|
||||
|
||||
function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
|
||||
const protectedRanges = getProtectedMarkdownRanges(source);
|
||||
const tokens: HtmlToken[] = [];
|
||||
const parser = createHtmlishTokenParser(source, tokens);
|
||||
let cursor = 0;
|
||||
|
||||
for (const range of protectedRanges) {
|
||||
parser.write(source.slice(cursor, range.start));
|
||||
tokens.push({ kind: "text", value: source.slice(range.start, range.end) });
|
||||
parser.skip(range.end - range.start);
|
||||
cursor = range.end;
|
||||
}
|
||||
|
||||
parser.write(source.slice(cursor));
|
||||
parser.end();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
|
||||
let stripNextLeadingLineBreak = false;
|
||||
let skippedLength = 0;
|
||||
let parser!: Parser;
|
||||
parser = new Parser(
|
||||
{
|
||||
onopentag(name, attributes, isImplied) {
|
||||
if (isImplied) {
|
||||
return;
|
||||
}
|
||||
const selfClosing = VOID_HTML_TAGS.has(name);
|
||||
tokens.push({
|
||||
kind: "tag",
|
||||
name,
|
||||
closing: false,
|
||||
selfClosing,
|
||||
attributes,
|
||||
raw: renderStartTag(name, attributes),
|
||||
});
|
||||
},
|
||||
onclosetag(name, isImplied) {
|
||||
if (isImplied || VOID_HTML_TAGS.has(name)) {
|
||||
return;
|
||||
}
|
||||
tokens.push({
|
||||
kind: "tag",
|
||||
name,
|
||||
closing: true,
|
||||
selfClosing: false,
|
||||
attributes: {},
|
||||
raw: `</${name}>`,
|
||||
});
|
||||
},
|
||||
ontext(value) {
|
||||
if (stripNextLeadingLineBreak) {
|
||||
stripNextLeadingLineBreak = false;
|
||||
value = stripLeadingLineBreak(value);
|
||||
}
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
tokens.push({ kind: "text", value });
|
||||
},
|
||||
oncomment() {
|
||||
stripNextLeadingLineBreak = isLineStart(source, parser.startIndex + skippedLength);
|
||||
tokens.push({ kind: "comment" });
|
||||
},
|
||||
},
|
||||
{
|
||||
decodeEntities: false,
|
||||
lowerCaseAttributeNames: true,
|
||||
lowerCaseTags: true,
|
||||
recognizeSelfClosing: true,
|
||||
},
|
||||
);
|
||||
return {
|
||||
write(chunk) {
|
||||
parser.write(chunk);
|
||||
},
|
||||
skip(length) {
|
||||
skippedLength += length;
|
||||
},
|
||||
end() {
|
||||
parser.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isLineStart(source: string, index: number): boolean {
|
||||
return index === 0 || source[index - 1] === "\n" || source[index - 1] === "\r";
|
||||
}
|
||||
|
||||
function stripLeadingLineBreak(value: string): string {
|
||||
if (value.startsWith("\r\n")) {
|
||||
return value.slice(2);
|
||||
}
|
||||
if (value.startsWith("\n") || value.startsWith("\r")) {
|
||||
return value.slice(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderStartTag(name: string, attributes: Record<string, string>): string {
|
||||
const renderedAttributes = Object.entries(attributes)
|
||||
.map(([key, value]) => (value === "" ? ` ${key}` : ` ${key}="${escapeAttribute(value)}"`))
|
||||
.join("");
|
||||
return `<${name}${renderedAttributes}>`;
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll('"', """);
|
||||
}
|
||||
|
||||
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
|
||||
const fencedRanges = getFencedCodeRanges(source);
|
||||
return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]);
|
||||
}
|
||||
|
||||
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
FENCE_LINE_RE.lastIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const open = FENCE_LINE_RE.exec(source);
|
||||
if (!open) {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
const marker = open[1];
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const close = findClosingFence(source, FENCE_LINE_RE.lastIndex, marker);
|
||||
if (!close) {
|
||||
ranges.push({ start: open.index, end: source.length });
|
||||
return ranges;
|
||||
}
|
||||
|
||||
ranges.push({ start: open.index, end: close.end });
|
||||
FENCE_LINE_RE.lastIndex = close.end;
|
||||
}
|
||||
}
|
||||
|
||||
function findClosingFence(
|
||||
source: string,
|
||||
start: number,
|
||||
marker: string,
|
||||
): MarkdownDelimiterMatch | null {
|
||||
const closeRe = new RegExp(
|
||||
`^ {0,3}[${marker[0]}]{${marker.length},}[^\\n\\r]*(?:\\r?\\n|$)`,
|
||||
"gm",
|
||||
);
|
||||
closeRe.lastIndex = start;
|
||||
const close = closeRe.exec(source);
|
||||
return close ? { index: close.index, end: closeRe.lastIndex } : null;
|
||||
}
|
||||
|
||||
function getInlineCodeRanges(
|
||||
source: string,
|
||||
fencedRanges: ProtectedMarkdownRange[],
|
||||
): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
BACKTICK_RUN_RE.lastIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const open = BACKTICK_RUN_RE.exec(source);
|
||||
if (!open) {
|
||||
return ranges;
|
||||
}
|
||||
if (isProtectedIndex(open.index, fencedRanges)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = open[0];
|
||||
const close = findClosingBacktickRun(source, BACKTICK_RUN_RE.lastIndex, marker, fencedRanges);
|
||||
if (!close) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ranges.push({ start: open.index, end: close.end });
|
||||
BACKTICK_RUN_RE.lastIndex = close.end;
|
||||
}
|
||||
}
|
||||
|
||||
function findClosingBacktickRun(
|
||||
source: string,
|
||||
start: number,
|
||||
marker: string,
|
||||
fencedRanges: ProtectedMarkdownRange[],
|
||||
): MarkdownDelimiterMatch | null {
|
||||
BACKTICK_RUN_RE.lastIndex = start;
|
||||
|
||||
while (true) {
|
||||
const close = BACKTICK_RUN_RE.exec(source);
|
||||
if (!close) {
|
||||
return null;
|
||||
}
|
||||
if (close[0] === marker && !isProtectedIndex(close.index, fencedRanges)) {
|
||||
return { index: close.index, end: BACKTICK_RUN_RE.lastIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProtectedRanges(ranges: ProtectedMarkdownRange[]): ProtectedMarkdownRange[] {
|
||||
const sorted = ranges.toSorted((a, b) => a.start - b.start);
|
||||
const merged: ProtectedMarkdownRange[] = [];
|
||||
|
||||
for (const range of sorted) {
|
||||
const previous = merged.at(-1);
|
||||
if (!previous || range.start > previous.end) {
|
||||
merged.push({ ...range });
|
||||
continue;
|
||||
}
|
||||
previous.end = Math.max(previous.end, range.end);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isProtectedIndex(index: number, ranges: ProtectedMarkdownRange[]): boolean {
|
||||
return ranges.some((range) => index >= range.start && index < range.end);
|
||||
}
|
||||
|
||||
export function normalizeHtmlishMarkdown(source: string): string {
|
||||
return renderInlineTokens(tokenizeHtmlishMarkdown(source));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export interface InlineImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface InlineImageExplicitDimensions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const INLINE_IMAGE_FALLBACK_SIZE = 16;
|
||||
const INLINE_IMAGE_MAX_WIDTH = 240;
|
||||
const INLINE_IMAGE_MAX_HEIGHT = 160;
|
||||
|
||||
export function resolveInlineImageSize(input: {
|
||||
explicit: InlineImageExplicitDimensions;
|
||||
natural: InlineImageDimensions | null;
|
||||
}): InlineImageDimensions {
|
||||
const dimensions = resolveInlineImageDimensions(input);
|
||||
const scale = Math.min(
|
||||
1,
|
||||
INLINE_IMAGE_MAX_WIDTH / dimensions.width,
|
||||
INLINE_IMAGE_MAX_HEIGHT / dimensions.height,
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.round(dimensions.width * scale),
|
||||
height: Math.round(dimensions.height * scale),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInlineImageDimensions(input: {
|
||||
explicit: InlineImageExplicitDimensions;
|
||||
natural: InlineImageDimensions | null;
|
||||
}): InlineImageDimensions {
|
||||
if (input.explicit.width && input.explicit.height) {
|
||||
return { width: input.explicit.width, height: input.explicit.height };
|
||||
}
|
||||
|
||||
if (input.explicit.width && input.natural) {
|
||||
return {
|
||||
width: input.explicit.width,
|
||||
height: input.explicit.width * (input.natural.height / input.natural.width),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.explicit.height && input.natural) {
|
||||
return {
|
||||
width: input.explicit.height * (input.natural.width / input.natural.height),
|
||||
height: input.explicit.height,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.explicit.width) {
|
||||
return { width: input.explicit.width, height: input.explicit.width };
|
||||
}
|
||||
|
||||
if (input.explicit.height) {
|
||||
return { width: input.explicit.height, height: input.explicit.height };
|
||||
}
|
||||
|
||||
return input.natural ?? { width: INLINE_IMAGE_FALLBACK_SIZE, height: INLINE_IMAGE_FALLBACK_SIZE };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MarkdownDisplayPart } from "./html-ish";
|
||||
import { groupMarkdownParts } from "./part-groups";
|
||||
|
||||
describe("groupMarkdownParts", () => {
|
||||
const flowImage: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/priority.svg",
|
||||
flowsWithText: true,
|
||||
};
|
||||
|
||||
it("flows an image with the lead paragraph and keeps later paragraphs full width", () => {
|
||||
expect(
|
||||
groupMarkdownParts([
|
||||
flowImage,
|
||||
{ kind: "markdown", text: " **Title line**\n\nSecond paragraph." },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "imageText",
|
||||
images: [flowImage],
|
||||
lead: "**Title line**",
|
||||
rest: "Second paragraph.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flows an image with a single-paragraph text", () => {
|
||||
expect(groupMarkdownParts([flowImage, { kind: "markdown", text: " Only line" }])).toEqual([
|
||||
{ kind: "imageText", images: [flowImage], lead: "Only line", rest: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups two consecutive flowing images with the lead paragraph", () => {
|
||||
const secondImage: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Severity",
|
||||
src: "https://example.com/severity.svg",
|
||||
flowsWithText: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
groupMarkdownParts([
|
||||
flowImage,
|
||||
{ kind: "markdown", text: " " },
|
||||
secondImage,
|
||||
{ kind: "markdown", text: " **Title text here**\n\nBody paragraph." },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "imageText",
|
||||
images: [flowImage, secondImage],
|
||||
lead: "**Title text here**",
|
||||
rest: "Body paragraph.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps standalone images as plain parts", () => {
|
||||
const image: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Shot",
|
||||
src: "https://example.com/shot.png",
|
||||
};
|
||||
|
||||
expect(groupMarkdownParts([image, { kind: "markdown", text: "\n\nCaption" }])).toEqual([
|
||||
{ kind: "part", part: image },
|
||||
{ kind: "part", part: { kind: "markdown", text: "\n\nCaption" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a flowing image alone when no markdown follows", () => {
|
||||
expect(groupMarkdownParts([flowImage])).toEqual([{ kind: "part", part: flowImage }]);
|
||||
});
|
||||
|
||||
it("keeps a flowing image alone when the following markdown starts with a blank line", () => {
|
||||
expect(groupMarkdownParts([flowImage, { kind: "markdown", text: "\n\nBelow" }])).toEqual([
|
||||
{ kind: "part", part: flowImage },
|
||||
{ kind: "part", part: { kind: "markdown", text: "\n\nBelow" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes other part kinds through unchanged", () => {
|
||||
const details: MarkdownDisplayPart = { kind: "details", summary: "More", body: "Body" };
|
||||
|
||||
expect(groupMarkdownParts([{ kind: "markdown", text: "Intro" }, details])).toEqual([
|
||||
{ kind: "part", part: { kind: "markdown", text: "Intro" } },
|
||||
{ kind: "part", part: details },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { MarkdownDisplayPart, MarkdownInlineImagePart } from "./html-ish";
|
||||
|
||||
export type MarkdownPartGroup =
|
||||
| { kind: "part"; part: MarkdownDisplayPart }
|
||||
| { kind: "imageText"; images: MarkdownInlineImagePart[]; lead: string; rest: string };
|
||||
|
||||
/**
|
||||
* Pairs a run of inline images that flow with text (small status images at the
|
||||
* start of a line, GitHub-style) with the lead paragraph of the following
|
||||
* markdown part, so the renderer can lay them out side by side instead of
|
||||
* stacking the images as their own blocks.
|
||||
*
|
||||
* Between consecutive flowing images the parser may emit whitespace-only
|
||||
* markdown parts — these are consumed and discarded when building the group.
|
||||
*/
|
||||
export function groupMarkdownParts(parts: readonly MarkdownDisplayPart[]): MarkdownPartGroup[] {
|
||||
const groups: MarkdownPartGroup[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < parts.length) {
|
||||
const part = parts[index];
|
||||
|
||||
if (part.kind === "inlineImage" && part.flowsWithText) {
|
||||
// Collect the maximal run of flowing images, skipping whitespace-only markdown between them.
|
||||
const images: MarkdownInlineImagePart[] = [part];
|
||||
let lookahead = index + 1;
|
||||
|
||||
while (lookahead < parts.length) {
|
||||
const candidate = parts[lookahead];
|
||||
if (candidate === undefined) {
|
||||
break;
|
||||
}
|
||||
if (isWhitespaceOnlyMarkdown(candidate)) {
|
||||
lookahead += 1;
|
||||
continue;
|
||||
}
|
||||
if (candidate.kind === "inlineImage" && candidate.flowsWithText) {
|
||||
images.push(candidate);
|
||||
lookahead += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const trailing = parts[lookahead];
|
||||
if (trailing?.kind === "markdown") {
|
||||
const { lead, rest } = splitLeadParagraph(trailing.text);
|
||||
if (lead) {
|
||||
groups.push({ kind: "imageText", images, lead, rest });
|
||||
index = lookahead + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No usable lead — emit the images (and any whitespace parts) as plain parts.
|
||||
for (let i = index; i < lookahead; i += 1) {
|
||||
const p = parts[i];
|
||||
if (p !== undefined) {
|
||||
groups.push({ kind: "part", part: p });
|
||||
}
|
||||
}
|
||||
index = lookahead;
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push({ kind: "part", part });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function isWhitespaceOnlyMarkdown(part: MarkdownDisplayPart): boolean {
|
||||
return part.kind === "markdown" && part.text.trim() === "";
|
||||
}
|
||||
|
||||
function splitLeadParagraph(text: string): { lead: string; rest: string } {
|
||||
if (/^[ \t]*\r?\n/.test(text)) {
|
||||
return { lead: "", rest: text };
|
||||
}
|
||||
const boundary = /\r?\n[ \t]*\r?\n/.exec(text);
|
||||
if (!boundary) {
|
||||
return { lead: text.trim(), rest: "" };
|
||||
}
|
||||
return {
|
||||
lead: text.slice(0, boundary.index).trim(),
|
||||
rest: text.slice(boundary.index + boundary[0].length),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveInlineImageSize } from "./inline-image-size";
|
||||
|
||||
describe("resolveInlineImageSize", () => {
|
||||
it("respects a one-sided explicit width using natural aspect ratio", () => {
|
||||
expect(
|
||||
resolveInlineImageSize({ explicit: { width: 18 }, natural: { width: 90, height: 45 } }),
|
||||
).toEqual({
|
||||
width: 18,
|
||||
height: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects a one-sided explicit height using natural aspect ratio", () => {
|
||||
expect(
|
||||
resolveInlineImageSize({ explicit: { height: 18 }, natural: { width: 90, height: 45 } }),
|
||||
).toEqual({
|
||||
width: 36,
|
||||
height: 18,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a generic small fallback when no dimensions are known", () => {
|
||||
expect(resolveInlineImageSize({ explicit: {}, natural: null })).toEqual({
|
||||
width: 16,
|
||||
height: 16,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,758 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Image,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import Markdown, {
|
||||
MarkdownIt,
|
||||
type ASTNode,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
splitHtmlishMarkdown,
|
||||
type MarkdownDisplayPart,
|
||||
type MarkdownInlineImagePart,
|
||||
} from "./html-ish";
|
||||
import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size";
|
||||
import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups";
|
||||
|
||||
export type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
interface MarkdownWithStableRendererProps {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles> | ReturnType<typeof createCompactMarkdownStyles>;
|
||||
rules?: RenderRules;
|
||||
markdownit?: ReturnType<typeof MarkdownIt>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
allowedImageHandlers?: readonly string[];
|
||||
topLevelMaxExceededItem?: ReactNode;
|
||||
}
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||
|
||||
function markdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRendererProps> {
|
||||
return { style: createMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
function compactMarkdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRendererProps> {
|
||||
return { style: createCompactMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
const defaultMarkdownParser = MarkdownIt({ typographer: true, linkify: true });
|
||||
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
export interface MarkdownRendererProps {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
rules?: RenderRules;
|
||||
markdownit?: ReturnType<typeof MarkdownIt>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
allowedImageHandlers?: readonly string[];
|
||||
topLevelMaxExceededItem?: ReactNode;
|
||||
enableHtmlish?: boolean;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({
|
||||
text,
|
||||
compact = false,
|
||||
rules,
|
||||
markdownit = defaultMarkdownParser,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
enableHtmlish = true,
|
||||
}: MarkdownRendererProps) {
|
||||
const markdownRules = useMemo(() => rules ?? createSharedMarkdownRules(), [rules]);
|
||||
const parts = useMemo(
|
||||
() => (enableHtmlish ? splitHtmlishMarkdown(text) : [{ kind: "markdown" as const, text }]),
|
||||
[enableHtmlish, text],
|
||||
);
|
||||
const rendererProps = useMemo(
|
||||
() => ({
|
||||
compact,
|
||||
rules: markdownRules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
}),
|
||||
[
|
||||
allowedImageHandlers,
|
||||
compact,
|
||||
markdownRules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
topLevelMaxExceededItem,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<MarkdownPartList parts={parts} rendererProps={rendererProps} />
|
||||
</AppearanceStyleBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
type MarkdownPartRendererProps = Omit<MarkdownRendererProps, "text" | "enableHtmlish"> & {
|
||||
rules: RenderRules;
|
||||
};
|
||||
|
||||
function MarkdownPartList({
|
||||
parts,
|
||||
rendererProps,
|
||||
}: {
|
||||
parts: MarkdownDisplayPart[];
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const keyedGroups = useMemo(() => keyMarkdownGroups(groupMarkdownParts(parts)), [parts]);
|
||||
return (
|
||||
<>
|
||||
{keyedGroups.map(({ key, group }) =>
|
||||
group.kind === "part" ? (
|
||||
<MarkdownPart key={key} part={group.part} rendererProps={rendererProps} />
|
||||
) : (
|
||||
<MarkdownImageTextGroup key={key} group={group} rendererProps={rendererProps} />
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function keyMarkdownGroups(
|
||||
groups: MarkdownPartGroup[],
|
||||
): { key: string; group: MarkdownPartGroup }[] {
|
||||
const seen = new Map<string, number>();
|
||||
return groups.map((group) => {
|
||||
const identity =
|
||||
group.kind === "part"
|
||||
? getMarkdownPartIdentity(group.part)
|
||||
: `imageText:${group.images.map((i) => i.src).join(",")}:${group.lead.slice(0, 80)}`;
|
||||
const seenCount = seen.get(identity) ?? 0;
|
||||
seen.set(identity, seenCount + 1);
|
||||
return { key: `${identity}:${seenCount}`, group };
|
||||
});
|
||||
}
|
||||
|
||||
function getMarkdownPartIdentity(part: MarkdownDisplayPart): string {
|
||||
if (part.kind === "markdown") {
|
||||
return `markdown:${part.text.slice(0, 80)}`;
|
||||
}
|
||||
if (part.kind === "inlineImage") {
|
||||
return `inlineImage:${part.src}:${part.alt}`;
|
||||
}
|
||||
return `details:${part.summary.slice(0, 80)}:${part.body.slice(0, 80)}`;
|
||||
}
|
||||
|
||||
function MarkdownPart({
|
||||
part,
|
||||
rendererProps,
|
||||
}: {
|
||||
part: MarkdownDisplayPart;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
if (part.kind === "details") {
|
||||
return <MarkdownDetails part={part} rendererProps={rendererProps} />;
|
||||
}
|
||||
|
||||
if (part.kind === "inlineImage") {
|
||||
return <MarkdownInlineImage part={part} onLinkPress={rendererProps.onLinkPress} />;
|
||||
}
|
||||
|
||||
if (part.text.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MarkdownFragment text={part.text} {...rendererProps} />;
|
||||
}
|
||||
|
||||
function MarkdownFragment({
|
||||
text,
|
||||
compact,
|
||||
rules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
}: MarkdownRendererProps & { rules: RenderRules }) {
|
||||
const uniProps = compact ? compactMarkdownStyleMapping : markdownStyleMapping;
|
||||
return (
|
||||
<ThemedMarkdown
|
||||
uniProps={uniProps}
|
||||
rules={rules}
|
||||
markdownit={markdownit}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={allowedImageHandlers}
|
||||
topLevelMaxExceededItem={topLevelMaxExceededItem}
|
||||
>
|
||||
{text}
|
||||
</ThemedMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
function useNaturalImageDimensions(part: MarkdownInlineImagePart): {
|
||||
natural: InlineImageDimensions | null;
|
||||
failed: boolean;
|
||||
setFailed: (failed: boolean) => void;
|
||||
} {
|
||||
const [natural, setNatural] = useState<InlineImageDimensions | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (part.width && part.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
Image.getSize(
|
||||
part.src,
|
||||
(width, height) => {
|
||||
if (!cancelled) {
|
||||
setNatural({ width, height });
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
setFailed(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [part.height, part.src, part.width]);
|
||||
|
||||
return { natural, failed, setFailed };
|
||||
}
|
||||
|
||||
function MarkdownInlineImage({
|
||||
part,
|
||||
onLinkPress,
|
||||
}: {
|
||||
part: Extract<MarkdownDisplayPart, { kind: "inlineImage" }>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
}) {
|
||||
const { natural: naturalDimensions } = useNaturalImageDimensions(part);
|
||||
const explicitDimensions = useMemo(
|
||||
() => ({ width: part.width, height: part.height }),
|
||||
[part.height, part.width],
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!part.href) return;
|
||||
if (onLinkPress?.(part.href) === false) return;
|
||||
void openExternalUrl(part.href);
|
||||
}, [onLinkPress, part.href]);
|
||||
const source = useMemo(() => ({ uri: part.src }), [part.src]);
|
||||
const imageSize = useMemo(
|
||||
() => resolveInlineImageSize({ explicit: explicitDimensions, natural: naturalDimensions }),
|
||||
[explicitDimensions, naturalDimensions],
|
||||
);
|
||||
const imageStyle = useMemo(() => [detailsStyles.inlineImage, imageSize], [imageSize]);
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
source={source}
|
||||
style={imageStyle}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={part.alt || undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!part.href) {
|
||||
return <View style={detailsStyles.inlineImageWrap}>{image}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable style={detailsStyles.inlineImageWrap} onPress={handlePress} accessibilityRole="link">
|
||||
{image}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const FLOW_IMAGE_MAX_HEIGHT = 18;
|
||||
|
||||
function MarkdownFlowImage({
|
||||
part,
|
||||
onLinkPress,
|
||||
}: {
|
||||
part: MarkdownInlineImagePart;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
}) {
|
||||
const { natural, failed, setFailed } = useNaturalImageDimensions(part);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!part.href) return;
|
||||
if (onLinkPress?.(part.href) === false) return;
|
||||
void openExternalUrl(part.href);
|
||||
}, [onLinkPress, part.href]);
|
||||
const handleError = useCallback(() => setFailed(true), [setFailed]);
|
||||
const source = useMemo(() => ({ uri: part.src }), [part.src]);
|
||||
const imageSize = useMemo(() => {
|
||||
const size = resolveInlineImageSize({
|
||||
explicit: { width: part.width, height: part.height },
|
||||
natural,
|
||||
});
|
||||
const scale = Math.min(1, FLOW_IMAGE_MAX_HEIGHT / size.height);
|
||||
return { width: Math.round(size.width * scale), height: Math.round(size.height * scale) };
|
||||
}, [natural, part.height, part.width]);
|
||||
const imageStyle = useMemo(() => [detailsStyles.flowImage, imageSize], [imageSize]);
|
||||
|
||||
if (failed) {
|
||||
if (!part.alt) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={detailsStyles.flowImageFallback}>
|
||||
<Text style={detailsStyles.flowImageFallbackText}>{part.alt}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
source={source}
|
||||
style={imageStyle}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={part.alt || undefined}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!part.href) {
|
||||
return image;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} accessibilityRole="link">
|
||||
{image}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownImageTextGroup({
|
||||
group,
|
||||
rendererProps,
|
||||
}: {
|
||||
group: Extract<MarkdownPartGroup, { kind: "imageText" }>;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<View style={detailsStyles.imageTextRow}>
|
||||
{group.images.map((image) => (
|
||||
<MarkdownFlowImage key={image.src} part={image} onLinkPress={rendererProps.onLinkPress} />
|
||||
))}
|
||||
<View style={detailsStyles.imageTextRowContent}>
|
||||
<MarkdownFragment text={group.lead} {...rendererProps} />
|
||||
</View>
|
||||
</View>
|
||||
{group.rest ? <MarkdownFragment text={group.rest} {...rendererProps} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownDetails({
|
||||
part,
|
||||
rendererProps,
|
||||
}: {
|
||||
part: Extract<MarkdownDisplayPart, { kind: "details" }>;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggle = useCallback(() => setOpen((current) => !current), []);
|
||||
const bodyParts = useMemo(
|
||||
() => part.bodyParts ?? [{ kind: "markdown" as const, text: part.body }],
|
||||
[part.body, part.bodyParts],
|
||||
);
|
||||
return (
|
||||
<View style={detailsStyles.container}>
|
||||
<Pressable style={detailsStyles.summaryRow} onPress={toggle} accessibilityRole="button">
|
||||
{open ? (
|
||||
<ChevronDown size={14} color={detailsStyles.summaryIcon.color} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={detailsStyles.summaryIcon.color} />
|
||||
)}
|
||||
<Text style={detailsStyles.summaryText}>{part.summary}</Text>
|
||||
</Pressable>
|
||||
{open ? (
|
||||
<View style={detailsStyles.body}>
|
||||
<MarkdownPartList parts={bodyParts} rendererProps={rendererProps} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownInheritedTextProps {
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
style?: TextStyle;
|
||||
monoSurface?: boolean;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function MarkdownInheritedText({
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
style: overrideStyle,
|
||||
monoSurface,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
children,
|
||||
}: MarkdownInheritedTextProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
return (
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
contentStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
|
||||
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface MarkdownListViewProps {
|
||||
baseStyle: ViewStyle;
|
||||
spacing: { marginTop: number; marginBottom: number };
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
|
||||
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface SharedMarkdownLinkProps {
|
||||
href: string;
|
||||
inheritedStyles: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SharedMarkdownLink({
|
||||
href,
|
||||
inheritedStyles,
|
||||
linkStyle,
|
||||
onLinkPress,
|
||||
children,
|
||||
}: SharedMarkdownLinkProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
if (!href) return;
|
||||
if (onLinkPress?.(href) === false) return;
|
||||
void openExternalUrl(href);
|
||||
}, [href, onLinkPress]);
|
||||
|
||||
return (
|
||||
<MarkdownInheritedText
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={linkStyle}
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownLinkHref(node: ASTNode): string {
|
||||
const href = node.attributes?.href;
|
||||
return typeof href === "string" ? href : "";
|
||||
}
|
||||
|
||||
export function createSharedMarkdownRules(): RenderRules {
|
||||
return {
|
||||
text: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
textgroup: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.textgroup}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={node.sourceInfo}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
>
|
||||
{node.content ?? ""}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
list_item: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
link: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
onLinkPress?: (url: string) => boolean,
|
||||
) => (
|
||||
<SharedMarkdownLink
|
||||
key={node.key}
|
||||
href={getMarkdownLinkHref(node)}
|
||||
inheritedStyles={EMPTY_TEXT_STYLE}
|
||||
linkStyle={styles.link}
|
||||
onLinkPress={onLinkPress}
|
||||
>
|
||||
{children}
|
||||
</SharedMarkdownLink>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const detailsStyles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginBottom: theme.spacing[2],
|
||||
overflow: "hidden",
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
summaryIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
summaryText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 18,
|
||||
},
|
||||
body: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
},
|
||||
inlineImageWrap: {
|
||||
alignSelf: "flex-start",
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
inlineImage: {},
|
||||
imageTextRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
imageTextRowContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
flowImage: {
|
||||
marginTop: 2,
|
||||
},
|
||||
flowImageFallback: {
|
||||
marginTop: 2,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
flowImageFallbackText: {
|
||||
fontSize: 10,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useState,
|
||||
@@ -27,12 +26,8 @@ import {
|
||||
Children,
|
||||
cloneElement,
|
||||
} from "react";
|
||||
import type { ReactNode, ComponentType } from "react";
|
||||
import Markdown, {
|
||||
MarkdownIt,
|
||||
type ASTNode,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { MarkdownIt, type ASTNode, type RenderRules } from "react-native-markdown-display";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
@@ -52,7 +47,7 @@ import {
|
||||
FileSymlink,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { type Theme } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -63,8 +58,8 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
@@ -113,8 +108,6 @@ import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
|
||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
interface UserMessageProps {
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
@@ -162,22 +155,6 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
||||
] as const;
|
||||
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||||
|
||||
interface MarkdownWithStableRendererProps {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles>;
|
||||
rules: RenderRules;
|
||||
markdownit: MarkdownIt;
|
||||
onLinkPress: (url: string) => boolean;
|
||||
allowedImageHandlers: readonly string[];
|
||||
topLevelMaxExceededItem: ReactNode;
|
||||
}
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||
const markdownStyleMapping = (theme: Theme): Partial<MarkdownWithStableRendererProps> => ({
|
||||
style: createMarkdownStyles(theme),
|
||||
});
|
||||
|
||||
const ThemedMicVocal = withUnistyles(MicVocal);
|
||||
const ThemedTodoCheckIcon = withUnistyles(Check);
|
||||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||||
@@ -1521,18 +1498,15 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||
onLinkPress,
|
||||
}: MemoizedMarkdownBlockProps) {
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<ThemedMarkdown
|
||||
uniProps={markdownStyleMapping}
|
||||
rules={rules}
|
||||
markdownit={parser}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||
>
|
||||
{text}
|
||||
</ThemedMarkdown>
|
||||
</AppearanceStyleBoundary>
|
||||
<MarkdownRenderer
|
||||
text={text}
|
||||
enableHtmlish={false}
|
||||
rules={rules}
|
||||
markdownit={parser}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ function imageWithId(id: string): AttachmentMetadata {
|
||||
return { ...imageMetadata, id, storageKey: id, fileName: `${id}.png` };
|
||||
}
|
||||
|
||||
function reviewWorkspaceAttachment(body: string): WorkspaceComposerAttachment {
|
||||
function reviewWorkspaceAttachment(
|
||||
body: string,
|
||||
): Extract<WorkspaceComposerAttachment, { kind: "review" }> {
|
||||
const attachment: Extract<AgentAttachment, { type: "review" }> = {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactElement } from "re
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { MessageSquareCode, MousePointer2 } from "lucide-react-native";
|
||||
import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native";
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
@@ -12,12 +12,15 @@ 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[];
|
||||
@@ -59,6 +62,12 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
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,
|
||||
@@ -74,6 +83,83 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
@@ -90,9 +176,6 @@ function useWorkspaceAttachmentBinding({
|
||||
onOpenWorkspaceAttachment,
|
||||
}: WorkspaceAttachmentBindingInput): ComposerWorkspaceAttachmentBinding {
|
||||
const clearReviewDraft = useClearReviewDraft();
|
||||
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
||||
(state) => state.setWorkspaceAttachments,
|
||||
);
|
||||
const [suppressedKeys, setSuppressedKeys] = useState<readonly string[]>([]);
|
||||
const workspaceAttachmentKeys = useMemo(
|
||||
() => workspaceAttachments.map(getAttachmentKey),
|
||||
@@ -143,6 +226,7 @@ function useWorkspaceAttachmentBinding({
|
||||
clearReviewDraft({ key: attachment.reviewDraftKey });
|
||||
}
|
||||
}
|
||||
removeSentContextAttachments(attachments);
|
||||
},
|
||||
[clearReviewDraft],
|
||||
);
|
||||
@@ -151,17 +235,9 @@ function useWorkspaceAttachmentBinding({
|
||||
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
|
||||
const selected = current[index];
|
||||
if (isWorkspaceAttachment(selected)) {
|
||||
if (selected.kind === "browser_element") {
|
||||
if (selected.kind === "browser_element" || isPullRequestContextAttachment(selected)) {
|
||||
const selectedKey = getAttachmentKey(selected);
|
||||
const { attachmentsByScope } = 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 });
|
||||
}
|
||||
}
|
||||
removeWorkspaceAttachmentsMatching(selectedKey);
|
||||
return true;
|
||||
}
|
||||
suppressWorkspaceAttachment(selected);
|
||||
@@ -169,7 +245,7 @@ function useWorkspaceAttachmentBinding({
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[setWorkspaceAttachments, suppressWorkspaceAttachment],
|
||||
[suppressWorkspaceAttachment],
|
||||
);
|
||||
|
||||
const openAttachment = useCallback(
|
||||
@@ -233,15 +309,7 @@ function WorkspaceAttachmentPill({
|
||||
onRemove,
|
||||
}: WorkspaceAttachmentPillProps) {
|
||||
const { t } = useTranslation();
|
||||
let label: string;
|
||||
if (attachment.kind === "browser_element") {
|
||||
label = t("composer.attachments.browserElement", { tag: attachment.attachment.tag });
|
||||
} else {
|
||||
label =
|
||||
attachment.commentCount === 1
|
||||
? t("message.attachments.reviewOne")
|
||||
: t("message.attachments.reviewMany", { count: attachment.commentCount });
|
||||
}
|
||||
const label = getPillLabel(attachment, t);
|
||||
const handleOpen = useCallback(() => {
|
||||
onOpen(attachment);
|
||||
}, [onOpen, attachment]);
|
||||
@@ -253,26 +321,12 @@ function WorkspaceAttachmentPill({
|
||||
testID="composer-review-attachment-pill"
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={
|
||||
attachment.kind === "browser_element"
|
||||
? t("composer.attachments.openBrowserElement")
|
||||
: t("composer.attachments.openReview")
|
||||
}
|
||||
removeAccessibilityLabel={
|
||||
attachment.kind === "browser_element"
|
||||
? t("composer.attachments.removeBrowserElement")
|
||||
: t("composer.attachments.removeReview")
|
||||
}
|
||||
openAccessibilityLabel={getOpenAccessibilityLabel(attachment, t)}
|
||||
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={styles.pillBody}>
|
||||
<View style={styles.pillIcon}>
|
||||
{attachment.kind === "browser_element" ? (
|
||||
<ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
) : (
|
||||
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.pillIcon}>{renderPillIcon(attachment)}</View>
|
||||
<Text style={styles.pillText} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
@@ -315,4 +369,5 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
|
||||
const ThemedMousePointer2 = withUnistyles(MousePointer2);
|
||||
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||
const ThemedFileText = withUnistyles(FileText);
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
CircleDot,
|
||||
CircleSlash,
|
||||
CircleX,
|
||||
ExternalLink,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
GitPullRequestClosed,
|
||||
GitPullRequestDraft,
|
||||
MessageSquare,
|
||||
} from "lucide-react-native";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import type {
|
||||
CheckStatus,
|
||||
PrPaneActivity,
|
||||
PrPaneCheck,
|
||||
PrPaneData,
|
||||
PrState,
|
||||
} from "@/git/pr-pane-data";
|
||||
|
||||
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
|
||||
return [styles.row, Boolean(hovered) && styles.hoverable];
|
||||
}
|
||||
|
||||
function activityPressableStyle({ hovered }: { hovered?: boolean }) {
|
||||
return [styles.activityRow, Boolean(hovered) && styles.hoverable];
|
||||
}
|
||||
|
||||
export function PrPane({ data }: { data: PrPaneData }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const [checksOpen, setChecksOpen] = useState(true);
|
||||
const [reviewsOpen, setReviewsOpen] = useState(true);
|
||||
|
||||
const handleOpenPrUrl = useCallback(() => {
|
||||
void openExternalUrl(data.url);
|
||||
}, [data.url]);
|
||||
|
||||
const handleToggleChecks = useCallback(() => {
|
||||
setChecksOpen((o) => !o);
|
||||
}, []);
|
||||
|
||||
const handleToggleReviews = useCallback(() => {
|
||||
setReviewsOpen((o) => !o);
|
||||
}, []);
|
||||
|
||||
const passed = data.checks.filter((c) => c.status === "success").length;
|
||||
const failed = data.checks.filter((c) => c.status === "failure").length;
|
||||
const pending = data.checks.filter((c) => c.status === "pending").length;
|
||||
|
||||
const approvals = data.activity.filter(
|
||||
(a) => a.kind === "review" && a.reviewState === "approved",
|
||||
).length;
|
||||
const changesRequested = data.activity.filter(
|
||||
(a) => a.kind === "review" && a.reviewState === "changes_requested",
|
||||
).length;
|
||||
const commentCount = data.activity.filter(
|
||||
(a) => a.kind === "comment" || (a.kind === "review" && a.reviewState === "commented"),
|
||||
).length;
|
||||
|
||||
const stateColor = getStateColor(data.state, theme);
|
||||
const StateIcon = getStateIcon(data.state);
|
||||
const stateLabel = t(`workspace.git.pr.states.${data.state}`);
|
||||
const stateLabelStyle = useMemo(() => [styles.stateLabel, { color: stateColor }], [stateColor]);
|
||||
const keyedActivity = useMemo(
|
||||
() => data.activity.map((item, idx) => ({ key: `${item.author}-${item.kind}-${idx}`, item })),
|
||||
[data.activity],
|
||||
);
|
||||
|
||||
const checkSuccessIcon = useMemo(
|
||||
() => <CircleCheck size={12} color={theme.colors.statusSuccess} />,
|
||||
[theme.colors.statusSuccess],
|
||||
);
|
||||
const checkDangerIcon = useMemo(
|
||||
() => <CircleX size={12} color={theme.colors.statusDanger} />,
|
||||
[theme.colors.statusDanger],
|
||||
);
|
||||
const checkWarningIcon = useMemo(
|
||||
() => <CircleDot size={12} color={theme.colors.statusWarning} />,
|
||||
[theme.colors.statusWarning],
|
||||
);
|
||||
const commentIcon = useMemo(
|
||||
() => <MessageSquare size={11} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane">
|
||||
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<View style={styles.stateLine}>
|
||||
<StateIcon size={14} color={stateColor} />
|
||||
<Text style={stateLabelStyle} testID="pr-pane-state">
|
||||
{stateLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.title} numberOfLines={3} testID="pr-pane-title">
|
||||
{data.title}
|
||||
{hovered ? (
|
||||
<Text>
|
||||
{" "}
|
||||
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title={t("workspace.git.pr.sections.checks")}
|
||||
open={checksOpen}
|
||||
onToggle={handleToggleChecks}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill
|
||||
count={passed}
|
||||
color={theme.colors.statusSuccess}
|
||||
icon={checkSuccessIcon}
|
||||
testID="pr-pane-check-passed"
|
||||
/>
|
||||
<SummaryPill
|
||||
count={failed}
|
||||
color={theme.colors.statusDanger}
|
||||
icon={checkDangerIcon}
|
||||
testID="pr-pane-check-failed"
|
||||
/>
|
||||
<SummaryPill
|
||||
count={pending}
|
||||
color={theme.colors.statusWarning}
|
||||
icon={checkWarningIcon}
|
||||
testID="pr-pane-check-pending"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.checks.map((check) => (
|
||||
<CheckRow key={check.name} check={check} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title={t("workspace.git.pr.sections.reviews")}
|
||||
open={reviewsOpen}
|
||||
onToggle={handleToggleReviews}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill
|
||||
count={approvals}
|
||||
color={theme.colors.statusSuccess}
|
||||
icon={checkSuccessIcon}
|
||||
/>
|
||||
<SummaryPill
|
||||
count={changesRequested}
|
||||
color={theme.colors.statusDanger}
|
||||
icon={checkDangerIcon}
|
||||
/>
|
||||
<SummaryPill
|
||||
count={commentCount}
|
||||
color={theme.colors.foregroundMuted}
|
||||
icon={commentIcon}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{keyedActivity.map(({ key, item }) => (
|
||||
<ActivityRow key={key} item={item} />
|
||||
))}
|
||||
</Section>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
summary: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ title, open, onToggle, summary, children }: SectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<View style={open ? styles.sectionOpen : undefined}>
|
||||
<Pressable style={styles.sectionHeader} onPress={onToggle}>
|
||||
{open ? (
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<View style={styles.summaryWrap}>{summary}</View>
|
||||
</Pressable>
|
||||
{open && (
|
||||
<ScrollView
|
||||
style={styles.sectionBody}
|
||||
contentContainerStyle={styles.sectionBodyContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryPill({
|
||||
count,
|
||||
color,
|
||||
icon,
|
||||
testID,
|
||||
}: {
|
||||
count: number;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
testID?: string;
|
||||
}) {
|
||||
const textStyle = useMemo(() => [styles.summaryPillText, { color }], [color]);
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<View style={styles.summaryPill} testID={testID}>
|
||||
{icon}
|
||||
<Text style={textStyle}>{count}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckRow({ check }: { check: PrPaneCheck }) {
|
||||
const handlePress = useCallback(() => {
|
||||
void openExternalUrl(check.url);
|
||||
}, [check.url]);
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={rowPressableStyle}>
|
||||
<CheckStatusIcon status={check.status} />
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{check.name}
|
||||
</Text>
|
||||
{check.workflow && (
|
||||
<Text style={styles.rowMetaMid} numberOfLines={1}>
|
||||
{check.workflow}
|
||||
</Text>
|
||||
)}
|
||||
{check.duration && <Text style={styles.rowMeta}>{check.duration}</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckStatusIcon({ status }: { status: CheckStatus }) {
|
||||
const { theme } = useUnistyles();
|
||||
if (status === "success") return <CircleCheck size={14} color={theme.colors.statusSuccess} />;
|
||||
if (status === "failure") return <CircleX size={14} color={theme.colors.statusDanger} />;
|
||||
if (status === "pending") return <CircleDot size={14} color={theme.colors.statusWarning} />;
|
||||
return <CircleSlash size={14} color={theme.colors.foregroundMuted} />;
|
||||
}
|
||||
|
||||
function ActivityRow({ item }: { item: PrPaneActivity }) {
|
||||
const { t } = useTranslation();
|
||||
const verb = getTranslatedActivityVerb(item, t);
|
||||
const age = item.age === "just now" ? t("workspace.git.pr.time.justNow") : item.age;
|
||||
const handlePress = useCallback(() => {
|
||||
void openExternalUrl(item.url);
|
||||
}, [item.url]);
|
||||
const avatarStyle = useMemo(
|
||||
() => [styles.avatar, { backgroundColor: item.avatarColor }],
|
||||
[item.avatarColor],
|
||||
);
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={activityPressableStyle} testID="pr-pane-activity-row">
|
||||
<View style={avatarStyle}>
|
||||
<Text style={styles.avatarText}>{item.author.slice(0, 1).toUpperCase()}</Text>
|
||||
</View>
|
||||
<View style={styles.activityMain}>
|
||||
<View style={styles.activityHeader}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{item.author}
|
||||
</Text>
|
||||
<Text style={styles.rowMetaMid}>{verb}</Text>
|
||||
<Text style={styles.rowMeta}>{age}</Text>
|
||||
</View>
|
||||
<Text style={styles.rowBody} numberOfLines={2}>
|
||||
{item.body}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function getTranslatedActivityVerb(
|
||||
item: Pick<PrPaneActivity, "kind" | "reviewState">,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (item.kind === "comment") return t("workspace.git.pr.activity.commented");
|
||||
if (item.reviewState === "approved") return t("workspace.git.pr.activity.approved");
|
||||
if (item.reviewState === "changes_requested") {
|
||||
return t("workspace.git.pr.activity.requestedChanges");
|
||||
}
|
||||
return t("workspace.git.pr.activity.reviewed");
|
||||
}
|
||||
|
||||
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
||||
if (state === "open") return theme.colors.statusSuccess;
|
||||
if (state === "draft") return theme.colors.foregroundMuted;
|
||||
if (state === "merged") return theme.colors.statusMerged;
|
||||
return theme.colors.statusDanger;
|
||||
}
|
||||
|
||||
function getStateIcon(state: PrState) {
|
||||
if (state === "draft") return GitPullRequestDraft;
|
||||
if (state === "merged") return GitMerge;
|
||||
if (state === "closed") return GitPullRequestClosed;
|
||||
return GitPullRequest;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
hoverable: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
},
|
||||
stateLine: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
stateLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 19,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
sectionOpen: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
sectionBody: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
sectionBodyContent: {
|
||||
paddingBottom: theme.spacing[3],
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
summaryWrap: {
|
||||
marginLeft: "auto",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
summaryPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
},
|
||||
summaryPillText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
activityRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
activityMain: { flex: 1, minWidth: 0, gap: 2 },
|
||||
activityHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rowTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
},
|
||||
rowMeta: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginLeft: "auto",
|
||||
},
|
||||
rowMetaMid: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
rowBody: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 16,
|
||||
},
|
||||
avatar: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 1,
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 10,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: "#fff",
|
||||
},
|
||||
}));
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PrPaneData } from "./pr-pane-data";
|
||||
import { formatPrTabLabel } from "./pr-tab-label";
|
||||
|
||||
function makePrPaneData(overrides: Partial<PrPaneData> = {}): PrPaneData {
|
||||
return {
|
||||
number: 42,
|
||||
title: "Sample PR",
|
||||
state: "open",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
reviewDecision: "pending",
|
||||
awaitingReviewers: [],
|
||||
checks: [],
|
||||
activity: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatPrTabLabel", () => {
|
||||
it("returns #<number> when data is present", () => {
|
||||
expect(formatPrTabLabel(makePrPaneData({ number: 42 }))).toBe("#42");
|
||||
});
|
||||
|
||||
it("returns #— fallback when data is null", () => {
|
||||
expect(formatPrTabLabel(null)).toBe("#—");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { PrPaneData } from "./pr-pane-data";
|
||||
|
||||
export function formatPrTabLabel(data: PrPaneData | null): string {
|
||||
return data ? `#${data.number}` : "#—";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPullRequestActivityLocation } from "./activity-location";
|
||||
import type { PrPaneActivity } from "./data";
|
||||
|
||||
function activity(location: PrPaneActivity["location"]): PrPaneActivity {
|
||||
return {
|
||||
id: "comment-1",
|
||||
provider: "github",
|
||||
kind: "comment",
|
||||
author: "octocat",
|
||||
avatarColor: "#0ea5e9",
|
||||
body: "Looks good.",
|
||||
age: "3d ago",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
location,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pull request activity location labels", () => {
|
||||
it("includes inline thread state and concise thread IDs", () => {
|
||||
expect(
|
||||
formatPullRequestActivityLocation(
|
||||
activity({
|
||||
path: "packages/app/src/panel.tsx",
|
||||
startLine: 10,
|
||||
line: 12,
|
||||
threadId: "PRRT_1",
|
||||
isResolved: false,
|
||||
isOutdated: true,
|
||||
}),
|
||||
),
|
||||
).toBe("packages/app/src/panel.tsx:10-12 · unresolved · outdated · thread PRRT_1");
|
||||
});
|
||||
|
||||
it("includes current/resolved state without noisy long thread IDs", () => {
|
||||
expect(
|
||||
formatPullRequestActivityLocation(
|
||||
activity({
|
||||
path: "packages/app/src/panel.tsx",
|
||||
line: 12,
|
||||
threadId: "PRRT_kwDOAReallyLongOpaqueThreadIdentifier",
|
||||
isResolved: true,
|
||||
isOutdated: false,
|
||||
}),
|
||||
),
|
||||
).toBe("packages/app/src/panel.tsx:12 · resolved · current");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { PrPaneActivity } from "./data";
|
||||
|
||||
export function formatPullRequestActivityLocation(activity: PrPaneActivity): string | null {
|
||||
if (!activity.location) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = [formatPathAndLines(activity.location)];
|
||||
const threadState = formatThreadState(activity.location);
|
||||
if (threadState) {
|
||||
parts.push(...threadState);
|
||||
}
|
||||
const threadLabel = formatThreadLabel(activity.location.threadId);
|
||||
if (threadLabel) {
|
||||
parts.push(threadLabel);
|
||||
}
|
||||
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function formatPathAndLines(location: NonNullable<PrPaneActivity["location"]>): string {
|
||||
if (location.line !== undefined && location.startLine !== undefined) {
|
||||
return `${location.path}:${location.startLine}-${location.line}`;
|
||||
}
|
||||
if (location.line !== undefined) {
|
||||
return `${location.path}:${location.line}`;
|
||||
}
|
||||
return location.path;
|
||||
}
|
||||
|
||||
/** Path-and-line label for a review thread header, e.g. "src/foo.ts:12-14". */
|
||||
export function formatPullRequestThreadPath(
|
||||
location: NonNullable<PrPaneActivity["location"]>,
|
||||
): string {
|
||||
return formatPathAndLines(location);
|
||||
}
|
||||
|
||||
function formatThreadState(location: NonNullable<PrPaneActivity["location"]>): string[] | null {
|
||||
const state = [];
|
||||
if (location.isResolved !== undefined) {
|
||||
state.push(location.isResolved ? "resolved" : "unresolved");
|
||||
}
|
||||
if (location.isOutdated !== undefined) {
|
||||
state.push(location.isOutdated ? "outdated" : "current");
|
||||
}
|
||||
return state.length > 0 ? state : null;
|
||||
}
|
||||
|
||||
function formatThreadLabel(threadId: string | undefined): string | null {
|
||||
if (!threadId || threadId.length > 24) {
|
||||
return null;
|
||||
}
|
||||
return `thread ${threadId}`;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collapseActivity,
|
||||
expandActivity,
|
||||
getActivityState,
|
||||
getCollapsedEntryIds,
|
||||
getVisibleEntries,
|
||||
} from "./activity-state";
|
||||
import type { PrTimelineEntry } from "./timeline";
|
||||
import type { PrPaneActivity } from "./data";
|
||||
|
||||
function activity(id: string, overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
|
||||
return {
|
||||
id,
|
||||
provider: "github",
|
||||
kind: "comment",
|
||||
author: "octocat",
|
||||
avatarColor: "#0ea5e9",
|
||||
body: "Looks good.",
|
||||
age: "3d ago",
|
||||
url: `https://github.com/getpaseo/paseo/pull/42#${id}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function singleEntry(id: string, resolved = false, outdated = false): PrTimelineEntry {
|
||||
return {
|
||||
kind: "single",
|
||||
id,
|
||||
activity: activity(id, {
|
||||
location:
|
||||
resolved || outdated
|
||||
? { path: "a.ts", line: 1, isResolved: resolved, isOutdated: outdated }
|
||||
: undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function threadEntry(id: string, resolved = false, outdated = false): PrTimelineEntry {
|
||||
return {
|
||||
kind: "thread",
|
||||
id: `thread:${id}`,
|
||||
location: { path: "a.ts", line: 1, isResolved: resolved, isOutdated: outdated },
|
||||
comments: [activity(id)],
|
||||
};
|
||||
}
|
||||
|
||||
function reviewEntry(id: string, threads: PrTimelineEntry[]): PrTimelineEntry {
|
||||
return {
|
||||
kind: "review",
|
||||
id,
|
||||
review: activity(id, { kind: "review", reviewState: "commented" }),
|
||||
threads: threads.filter((entry) => entry.kind === "thread"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("pull request activity state", () => {
|
||||
it("collapses and expands activity by PR-scoped stable key", () => {
|
||||
const collapsed = collapseActivity(getActivityState(), {
|
||||
prNumber: 42,
|
||||
activityId: "comment-1",
|
||||
});
|
||||
|
||||
expect(collapsed.collapsedKeys).toEqual(["42:comment-1"]);
|
||||
expect(expandActivity(collapsed, { prNumber: 42, activityId: "comment-1" })).toEqual({
|
||||
collapsedKeys: [],
|
||||
expandedKeys: ["42:comment-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses resolved and outdated entries by default", () => {
|
||||
const entries = [
|
||||
singleEntry("normal"),
|
||||
singleEntry("resolved", true),
|
||||
singleEntry("outdated", false, true),
|
||||
threadEntry("thread-normal"),
|
||||
threadEntry("thread-resolved", true),
|
||||
threadEntry("thread-outdated", false, true),
|
||||
];
|
||||
|
||||
const visible = getVisibleEntries(getActivityState(), { prNumber: 42, entries });
|
||||
|
||||
expect(visible.map((v) => ({ id: v.entry.id, collapsed: v.collapsed }))).toEqual([
|
||||
{ id: "normal", collapsed: false },
|
||||
{ id: "resolved", collapsed: true },
|
||||
{ id: "outdated", collapsed: true },
|
||||
{ id: "thread:thread-normal", collapsed: false },
|
||||
{ id: "thread:thread-resolved", collapsed: true },
|
||||
{ id: "thread:thread-outdated", collapsed: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets user expand a default-collapsed entry", () => {
|
||||
const entries = [threadEntry("resolved", true)];
|
||||
const expanded = expandActivity(getActivityState(), {
|
||||
prNumber: 42,
|
||||
activityId: "thread:resolved",
|
||||
});
|
||||
|
||||
const visible = getVisibleEntries(expanded, { prNumber: 42, entries });
|
||||
expect(visible[0].collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("lets user collapse a default-expanded entry", () => {
|
||||
const entries = [threadEntry("normal")];
|
||||
const collapsed = collapseActivity(getActivityState(), {
|
||||
prNumber: 42,
|
||||
activityId: "thread:normal",
|
||||
});
|
||||
|
||||
const visible = getVisibleEntries(collapsed, { prNumber: 42, entries });
|
||||
expect(visible[0].collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it("includes default-collapsed nested review threads in collapsed IDs", () => {
|
||||
const entries = [
|
||||
reviewEntry("review", [
|
||||
threadEntry("thread-normal"),
|
||||
threadEntry("thread-resolved", true),
|
||||
threadEntry("thread-outdated", false, true),
|
||||
]),
|
||||
];
|
||||
|
||||
const collapsedIds = getCollapsedEntryIds(getActivityState(), { prNumber: 42, entries });
|
||||
|
||||
expect([...collapsedIds].sort()).toEqual(["thread:thread-outdated", "thread:thread-resolved"]);
|
||||
});
|
||||
|
||||
it("lets user expand a default-collapsed nested review thread", () => {
|
||||
const entries = [reviewEntry("review", [threadEntry("thread-resolved", true)])];
|
||||
const expanded = expandActivity(getActivityState(), {
|
||||
prNumber: 42,
|
||||
activityId: "thread:thread-resolved",
|
||||
});
|
||||
|
||||
const collapsedIds = getCollapsedEntryIds(expanded, { prNumber: 42, entries });
|
||||
|
||||
expect(collapsedIds.has("thread:thread-resolved")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { PrTimelineEntry } from "./timeline";
|
||||
|
||||
export interface PullRequestActivityIdentity {
|
||||
prNumber: number;
|
||||
activityId: string;
|
||||
}
|
||||
|
||||
export interface PullRequestActivityState {
|
||||
collapsedKeys: readonly string[];
|
||||
expandedKeys: readonly string[];
|
||||
}
|
||||
|
||||
export interface VisiblePullRequestEntry {
|
||||
entry: PrTimelineEntry;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export function getActivityState(): PullRequestActivityState {
|
||||
return { collapsedKeys: [], expandedKeys: [] };
|
||||
}
|
||||
|
||||
export function getActivityStateKey(identity: PullRequestActivityIdentity): string {
|
||||
return `${identity.prNumber}:${identity.activityId}`;
|
||||
}
|
||||
|
||||
function shouldCollapseByDefault(entry: PrTimelineEntry): boolean {
|
||||
if (entry.kind === "thread") {
|
||||
return entry.location.isResolved === true || entry.location.isOutdated === true;
|
||||
}
|
||||
if (entry.kind === "single") {
|
||||
return (
|
||||
entry.activity.location?.isResolved === true || entry.activity.location?.isOutdated === true
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function collapseActivity(
|
||||
state: PullRequestActivityState,
|
||||
identity: PullRequestActivityIdentity,
|
||||
): PullRequestActivityState {
|
||||
const key = getActivityStateKey(identity);
|
||||
if (state.collapsedKeys.includes(key)) {
|
||||
return { ...state, expandedKeys: state.expandedKeys.filter((item) => item !== key) };
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
collapsedKeys: [...state.collapsedKeys, key],
|
||||
expandedKeys: state.expandedKeys.filter((item) => item !== key),
|
||||
};
|
||||
}
|
||||
|
||||
export function expandActivity(
|
||||
state: PullRequestActivityState,
|
||||
identity: PullRequestActivityIdentity,
|
||||
): PullRequestActivityState {
|
||||
const key = getActivityStateKey(identity);
|
||||
const collapsedKeys = state.collapsedKeys.filter((item) => item !== key);
|
||||
const expandedKeys = state.expandedKeys.includes(key)
|
||||
? state.expandedKeys
|
||||
: [...state.expandedKeys, key];
|
||||
return { ...state, collapsedKeys, expandedKeys };
|
||||
}
|
||||
|
||||
export function getVisibleEntries(
|
||||
state: PullRequestActivityState,
|
||||
input: { prNumber: number; entries: readonly PrTimelineEntry[] },
|
||||
): VisiblePullRequestEntry[] {
|
||||
return input.entries.map((entry) => {
|
||||
const key = getActivityStateKey({ prNumber: input.prNumber, activityId: entry.id });
|
||||
const collapsedByDefault = shouldCollapseByDefault(entry);
|
||||
const isExplicitlyCollapsed = state.collapsedKeys.includes(key);
|
||||
const isExplicitlyExpanded = state.expandedKeys.includes(key);
|
||||
|
||||
const collapsed = isExplicitlyCollapsed || (collapsedByDefault && !isExplicitlyExpanded);
|
||||
|
||||
return { entry, collapsed };
|
||||
});
|
||||
}
|
||||
|
||||
export function getCollapsedEntryIds(
|
||||
state: PullRequestActivityState,
|
||||
input: { prNumber: number; entries?: readonly PrTimelineEntry[] },
|
||||
): ReadonlySet<string> {
|
||||
const prefix = `${input.prNumber}:`;
|
||||
const collapsedIds = new Set(
|
||||
state.collapsedKeys
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.map((key) => key.slice(prefix.length)),
|
||||
);
|
||||
|
||||
addDefaultCollapsedEntryIds(collapsedIds, state, input.prNumber, input.entries ?? []);
|
||||
|
||||
return collapsedIds;
|
||||
}
|
||||
|
||||
function addDefaultCollapsedEntryIds(
|
||||
collapsedIds: Set<string>,
|
||||
state: PullRequestActivityState,
|
||||
prNumber: number,
|
||||
entries: readonly PrTimelineEntry[],
|
||||
) {
|
||||
for (const entry of entries) {
|
||||
const key = getActivityStateKey({ prNumber, activityId: entry.id });
|
||||
if (shouldCollapseByDefault(entry) && !state.expandedKeys.includes(key)) {
|
||||
collapsedIds.add(entry.id);
|
||||
}
|
||||
if (state.expandedKeys.includes(key)) {
|
||||
collapsedIds.delete(entry.id);
|
||||
}
|
||||
if (entry.kind === "review") {
|
||||
addDefaultCollapsedEntryIds(collapsedIds, state, prNumber, entry.threads);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPullRequestCheckContextAttachment,
|
||||
buildPullRequestCommentContextAttachment,
|
||||
buildPullRequestReviewContextAttachment,
|
||||
buildPullRequestThreadContextAttachment,
|
||||
canAddPullRequestActivityToChat,
|
||||
canAddPullRequestCheckLogsToChat,
|
||||
type PullRequestContextBuilderInput,
|
||||
} from "./context-attachment";
|
||||
import type { PrPaneActivity, PrPaneCheck } from "./data";
|
||||
import type { PrThreadEntry } from "./timeline";
|
||||
|
||||
const baseInput: Omit<PullRequestContextBuilderInput, "activity"> = {
|
||||
provider: { id: "github", label: "GitHub" },
|
||||
pullRequest: {
|
||||
number: 42,
|
||||
title: "Fix flaky build",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
},
|
||||
};
|
||||
|
||||
function comment(overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
|
||||
return {
|
||||
id: "comment-1",
|
||||
provider: "github",
|
||||
kind: "comment",
|
||||
author: "octocat",
|
||||
avatarColor: "#0ea5e9",
|
||||
body: "Looks good.",
|
||||
age: "3d ago",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function review(overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
|
||||
return {
|
||||
id: "review-1",
|
||||
provider: "github",
|
||||
kind: "review",
|
||||
author: "reviewer",
|
||||
avatarColor: "#8b5cf6",
|
||||
reviewState: "commented",
|
||||
body: "Please simplify this.",
|
||||
age: "2d ago",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function check(overrides: Partial<PrPaneCheck> = {}): PrPaneCheck {
|
||||
return {
|
||||
name: "server-tests",
|
||||
provider: "github",
|
||||
status: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
github: { checkRunId: 12345, workflowRunId: 456 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pull request context attachments", () => {
|
||||
it("formats a comment as a workspace context attachment", () => {
|
||||
expect(
|
||||
buildPullRequestCommentContextAttachment({
|
||||
...baseInput,
|
||||
activity: comment({
|
||||
location: {
|
||||
path: "packages/app/src/panel.tsx",
|
||||
line: 42,
|
||||
threadId: "PRRT_1",
|
||||
isResolved: false,
|
||||
isOutdated: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "github.pull_request_comment",
|
||||
id: "42:comment-1",
|
||||
title: "octocat",
|
||||
subtitle: "#42 Fix flaky build",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
|
||||
text: [
|
||||
"GitHub pull request comment",
|
||||
"Pull request: #42 Fix flaky build",
|
||||
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
|
||||
"URL: https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
|
||||
"Author: octocat",
|
||||
"Created: 3d ago",
|
||||
"Location: packages/app/src/panel.tsx:42 · unresolved · outdated · thread PRRT_1",
|
||||
"",
|
||||
"Looks good.",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("formats a review with state as a workspace context attachment", () => {
|
||||
expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity: review() })).toEqual({
|
||||
kind: "github.pull_request_review",
|
||||
id: "42:review-1",
|
||||
title: "reviewer",
|
||||
subtitle: "#42 Fix flaky build",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
text: [
|
||||
"GitHub pull request review",
|
||||
"Pull request: #42 Fix flaky build",
|
||||
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
|
||||
"URL: https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
"Author: reviewer",
|
||||
"State: commented",
|
||||
"Created: 2d ago",
|
||||
"",
|
||||
"Please simplify this.",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add bodyless approvals to chat", () => {
|
||||
const activity = review({ reviewState: "approved", body: "" });
|
||||
|
||||
expect(canAddPullRequestActivityToChat(activity)).toBe(false);
|
||||
expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity })).toBeNull();
|
||||
});
|
||||
|
||||
it("adds bodyless changes-requested reviews as metadata-only context", () => {
|
||||
const activity = review({ reviewState: "changes_requested", body: "" });
|
||||
|
||||
expect(canAddPullRequestActivityToChat(activity)).toBe(true);
|
||||
expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity })?.text).toBe(
|
||||
[
|
||||
"GitHub pull request review",
|
||||
"Pull request: #42 Fix flaky build",
|
||||
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
|
||||
"URL: https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
"Author: reviewer",
|
||||
"State: changes_requested",
|
||||
"Created: 2d ago",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows add-logs only for failed checks", () => {
|
||||
expect(canAddPullRequestCheckLogsToChat(check({ status: "failure" }))).toBe(true);
|
||||
expect(canAddPullRequestCheckLogsToChat(check({ status: "success" }))).toBe(false);
|
||||
expect(canAddPullRequestCheckLogsToChat(check({ status: "pending" }))).toBe(false);
|
||||
expect(canAddPullRequestCheckLogsToChat(check({ status: "skipped" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("formats failed check details as a workspace context attachment", () => {
|
||||
expect(
|
||||
buildPullRequestCheckContextAttachment({
|
||||
...baseInput,
|
||||
check: check(),
|
||||
githubDetails: {
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
detailsUrl: "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,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "github.pull_request_check",
|
||||
id: "42:check-run:12345",
|
||||
title: "server-tests",
|
||||
subtitle: "#42 Fix flaky build",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
text: [
|
||||
"GitHub pull request check",
|
||||
"Pull request: #42 Fix flaky build",
|
||||
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
|
||||
"Check: server-tests",
|
||||
"Status: failure",
|
||||
"Conclusion: failure",
|
||||
"Check URL: https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
"Details URL: https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
"Output title: Tests failed",
|
||||
"Output summary: 1 failure",
|
||||
"Output text:",
|
||||
"Assertion failed",
|
||||
"",
|
||||
"Annotations:",
|
||||
"- packages/server/src/index.ts:10-12 failure: Expected true",
|
||||
"",
|
||||
"Failed jobs:",
|
||||
"- test: failure",
|
||||
" https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
" ```",
|
||||
" last line",
|
||||
" ```",
|
||||
"",
|
||||
"Note: Check details were truncated by GitHub/API or local caps.",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps same-named GitHub checks distinct by check run", () => {
|
||||
const ubuntu = buildPullRequestCheckContextAttachment({
|
||||
...baseInput,
|
||||
check: check({ github: { checkRunId: 12345, workflowRunId: 456 } }),
|
||||
githubDetails: null,
|
||||
});
|
||||
const windows = buildPullRequestCheckContextAttachment({
|
||||
...baseInput,
|
||||
check: check({ github: { checkRunId: 67890, workflowRunId: 456 } }),
|
||||
githubDetails: null,
|
||||
});
|
||||
|
||||
expect(ubuntu.id).toBe("42:check-run:12345");
|
||||
expect(windows.id).toBe("42:check-run:67890");
|
||||
});
|
||||
|
||||
it("formats metadata-only failed check context when details are unavailable", () => {
|
||||
expect(
|
||||
buildPullRequestCheckContextAttachment({
|
||||
...baseInput,
|
||||
check: check({
|
||||
name: "status/context",
|
||||
url: "https://github.com/getpaseo/paseo/status/context",
|
||||
github: undefined,
|
||||
}),
|
||||
githubDetails: null,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "github.pull_request_check",
|
||||
id: "42:check:status/context",
|
||||
title: "status/context",
|
||||
subtitle: "#42 Fix flaky build",
|
||||
url: "https://github.com/getpaseo/paseo/status/context",
|
||||
text: [
|
||||
"GitHub pull request check",
|
||||
"Pull request: #42 Fix flaky build",
|
||||
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
|
||||
"Check: status/context",
|
||||
"Status: failure",
|
||||
"Check URL: https://github.com/getpaseo/paseo/status/context",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPullRequestThreadContextAttachment", () => {
|
||||
function thread(overrides: Partial<PrThreadEntry> = {}): PrThreadEntry {
|
||||
return {
|
||||
kind: "thread",
|
||||
id: "thread:PRRT_1",
|
||||
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
|
||||
comments: [
|
||||
comment({
|
||||
id: "t1",
|
||||
body: "This is frozen after initial registration.",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
}),
|
||||
comment({ id: "t2", author: "mo", age: "1d ago", body: "Good catch, fixing." }),
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("bundles the whole thread conversation into one attachment", () => {
|
||||
expect(buildPullRequestThreadContextAttachment({ ...baseInput, thread: thread() })).toEqual({
|
||||
kind: "github.pull_request_comment",
|
||||
id: "42:thread:PRRT_1",
|
||||
title: "src/a.ts:12",
|
||||
subtitle: "#42 Fix flaky build",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
text: [
|
||||
"GitHub pull request review thread",
|
||||
"Pull request: #42 Fix flaky build",
|
||||
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
|
||||
"URL: https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
"Location: src/a.ts:12",
|
||||
"Thread state: unresolved",
|
||||
"",
|
||||
"octocat (3d ago):\nThis is frozen after initial registration.",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"mo (1d ago):\nGood catch, fixing.",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no comment has a body", () => {
|
||||
expect(
|
||||
buildPullRequestThreadContextAttachment({
|
||||
...baseInput,
|
||||
thread: thread({ comments: [comment({ body: " " })] }),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { CheckoutGithubCheckDetails } from "@getpaseo/protocol/messages";
|
||||
import type { PullRequestContextAttachment } from "@/attachments/types";
|
||||
import {
|
||||
formatPullRequestActivityLocation,
|
||||
formatPullRequestThreadPath,
|
||||
} from "./activity-location";
|
||||
import type { PrPaneActivity, PrPaneCheck, PullRequestProviderMetadata, ReviewState } from "./data";
|
||||
import type { PrThreadEntry } from "./timeline";
|
||||
|
||||
export interface PullRequestContextMetadata {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PullRequestContextBuilderInput {
|
||||
provider: PullRequestProviderMetadata;
|
||||
pullRequest: PullRequestContextMetadata;
|
||||
activity: PrPaneActivity;
|
||||
}
|
||||
|
||||
export interface PullRequestThreadContextBuilderInput {
|
||||
provider: PullRequestProviderMetadata;
|
||||
pullRequest: PullRequestContextMetadata;
|
||||
thread: PrThreadEntry;
|
||||
}
|
||||
|
||||
export interface PullRequestGithubCheckContextBuilderInput {
|
||||
provider: PullRequestProviderMetadata & { id: "github" };
|
||||
pullRequest: PullRequestContextMetadata;
|
||||
check: PrPaneCheck & { provider: "github" };
|
||||
githubDetails?: CheckoutGithubCheckDetails | null;
|
||||
}
|
||||
|
||||
export function canAddPullRequestActivityToChat(activity: PrPaneActivity): boolean {
|
||||
if (activity.kind === "comment") {
|
||||
return activity.body.trim().length > 0;
|
||||
}
|
||||
return activity.body.trim().length > 0 || activity.reviewState === "changes_requested";
|
||||
}
|
||||
|
||||
export function canAddPullRequestCheckLogsToChat(check: PrPaneCheck): boolean {
|
||||
return check.status === "failure";
|
||||
}
|
||||
|
||||
export function buildPullRequestCommentContextAttachment(
|
||||
input: PullRequestContextBuilderInput,
|
||||
): PullRequestContextAttachment {
|
||||
return {
|
||||
kind: "github.pull_request_comment",
|
||||
id: `${input.pullRequest.number}:${input.activity.id}`,
|
||||
title: input.activity.author,
|
||||
subtitle: formatPullRequestSubtitle(input.pullRequest),
|
||||
text: formatActivityContextText({
|
||||
...input,
|
||||
heading: `${input.provider.label} pull request comment`,
|
||||
}),
|
||||
url: input.activity.url,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPullRequestReviewContextAttachment(
|
||||
input: PullRequestContextBuilderInput,
|
||||
): PullRequestContextAttachment | null {
|
||||
if (!canAddPullRequestActivityToChat(input.activity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "github.pull_request_review",
|
||||
id: `${input.pullRequest.number}:${input.activity.id}`,
|
||||
title: input.activity.author,
|
||||
subtitle: formatPullRequestSubtitle(input.pullRequest),
|
||||
text: formatActivityContextText({
|
||||
...input,
|
||||
heading: `${input.provider.label} pull request review`,
|
||||
reviewState: input.activity.reviewState,
|
||||
}),
|
||||
url: input.activity.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a whole review thread (root comment plus replies) as one
|
||||
* attachment, so the agent gets the full conversation around a code location.
|
||||
*/
|
||||
export function buildPullRequestThreadContextAttachment(
|
||||
input: PullRequestThreadContextBuilderInput,
|
||||
): PullRequestContextAttachment | null {
|
||||
const comments = input.thread.comments.filter((comment) => comment.body.trim().length > 0);
|
||||
const root = input.thread.comments[0];
|
||||
if (comments.length === 0 || !root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`${input.provider.label} pull request review thread`,
|
||||
`Pull request: #${input.pullRequest.number} ${input.pullRequest.title}`,
|
||||
`Pull request URL: ${input.pullRequest.url}`,
|
||||
`URL: ${root.url}`,
|
||||
`Location: ${formatPullRequestThreadPath(input.thread.location)}`,
|
||||
];
|
||||
if (input.thread.location.isResolved !== undefined) {
|
||||
lines.push(`Thread state: ${input.thread.location.isResolved ? "resolved" : "unresolved"}`);
|
||||
}
|
||||
if (input.thread.location.isOutdated) {
|
||||
lines.push("Note: this thread is outdated (the code it refers to has changed)");
|
||||
}
|
||||
|
||||
const conversation = comments.map(
|
||||
(comment) => `${comment.author} (${comment.age}):\n${comment.body.trim()}`,
|
||||
);
|
||||
|
||||
return {
|
||||
kind: "github.pull_request_comment",
|
||||
id: `${input.pullRequest.number}:${input.thread.id}`,
|
||||
title: formatPullRequestThreadPath(input.thread.location),
|
||||
subtitle: formatPullRequestSubtitle(input.pullRequest),
|
||||
text: [...lines, "", conversation.join("\n\n---\n\n")].join("\n"),
|
||||
url: root.url,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPullRequestCheckContextAttachment(
|
||||
input: PullRequestGithubCheckContextBuilderInput,
|
||||
): PullRequestContextAttachment {
|
||||
return {
|
||||
kind: "github.pull_request_check",
|
||||
id: formatPullRequestCheckContextId(input.pullRequest, input.check),
|
||||
title: input.check.name,
|
||||
subtitle: formatPullRequestSubtitle(input.pullRequest),
|
||||
text: formatGitHubCheckContextText(input),
|
||||
url: input.githubDetails?.detailsUrl ?? input.githubDetails?.url ?? input.check.url,
|
||||
};
|
||||
}
|
||||
|
||||
function formatPullRequestCheckContextId(
|
||||
pullRequest: PullRequestContextMetadata,
|
||||
check: PrPaneCheck,
|
||||
): string {
|
||||
if (check.github?.checkRunId !== undefined) {
|
||||
return `${pullRequest.number}:check-run:${check.github.checkRunId}`;
|
||||
}
|
||||
return `${pullRequest.number}:check:${check.name}`;
|
||||
}
|
||||
|
||||
function formatGitHubCheckContextText({
|
||||
provider,
|
||||
pullRequest,
|
||||
check,
|
||||
githubDetails,
|
||||
}: PullRequestGithubCheckContextBuilderInput): string {
|
||||
const lines = [
|
||||
`${provider.label} pull request check`,
|
||||
`Pull request: #${pullRequest.number} ${pullRequest.title}`,
|
||||
`Pull request URL: ${pullRequest.url}`,
|
||||
`Check: ${check.name}`,
|
||||
`Status: ${check.status}`,
|
||||
];
|
||||
|
||||
if (githubDetails?.conclusion) {
|
||||
lines.push(`Conclusion: ${githubDetails.conclusion}`);
|
||||
}
|
||||
lines.push(`Check URL: ${check.url}`);
|
||||
const detailsUrl = githubDetails?.detailsUrl ?? githubDetails?.url;
|
||||
if (detailsUrl) {
|
||||
lines.push(`Details URL: ${detailsUrl}`);
|
||||
}
|
||||
appendGitHubCheckOutput(lines, githubDetails);
|
||||
appendGitHubCheckAnnotations(lines, githubDetails);
|
||||
appendGitHubFailedJobs(lines, githubDetails);
|
||||
if (githubDetails?.truncated) {
|
||||
lines.push("", "Note: Check details were truncated by GitHub/API or local caps.");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendGitHubCheckOutput(
|
||||
lines: string[],
|
||||
details: CheckoutGithubCheckDetails | null | undefined,
|
||||
) {
|
||||
if (details?.output?.title) {
|
||||
lines.push(`Output title: ${details.output.title}`);
|
||||
}
|
||||
if (details?.output?.summary) {
|
||||
lines.push(`Output summary: ${details.output.summary}`);
|
||||
}
|
||||
if (details?.output?.text) {
|
||||
lines.push("Output text:", details.output.text);
|
||||
}
|
||||
}
|
||||
|
||||
function appendGitHubCheckAnnotations(
|
||||
lines: string[],
|
||||
details: CheckoutGithubCheckDetails | null | undefined,
|
||||
) {
|
||||
if (!details?.annotations?.length) {
|
||||
return;
|
||||
}
|
||||
lines.push("", "Annotations:");
|
||||
for (const annotation of details.annotations) {
|
||||
lines.push(`- ${formatAnnotation(annotation)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function appendGitHubFailedJobs(
|
||||
lines: string[],
|
||||
details: CheckoutGithubCheckDetails | null | undefined,
|
||||
) {
|
||||
if (!details?.failedJobs?.length) {
|
||||
return;
|
||||
}
|
||||
lines.push("", "Failed jobs:");
|
||||
for (const job of details.failedJobs) {
|
||||
lines.push(`- ${job.name}: ${job.conclusion ?? job.status ?? "unknown"}`);
|
||||
if (job.url) {
|
||||
lines.push(` ${job.url}`);
|
||||
}
|
||||
if (job.logTail) {
|
||||
lines.push(" ```", ...job.logTail.split("\n").map((line) => ` ${line}`), " ```");
|
||||
}
|
||||
if (job.logTruncated) {
|
||||
lines.push(" Log tail truncated to the latest capped lines.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatAnnotation(annotation: CheckoutGithubCheckDetails["annotations"][number]): string {
|
||||
const location = annotation.path
|
||||
? `${annotation.path}${formatAnnotationLines(annotation)}`
|
||||
: "unknown location";
|
||||
const level = annotation.annotationLevel ? ` ${annotation.annotationLevel}` : "";
|
||||
const message = annotation.message ? `: ${annotation.message}` : "";
|
||||
return `${location}${level}${message}`;
|
||||
}
|
||||
|
||||
function formatAnnotationLines(
|
||||
annotation: CheckoutGithubCheckDetails["annotations"][number],
|
||||
): string {
|
||||
if (annotation.startLine !== undefined && annotation.endLine !== undefined) {
|
||||
return `:${annotation.startLine}-${annotation.endLine}`;
|
||||
}
|
||||
if (annotation.startLine !== undefined) {
|
||||
return `:${annotation.startLine}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatActivityContextText({
|
||||
heading,
|
||||
pullRequest,
|
||||
activity,
|
||||
reviewState,
|
||||
}: PullRequestContextBuilderInput & { heading: string; reviewState?: ReviewState }): string {
|
||||
const lines = [
|
||||
heading,
|
||||
`Pull request: #${pullRequest.number} ${pullRequest.title}`,
|
||||
`Pull request URL: ${pullRequest.url}`,
|
||||
`URL: ${activity.url}`,
|
||||
`Author: ${activity.author}`,
|
||||
];
|
||||
|
||||
if (reviewState) {
|
||||
lines.push(`State: ${reviewState}`);
|
||||
}
|
||||
if (activity.age) {
|
||||
lines.push(`Created: ${activity.age}`);
|
||||
}
|
||||
if (activity.location) {
|
||||
lines.push(`Location: ${formatPullRequestActivityLocation(activity)}`);
|
||||
}
|
||||
|
||||
const body = activity.body.trim();
|
||||
if (body.length === 0) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
return [...lines, "", body].join("\n");
|
||||
}
|
||||
|
||||
function formatPullRequestSubtitle(pullRequest: PullRequestContextMetadata): string {
|
||||
return `#${pullRequest.number} ${pullRequest.title}`;
|
||||
}
|
||||
+86
-5
@@ -9,7 +9,7 @@ import {
|
||||
getActivityVerb,
|
||||
getStateLabel,
|
||||
mapPrPaneData,
|
||||
} from "./pr-pane-data";
|
||||
} from "./data";
|
||||
|
||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
@@ -118,6 +118,7 @@ describe("mapPrPaneData", () => {
|
||||
|
||||
expect(data?.checks).toEqual([
|
||||
{
|
||||
provider: "github",
|
||||
name: "typecheck",
|
||||
status: "success",
|
||||
url: "https://example.com/checks/1",
|
||||
@@ -147,16 +148,44 @@ describe("mapPrPaneData", () => {
|
||||
|
||||
expect(data?.checks).toEqual([
|
||||
{
|
||||
provider: "github",
|
||||
name: "success",
|
||||
workflow: "CI",
|
||||
status: "success",
|
||||
duration: "1m",
|
||||
url: "https://example.com/1",
|
||||
},
|
||||
{ name: "failure", status: "failure", url: "https://example.com/2" },
|
||||
{ name: "pending", status: "pending", url: "https://example.com/3" },
|
||||
{ name: "skipped", status: "skipped", url: "https://example.com/4" },
|
||||
{ name: "cancelled", status: "skipped", url: "https://example.com/5" },
|
||||
{ provider: "github", name: "failure", status: "failure", url: "https://example.com/2" },
|
||||
{ provider: "github", name: "pending", status: "pending", url: "https://example.com/3" },
|
||||
{ provider: "github", name: "skipped", status: "skipped", url: "https://example.com/4" },
|
||||
{ provider: "github", name: "cancelled", status: "skipped", url: "https://example.com/5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps GitHub check identifiers into provider refs", () => {
|
||||
const data = mapPrPaneData(
|
||||
status({
|
||||
checks: [
|
||||
{
|
||||
name: "server-tests",
|
||||
status: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
},
|
||||
],
|
||||
}),
|
||||
baseTimeline,
|
||||
);
|
||||
|
||||
expect(data?.checks).toEqual([
|
||||
{
|
||||
provider: "github",
|
||||
name: "server-tests",
|
||||
status: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
github: { checkRunId: 12345, workflowRunId: 456 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -191,6 +220,58 @@ describe("mapPrPaneData", () => {
|
||||
expect(data?.activity.map((item) => item.author)).toEqual(["alice", "bob"]);
|
||||
});
|
||||
|
||||
it("maps timeline author avatar URLs and inline comment location metadata", () => {
|
||||
const data = mapPrPaneData(
|
||||
baseStatus,
|
||||
timeline({
|
||||
items: [
|
||||
{
|
||||
id: "thread-comment-1",
|
||||
kind: "comment",
|
||||
author: "inline-reviewer",
|
||||
authorUrl: "https://github.com/inline-reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
|
||||
body: "This should include line context.",
|
||||
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
Date.UTC(2026, 0, 1, 12, 0, 0),
|
||||
);
|
||||
|
||||
expect(data?.activity).toEqual([
|
||||
{
|
||||
id: "thread-comment-1",
|
||||
provider: "github",
|
||||
kind: "comment",
|
||||
author: "inline-reviewer",
|
||||
authorUrl: "https://github.com/inline-reviewer",
|
||||
avatarColor: deriveAvatarColor("inline-reviewer"),
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
|
||||
body: "This should include line context.",
|
||||
age: "1h ago",
|
||||
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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters empty commented reviews but keeps blocking review states", () => {
|
||||
const data = mapPrPaneData(
|
||||
baseStatus,
|
||||
+55
-2
@@ -7,27 +7,58 @@ export type PrState = "open" | "draft" | "merged" | "closed";
|
||||
export type CheckStatus = "success" | "failure" | "pending" | "skipped";
|
||||
export type ReviewState = "approved" | "changes_requested" | "commented";
|
||||
export type ActivityKind = "review" | "comment";
|
||||
export type PullRequestProvider = "github";
|
||||
|
||||
export interface PullRequestProviderMetadata {
|
||||
id: PullRequestProvider;
|
||||
label: string;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
const GITHUB_PROVIDER: PullRequestProviderMetadata = { id: "github", label: "GitHub" };
|
||||
|
||||
export interface PrPaneCheck {
|
||||
provider: PullRequestProvider;
|
||||
name: string;
|
||||
workflow?: string;
|
||||
status: CheckStatus;
|
||||
duration?: string;
|
||||
url: string;
|
||||
github?: {
|
||||
checkRunId?: number;
|
||||
workflowRunId?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PrPaneActivity {
|
||||
provider: PullRequestProvider;
|
||||
id: string;
|
||||
kind: ActivityKind;
|
||||
author: string;
|
||||
authorUrl?: string | null;
|
||||
avatarColor: string;
|
||||
avatarUrl?: string | null;
|
||||
reviewState?: ReviewState;
|
||||
body: string;
|
||||
age: string;
|
||||
url: string;
|
||||
/** For inline review comments: the review this comment was submitted with. */
|
||||
reviewId?: string;
|
||||
location?: {
|
||||
path: string;
|
||||
line?: number;
|
||||
startLine?: number;
|
||||
threadId?: string;
|
||||
isResolved?: boolean;
|
||||
isOutdated?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PrPaneData {
|
||||
provider: PullRequestProviderMetadata;
|
||||
number: number;
|
||||
repoOwner?: string;
|
||||
repoName?: string;
|
||||
title: string;
|
||||
state: PrState;
|
||||
url: string;
|
||||
@@ -69,7 +100,10 @@ export function mapPrPaneData(
|
||||
const timelineMatchesStatus = timeline?.prNumber === number;
|
||||
|
||||
return {
|
||||
provider: GITHUB_PROVIDER,
|
||||
number,
|
||||
repoOwner: status.repoOwner,
|
||||
repoName: status.repoName,
|
||||
title: status.title,
|
||||
state: derivePrState(status),
|
||||
url: status.url,
|
||||
@@ -137,11 +171,20 @@ function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPan
|
||||
|
||||
return [
|
||||
{
|
||||
provider: "github",
|
||||
name: check.name,
|
||||
workflow: check.workflow,
|
||||
status: mapCheckStatus(check.status),
|
||||
duration: check.duration,
|
||||
url: check.url,
|
||||
...(check.workflow ? { workflow: check.workflow } : {}),
|
||||
...(check.duration ? { duration: check.duration } : {}),
|
||||
...(check.checkRunId !== undefined || check.workflowRunId !== undefined
|
||||
? {
|
||||
github: {
|
||||
...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}),
|
||||
...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -168,12 +211,18 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
provider: "github",
|
||||
kind: "comment",
|
||||
author: item.author,
|
||||
authorUrl: item.authorUrl,
|
||||
avatarColor: deriveAvatarColor(item.author),
|
||||
avatarUrl: item.avatarUrl,
|
||||
body: item.body,
|
||||
age: formatAge(item.createdAt, nowMs),
|
||||
url: item.url,
|
||||
reviewId: item.reviewId,
|
||||
location: item.location,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -184,9 +233,13 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
|
||||
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
provider: "github",
|
||||
kind: "review",
|
||||
author: item.author,
|
||||
authorUrl: item.authorUrl,
|
||||
avatarColor: deriveAvatarColor(item.author),
|
||||
avatarUrl: item.avatarUrl,
|
||||
reviewState: item.reviewState,
|
||||
body: item.body,
|
||||
age: formatAge(item.createdAt, nowMs),
|
||||
@@ -0,0 +1,5 @@
|
||||
export { PullRequestPane } from "./pane";
|
||||
export { PullRequestTabIcon } from "./tab-icon";
|
||||
export { formatPrTabLabel } from "./tab-label";
|
||||
export { prPaneTimelineQueryKind } from "./query-keys";
|
||||
export { usePrPaneData } from "./use-data";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
export const prPaneTimelineQueryKind = "prPaneTimeline";
|
||||
|
||||
export function prPaneTimelineQueryKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number | null;
|
||||
}) {
|
||||
return [prPaneTimelineQueryKind, serverId, cwd, prNumber] as const;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
|
||||
export function PullRequestTabIcon({ size, color }: { size: number; color: string }) {
|
||||
return <GitHubIcon size={size} color={color} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPrTabLabel } from "./tab-label";
|
||||
|
||||
describe("formatPrTabLabel", () => {
|
||||
it("returns #<number> when a pull request number is present", () => {
|
||||
expect(formatPrTabLabel(42)).toBe("#42");
|
||||
});
|
||||
|
||||
it("returns #— fallback when the pull request number is null", () => {
|
||||
expect(formatPrTabLabel(null)).toBe("#—");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function formatPrTabLabel(prNumber: number | null): string {
|
||||
return prNumber === null ? "#—" : `#${prNumber}`;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPrTimeline } from "./timeline";
|
||||
import type { PrPaneActivity } from "./data";
|
||||
|
||||
function activity(overrides: Partial<PrPaneActivity> & { id: string }): PrPaneActivity {
|
||||
return {
|
||||
provider: "github",
|
||||
kind: "comment",
|
||||
author: "alice",
|
||||
avatarColor: "#8b5cf6",
|
||||
body: "body",
|
||||
age: "1h ago",
|
||||
url: "https://github.com/acme/app/pull/1#comment",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildPrTimeline", () => {
|
||||
it("keeps standalone comments and reviews as single entries in order", () => {
|
||||
const comment = activity({ id: "c1" });
|
||||
const review = activity({ id: "r1", kind: "review", reviewState: "approved" });
|
||||
|
||||
expect(buildPrTimeline([comment, review])).toEqual([
|
||||
{ kind: "single", id: "c1", activity: comment },
|
||||
{ kind: "single", id: "r1", activity: review },
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups comments sharing a threadId into one thread at the first comment's position", () => {
|
||||
const before = activity({ id: "c1" });
|
||||
const root = activity({
|
||||
id: "t1-root",
|
||||
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
|
||||
});
|
||||
const between = activity({ id: "c2" });
|
||||
const reply = activity({
|
||||
id: "t1-reply",
|
||||
author: "bob",
|
||||
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
|
||||
});
|
||||
|
||||
expect(buildPrTimeline([before, root, between, reply])).toEqual([
|
||||
{ kind: "single", id: "c1", activity: before },
|
||||
{
|
||||
kind: "thread",
|
||||
id: "thread:PRRT_1",
|
||||
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
|
||||
comments: [root, reply],
|
||||
},
|
||||
{ kind: "single", id: "c2", activity: between },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps located comments without a threadId as single entries", () => {
|
||||
const located = activity({ id: "c1", location: { path: "src/a.ts", line: 3 } });
|
||||
|
||||
expect(buildPrTimeline([located])).toEqual([{ kind: "single", id: "c1", activity: located }]);
|
||||
});
|
||||
|
||||
it("separates distinct threads", () => {
|
||||
const a = activity({ id: "a", location: { path: "x.ts", threadId: "T1" } });
|
||||
const b = activity({ id: "b", location: { path: "y.ts", threadId: "T2" } });
|
||||
const a2 = activity({ id: "a2", location: { path: "x.ts", threadId: "T1" } });
|
||||
|
||||
const entries = buildPrTimeline([a, b, a2]);
|
||||
expect(entries.map((entry) => entry.id)).toEqual(["thread:T1", "thread:T2"]);
|
||||
expect(entries[0]).toMatchObject({ comments: [a, a2] });
|
||||
expect(entries[1]).toMatchObject({ comments: [b] });
|
||||
});
|
||||
|
||||
it("nests threads under the review they were submitted with", () => {
|
||||
const review = activity({
|
||||
id: "R1",
|
||||
kind: "review",
|
||||
reviewState: "changes_requested",
|
||||
body: "Please fix these.",
|
||||
});
|
||||
const thread = activity({
|
||||
id: "t1",
|
||||
reviewId: "R1",
|
||||
location: { path: "src/a.ts", line: 4, threadId: "T1" },
|
||||
});
|
||||
const reply = activity({
|
||||
id: "t1-reply",
|
||||
reviewId: "R2-later",
|
||||
location: { path: "src/a.ts", line: 4, threadId: "T1" },
|
||||
});
|
||||
const unrelated = activity({
|
||||
id: "t2",
|
||||
reviewId: "R-unknown",
|
||||
location: { path: "src/b.ts", threadId: "T2" },
|
||||
});
|
||||
|
||||
expect(buildPrTimeline([review, thread, reply, unrelated])).toEqual([
|
||||
{
|
||||
kind: "review",
|
||||
id: "R1",
|
||||
review,
|
||||
threads: [
|
||||
{
|
||||
kind: "thread",
|
||||
id: "thread:T1",
|
||||
location: { path: "src/a.ts", line: 4, threadId: "T1" },
|
||||
comments: [thread, reply],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "thread",
|
||||
id: "thread:T2",
|
||||
location: { path: "src/b.ts", threadId: "T2" },
|
||||
comments: [unrelated],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps reviews without threads as single entries", () => {
|
||||
const review = activity({ id: "R1", kind: "review", reviewState: "approved" });
|
||||
|
||||
expect(buildPrTimeline([review])).toEqual([{ kind: "single", id: "R1", activity: review }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { PrPaneActivity } from "./data";
|
||||
|
||||
export type PrThreadLocation = NonNullable<PrPaneActivity["location"]>;
|
||||
|
||||
export interface PrThreadEntry {
|
||||
kind: "thread";
|
||||
id: string;
|
||||
location: PrThreadLocation;
|
||||
comments: PrPaneActivity[];
|
||||
}
|
||||
|
||||
export interface PrReviewEntry {
|
||||
kind: "review";
|
||||
id: string;
|
||||
review: PrPaneActivity;
|
||||
threads: PrThreadEntry[];
|
||||
}
|
||||
|
||||
export type PrTimelineEntry =
|
||||
| { kind: "single"; id: string; activity: PrPaneActivity }
|
||||
| PrThreadEntry
|
||||
| PrReviewEntry;
|
||||
|
||||
/**
|
||||
* Builds the GitHub-style nested timeline:
|
||||
* - comments sharing a `location.threadId` collapse into one thread entry
|
||||
* (root comment + replies), placed at the first comment's position;
|
||||
* - threads whose root comment carries a `reviewId` nest under that review
|
||||
* (GitHub's explicit signal: PullRequestReviewComment.pullRequestReview);
|
||||
* - everything else stays a standalone entry in original order.
|
||||
*/
|
||||
export function buildPrTimeline(activities: readonly PrPaneActivity[]): PrTimelineEntry[] {
|
||||
const entries = groupThreads(activities);
|
||||
return nestThreadsUnderReviews(entries);
|
||||
}
|
||||
|
||||
function groupThreads(activities: readonly PrPaneActivity[]): PrTimelineEntry[] {
|
||||
const entries: PrTimelineEntry[] = [];
|
||||
const threadsById = new Map<string, PrThreadEntry>();
|
||||
|
||||
for (const activity of activities) {
|
||||
const threadId = activity.location?.threadId;
|
||||
if (!activity.location || !threadId) {
|
||||
entries.push({ kind: "single", id: activity.id, activity });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = threadsById.get(threadId);
|
||||
if (existing) {
|
||||
existing.comments.push(activity);
|
||||
continue;
|
||||
}
|
||||
|
||||
const thread: PrThreadEntry = {
|
||||
kind: "thread",
|
||||
id: `thread:${threadId}`,
|
||||
location: activity.location,
|
||||
comments: [activity],
|
||||
};
|
||||
threadsById.set(threadId, thread);
|
||||
entries.push(thread);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function nestThreadsUnderReviews(entries: PrTimelineEntry[]): PrTimelineEntry[] {
|
||||
const reviewIds = new Set(
|
||||
entries.flatMap((entry) =>
|
||||
entry.kind === "single" && entry.activity.kind === "review" ? [entry.activity.id] : [],
|
||||
),
|
||||
);
|
||||
const threadsByReviewId = new Map<string, PrThreadEntry[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== "thread") {
|
||||
continue;
|
||||
}
|
||||
const reviewId = entry.comments[0]?.reviewId;
|
||||
if (!reviewId || !reviewIds.has(reviewId)) {
|
||||
continue;
|
||||
}
|
||||
const threads = threadsByReviewId.get(reviewId) ?? [];
|
||||
threads.push(entry);
|
||||
threadsByReviewId.set(reviewId, threads);
|
||||
}
|
||||
|
||||
if (threadsByReviewId.size === 0) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
const nestedThreads = new Set([...threadsByReviewId.values()].flat());
|
||||
return entries.flatMap((entry): PrTimelineEntry[] => {
|
||||
if (entry.kind === "thread") {
|
||||
return nestedThreads.has(entry) ? [] : [entry];
|
||||
}
|
||||
if (entry.kind === "single" && entry.activity.kind === "review") {
|
||||
const threads = threadsByReviewId.get(entry.activity.id);
|
||||
if (threads) {
|
||||
return [{ kind: "review" as const, id: entry.id, review: entry.activity, threads }];
|
||||
}
|
||||
}
|
||||
return [entry];
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@ import {
|
||||
selectPrPaneState,
|
||||
shouldFetchTimelineFrom,
|
||||
unsupportedTimelineKey,
|
||||
} from "./use-pr-pane-data";
|
||||
} from "./use-data";
|
||||
|
||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
||||
+2
-2
@@ -7,10 +7,10 @@ import type {
|
||||
PullRequestTimelineResponse,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { prPaneTimelineQueryKey } from "@/git/query-keys";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { mapPrPaneData, type PrPaneData } from "./data";
|
||||
import { prPaneTimelineQueryKey } from "./query-keys";
|
||||
|
||||
type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"];
|
||||
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
invalidateCheckoutGitQueriesForClient,
|
||||
prPaneTimelineQueryKey,
|
||||
} from "@/git/query-keys";
|
||||
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
|
||||
|
||||
describe("checkout query keys", () => {
|
||||
const serverId = "server-1";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Query, QueryClient } from "@tanstack/react-query";
|
||||
import { prPaneTimelineQueryKind } from "./pull-request-panel/query-keys";
|
||||
|
||||
interface CheckoutQueryIdentity {
|
||||
serverId: string;
|
||||
@@ -25,18 +26,6 @@ export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
export function prPaneTimelineQueryKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number | null;
|
||||
}) {
|
||||
return ["prPaneTimeline", serverId, cwd, prNumber] as const;
|
||||
}
|
||||
|
||||
export async function invalidateCheckoutGitQueriesForClient(
|
||||
queryClient: QueryClient,
|
||||
identity: CheckoutQueryIdentity,
|
||||
@@ -52,7 +41,7 @@ export async function invalidateCheckoutGitQueriesForClient(
|
||||
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("prPaneTimeline", identity),
|
||||
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -230,14 +230,15 @@ describe("workspace agent visibility", () => {
|
||||
"slash-agent",
|
||||
makeAgent({
|
||||
id: "slash-agent",
|
||||
cwd: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid/",
|
||||
cwd: "/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/normal-squid/",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceDirectory: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid",
|
||||
workspaceDirectory:
|
||||
"/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/normal-squid",
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["slash-agent"]));
|
||||
|
||||
Reference in New Issue
Block a user