forked from farhoodlabs/paperclip
feat: polish issue thread markdown and references
This commit is contained in:
@@ -70,4 +70,116 @@ describe("buildTranscript", () => {
|
||||
expect(first).toEqual([]);
|
||||
expect(second).toEqual([{ kind: "stdout", ts, text: "literal:finish" }]);
|
||||
});
|
||||
|
||||
it("converts parser failures into transcript error entries and keeps going", () => {
|
||||
const entries = buildTranscript(
|
||||
[
|
||||
{ ts, stream: "stdout", chunk: "ok\nexplode\nlater\n" },
|
||||
],
|
||||
(line, entryTs) => {
|
||||
if (line === "explode") {
|
||||
throw new Error("boom");
|
||||
}
|
||||
return [{ kind: "stdout", ts: entryTs, text: line }];
|
||||
},
|
||||
);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ kind: "stdout", ts, text: "ok" },
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "Chat transcript error: boom. Falling back for line: explode",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "transcript_parse_error",
|
||||
isError: true,
|
||||
errors: [],
|
||||
},
|
||||
{ kind: "stdout", ts, text: "later" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resets stateful parsers after a failure before parsing later lines", () => {
|
||||
const statefulAdapter: UIAdapterModule = {
|
||||
type: "stateful_test",
|
||||
label: "Stateful Test",
|
||||
parseStdoutLine: (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }],
|
||||
createStdoutParser: () => {
|
||||
let pending: string | null = null;
|
||||
return {
|
||||
parseLine: (line, entryTs) => {
|
||||
if (line.startsWith("begin:")) {
|
||||
pending = line.slice("begin:".length);
|
||||
return [];
|
||||
}
|
||||
if (line === "explode") {
|
||||
throw new Error(`bad state:${pending ?? "none"}`);
|
||||
}
|
||||
if (line === "finish" && pending) {
|
||||
const text = `completed:${pending}`;
|
||||
pending = null;
|
||||
return [{ kind: "stdout", ts: entryTs, text }];
|
||||
}
|
||||
return [{ kind: "stdout", ts: entryTs, text: `literal:${line}` }];
|
||||
},
|
||||
reset: () => {
|
||||
pending = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
ConfigFields: () => null,
|
||||
buildAdapterConfig: () => ({}),
|
||||
};
|
||||
|
||||
const entries = buildTranscript(
|
||||
[{ ts, stream: "stdout", chunk: "begin:task-a\nexplode\nfinish\n" }],
|
||||
statefulAdapter,
|
||||
);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "Chat transcript error: bad state:task-a. Falling back for line: explode",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "transcript_parse_error",
|
||||
isError: true,
|
||||
errors: [],
|
||||
},
|
||||
{ kind: "stdout", ts, text: "literal:finish" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles trailing buffered parser failures without throwing", () => {
|
||||
const entries = buildTranscript(
|
||||
[{ ts, stream: "stdout", chunk: "explode" }],
|
||||
(line, entryTs) => {
|
||||
if (line === "explode") {
|
||||
throw new Error("trailing boom");
|
||||
}
|
||||
return [{ kind: "stdout", ts: entryTs, text: line }];
|
||||
},
|
||||
);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "Chat transcript error: trailing boom. Falling back for line: explode",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "transcript_parse_error",
|
||||
isError: true,
|
||||
errors: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from "
|
||||
|
||||
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
||||
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
|
||||
type RedactionOptions = { enabled: boolean };
|
||||
|
||||
function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) {
|
||||
if (typeof source === "function") {
|
||||
@@ -33,6 +34,66 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr
|
||||
}
|
||||
}
|
||||
|
||||
function truncateTranscriptLine(line: string, maxLength = 160) {
|
||||
if (line.length <= maxLength) return line;
|
||||
return `${line.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
function formatTranscriptParserError(error: unknown) {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error) return error;
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
function createTranscriptParseErrorEntry(
|
||||
line: string,
|
||||
ts: string,
|
||||
error: unknown,
|
||||
redactionOptions: RedactionOptions,
|
||||
): TranscriptEntry {
|
||||
const errorText = formatTranscriptParserError(error) || "unknown parser error";
|
||||
const preview = truncateTranscriptLine(line);
|
||||
return {
|
||||
kind: "result",
|
||||
ts,
|
||||
text: redactHomePathUserSegments(
|
||||
`Chat transcript error: ${errorText}. Falling back for line: ${preview}`,
|
||||
redactionOptions,
|
||||
),
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "transcript_parse_error",
|
||||
isError: true,
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
function appendParsedTranscriptLine(args: {
|
||||
entries: TranscriptEntry[];
|
||||
line: string;
|
||||
ts: string;
|
||||
parseLine: (line: string, ts: string) => TranscriptEntry[];
|
||||
reset: (() => void) | null;
|
||||
redactionOptions: RedactionOptions;
|
||||
}) {
|
||||
const { entries, line, ts, parseLine, reset, redactionOptions } = args;
|
||||
try {
|
||||
appendTranscriptEntries(
|
||||
entries,
|
||||
parseLine(line, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)),
|
||||
);
|
||||
} catch (error) {
|
||||
reset?.();
|
||||
appendTranscriptEntry(entries, createTranscriptParseErrorEntry(line, ts, error, redactionOptions));
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTranscript(
|
||||
chunks: RunLogChunk[],
|
||||
parserSource: StdoutLineParser | TranscriptParserSource,
|
||||
@@ -59,14 +120,28 @@ export function buildTranscript(
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
appendTranscriptEntries(entries, parseLine(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
||||
appendParsedTranscriptLine({
|
||||
entries,
|
||||
line: trimmed,
|
||||
ts: chunk.ts,
|
||||
parseLine,
|
||||
reset,
|
||||
redactionOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = stdoutBuffer.trim();
|
||||
if (trailing) {
|
||||
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
||||
appendTranscriptEntries(entries, parseLine(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
||||
appendParsedTranscriptLine({
|
||||
entries,
|
||||
line: trailing,
|
||||
ts,
|
||||
parseLine,
|
||||
reset,
|
||||
redactionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
reset?.();
|
||||
|
||||
@@ -369,7 +369,7 @@ function CommentCard({
|
||||
<CopyMarkdownButton text={comment.body} />
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||
<MarkdownBody className="text-sm" softBreaks>{comment.body}</MarkdownBody>
|
||||
{companyId && !isPending ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<PluginSlotOutlet
|
||||
|
||||
@@ -424,7 +424,12 @@ function commentDateLabel(date: Date | string | undefined): string {
|
||||
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
||||
const { onImageClick } = useContext(IssueChatCtx);
|
||||
return (
|
||||
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined} onImageClick={onImageClick}>
|
||||
<MarkdownBody
|
||||
className="text-sm leading-6"
|
||||
style={recessed ? { opacity: 0.55 } : undefined}
|
||||
softBreaks
|
||||
onImageClick={onImageClick}
|
||||
>
|
||||
{text}
|
||||
</MarkdownBody>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useLocation } from "@/lib/router";
|
||||
import { ApiError } from "../api/client";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
import { deriveDocumentRevisionState } from "../lib/document-revisions";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
@@ -70,7 +69,7 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
|
||||
}
|
||||
|
||||
function renderBody(body: string, className?: string) {
|
||||
return <MarkdownBody className={className}>{body}</MarkdownBody>;
|
||||
return <MarkdownBody className={className} softBreaks={false}>{body}</MarkdownBody>;
|
||||
}
|
||||
|
||||
function isPlanKey(key: string) {
|
||||
@@ -537,10 +536,10 @@ export function IssueDocumentsSection({
|
||||
}, []);
|
||||
|
||||
const previewRevision = useCallback((doc: IssueDocument, revisionId: string) => {
|
||||
const revisionState = deriveDocumentRevisionState(doc, getDocumentRevisions(doc.key));
|
||||
const selectedRevision = revisionState.revisions.find((revision) => revision.id === revisionId);
|
||||
const revisions = getDocumentRevisions(doc.key);
|
||||
const selectedRevision = revisions.find((revision) => revision.id === revisionId);
|
||||
if (!selectedRevision) return;
|
||||
if (selectedRevision.id === revisionState.currentRevision.id) {
|
||||
if (selectedRevision.id === doc.latestRevisionId) {
|
||||
returnToLatestRevision(doc.key);
|
||||
return;
|
||||
}
|
||||
@@ -788,10 +787,7 @@ export function IssueDocumentsSection({
|
||||
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
|
||||
const activeConflict = documentConflict?.key === doc.key ? documentConflict : null;
|
||||
const isFolded = foldedDocumentKeys.includes(doc.key);
|
||||
const rawRevisionHistory = getDocumentRevisions(doc.key);
|
||||
const revisionState = deriveDocumentRevisionState(doc, rawRevisionHistory);
|
||||
const revisionHistory = revisionState.revisions;
|
||||
const currentRevision = revisionState.currentRevision;
|
||||
const revisionHistory = getDocumentRevisions(doc.key);
|
||||
const selectedRevisionId = selectedRevisionIds[doc.key] ?? null;
|
||||
const selectedHistoricalRevision = selectedRevisionId
|
||||
? revisionHistory.find((revision) => revision.id === selectedRevisionId) ?? null
|
||||
@@ -799,10 +795,10 @@ export function IssueDocumentsSection({
|
||||
const isHistoricalPreview = Boolean(selectedHistoricalRevision);
|
||||
const displayedTitle = selectedHistoricalRevision
|
||||
? selectedHistoricalRevision.title ?? ""
|
||||
: activeDraft?.title ?? currentRevision.title ?? "";
|
||||
const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? currentRevision.body;
|
||||
const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? currentRevision.revisionNumber;
|
||||
const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? currentRevision.createdAt;
|
||||
: activeDraft?.title ?? doc.title ?? "";
|
||||
const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? doc.body;
|
||||
const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? doc.latestRevisionNumber;
|
||||
const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? doc.updatedAt;
|
||||
const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key);
|
||||
const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote);
|
||||
|
||||
@@ -849,12 +845,12 @@ export function IssueDocumentsSection({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
<DropdownMenuLabel>Revision history</DropdownMenuLabel>
|
||||
{revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && rawRevisionHistory.length === 0 ? (
|
||||
{revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && revisionHistory.length === 0 ? (
|
||||
<DropdownMenuItem disabled>Loading revisions...</DropdownMenuItem>
|
||||
) : revisionHistory.length > 0 ? (
|
||||
<DropdownMenuRadioGroup value={selectedRevisionId ?? currentRevision.id ?? ""}>
|
||||
<DropdownMenuRadioGroup value={selectedRevisionId ?? doc.latestRevisionId ?? ""}>
|
||||
{revisionHistory.map((revision) => {
|
||||
const isCurrentRevision = revision.id === currentRevision.id;
|
||||
const isCurrentRevision = revision.id === doc.latestRevisionId;
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
key={revision.id}
|
||||
|
||||
@@ -1,17 +1,60 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: mockIssuesApi,
|
||||
}));
|
||||
|
||||
function renderMarkdown(children: string, seededIssues: Array<{ identifier: string; status: string }> = []) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const issue of seededIssues) {
|
||||
queryClient.setQueryData(queryKeys.issues.detail(issue.identifier), {
|
||||
id: issue.identifier,
|
||||
identifier: issue.identifier,
|
||||
status: issue.status,
|
||||
});
|
||||
}
|
||||
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{children}</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MarkdownBody", () => {
|
||||
it("renders markdown images without a resolver", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{""}</MarkdownBody>
|
||||
</ThemeProvider>,
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{""}</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('<img src="/api/attachments/test/content" alt=""/>');
|
||||
@@ -19,11 +62,13 @@ describe("MarkdownBody", () => {
|
||||
|
||||
it("resolves relative image paths when a resolver is provided", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<MarkdownBody resolveImageSrc={(src) => `/resolved/${src}`}>
|
||||
{""}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>,
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody resolveImageSrc={(src) => `/resolved/${src}`}>
|
||||
{""}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('src="/resolved/images/org-chart.png"');
|
||||
@@ -32,11 +77,13 @@ describe("MarkdownBody", () => {
|
||||
|
||||
it("renders agent, project, and skill mentions as chips", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>
|
||||
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>,
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>
|
||||
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/agents/agent-123"');
|
||||
@@ -48,4 +95,64 @@ describe("MarkdownBody", () => {
|
||||
expect(html).toContain('href="/skills/skill-789"');
|
||||
expect(html).toContain('data-mention-kind="skill"');
|
||||
});
|
||||
|
||||
it("uses soft-break styling by default", () => {
|
||||
const html = renderMarkdown("First line\nSecond line");
|
||||
|
||||
expect(html).toContain("First line<br/>");
|
||||
expect(html).toContain("Second line");
|
||||
});
|
||||
|
||||
it("can opt out of soft-break styling", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody softBreaks={false}>
|
||||
{"First line\nSecond line"}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("<br/>");
|
||||
});
|
||||
|
||||
it("does not inject extra line-break nodes into nested lists", () => {
|
||||
const html = renderMarkdown("1. Parent item\n - child a\n - child b\n\n2. Second item");
|
||||
|
||||
expect(html).not.toContain("[&_p]:whitespace-pre-line");
|
||||
expect(html).not.toContain("Parent item<br/>");
|
||||
expect(html).toContain("<ol>");
|
||||
expect(html).toContain("<ul>");
|
||||
});
|
||||
|
||||
it("linkifies bare issue identifiers in markdown text", () => {
|
||||
const html = renderMarkdown("Depends on PAP-1271 for the hover state.", [
|
||||
{ identifier: "PAP-1271", status: "done" },
|
||||
]);
|
||||
|
||||
expect(html).toContain('href="/issues/PAP-1271"');
|
||||
expect(html).toContain("text-green-600");
|
||||
expect(html).toContain(">PAP-1271<");
|
||||
});
|
||||
|
||||
it("rewrites full issue URLs to internal issue links", () => {
|
||||
const html = renderMarkdown("See http://localhost:3100/PAP/issues/PAP-1179.", [
|
||||
{ identifier: "PAP-1179", status: "blocked" },
|
||||
]);
|
||||
|
||||
expect(html).toContain('href="/issues/PAP-1179"');
|
||||
expect(html).toContain("text-red-600");
|
||||
expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<");
|
||||
});
|
||||
|
||||
it("linkifies issue identifiers inside inline code spans", () => {
|
||||
const html = renderMarkdown("Reference `PAP-1271` here.", [
|
||||
{ identifier: "PAP-1271", status: "done" },
|
||||
]);
|
||||
|
||||
expect(html).toContain('href="/issues/PAP-1271"');
|
||||
expect(html).toContain("<code>PAP-1271</code>");
|
||||
expect(html).toContain("text-green-600");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { isValidElement, useEffect, useId, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Markdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Link } from "@/lib/router";
|
||||
import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference";
|
||||
import { remarkSoftBreaks } from "../lib/remark-soft-breaks";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
|
||||
interface MarkdownBodyProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
softBreaks?: boolean;
|
||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||
resolveImageSrc?: (src: string) => string | null;
|
||||
/** Called when a user clicks an inline image */
|
||||
@@ -17,6 +25,29 @@ interface MarkdownBodyProps {
|
||||
|
||||
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
|
||||
|
||||
function MarkdownIssueLink({
|
||||
issuePathId,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
issuePathId: string;
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.issues.detail(issuePathId),
|
||||
queryFn: () => issuesApi.get(issuePathId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Link to={href} className="inline-flex items-center gap-1.5 align-baseline">
|
||||
{data ? <StatusIcon status={data.status} className="h-3.5 w-3.5" /> : null}
|
||||
<span>{children}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function loadMermaid() {
|
||||
if (!mermaidLoaderPromise) {
|
||||
mermaidLoaderPromise = import("mermaid").then((module) => module.default);
|
||||
@@ -94,8 +125,11 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownBody({ children, className, style, resolveImageSrc, onImageClick }: MarkdownBodyProps) {
|
||||
export function MarkdownBody({ children, className, style, softBreaks = true, resolveImageSrc, onImageClick }: MarkdownBodyProps) {
|
||||
const { theme } = useTheme();
|
||||
const remarkPlugins = softBreaks
|
||||
? [remarkGfm, remarkLinkIssueReferences, remarkSoftBreaks]
|
||||
: [remarkGfm, remarkLinkIssueReferences];
|
||||
const components: Components = {
|
||||
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
||||
const mermaidSource = extractMermaidSource(preChildren);
|
||||
@@ -105,6 +139,15 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm
|
||||
return <pre {...preProps}>{preChildren}</pre>;
|
||||
},
|
||||
a: ({ href, children: linkChildren }) => {
|
||||
const issueRef = parseIssueReferenceFromHref(href);
|
||||
if (issueRef) {
|
||||
return (
|
||||
<MarkdownIssueLink issuePathId={issueRef.issuePathId} href={issueRef.href}>
|
||||
{linkChildren}
|
||||
</MarkdownIssueLink>
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = href ? parseMentionChipHref(href) : null;
|
||||
if (parsed) {
|
||||
const targetHref = parsed.kind === "project"
|
||||
@@ -159,7 +202,7 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => url}>
|
||||
<Markdown remarkPlugins={remarkPlugins} components={components} urlTransform={(url) => url}>
|
||||
{children}
|
||||
</Markdown>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseIssuePathIdFromPath, parseIssueReferenceFromHref } from "./issue-reference";
|
||||
|
||||
describe("issue-reference", () => {
|
||||
it("extracts issue ids from company-scoped issue paths", () => {
|
||||
expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271");
|
||||
expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179");
|
||||
});
|
||||
|
||||
it("extracts issue ids from full issue URLs", () => {
|
||||
expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179");
|
||||
});
|
||||
|
||||
it("normalizes bare identifiers and issue URLs into internal links", () => {
|
||||
expect(parseIssueReferenceFromHref("pap-1271")).toEqual({
|
||||
issuePathId: "PAP-1271",
|
||||
href: "/issues/PAP-1271",
|
||||
});
|
||||
expect(parseIssueReferenceFromHref("http://localhost:3100/PAP/issues/PAP-1179")).toEqual({
|
||||
issuePathId: "PAP-1179",
|
||||
href: "/issues/PAP-1179",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes exact inline-code-like issue identifiers", () => {
|
||||
expect(parseIssueReferenceFromHref("PAP-1271")).toEqual({
|
||||
issuePathId: "PAP-1271",
|
||||
href: "/issues/PAP-1271",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
type MarkdownNode = {
|
||||
type: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
children?: MarkdownNode[];
|
||||
};
|
||||
|
||||
const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]+-\d+$/i;
|
||||
const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\b[A-Z][A-Z0-9]+-\d+\b/gi;
|
||||
|
||||
export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null {
|
||||
if (!pathOrUrl) return null;
|
||||
let pathname = pathOrUrl.trim();
|
||||
if (!pathname) return null;
|
||||
|
||||
if (/^https?:\/\//i.test(pathname)) {
|
||||
try {
|
||||
pathname = new URL(pathname).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const issueIndex = segments.findIndex((segment) => segment === "issues");
|
||||
if (issueIndex === -1 || issueIndex === segments.length - 1) return null;
|
||||
return decodeURIComponent(segments[issueIndex + 1] ?? "");
|
||||
}
|
||||
|
||||
export function parseIssueReferenceFromHref(href: string | null | undefined) {
|
||||
if (!href) return null;
|
||||
const pathId = parseIssuePathIdFromPath(href);
|
||||
if (pathId) {
|
||||
return {
|
||||
issuePathId: pathId,
|
||||
href: `/issues/${encodeURIComponent(pathId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmed = href.trim();
|
||||
if (!BARE_ISSUE_IDENTIFIER_RE.test(trimmed)) return null;
|
||||
const normalized = trimmed.toUpperCase();
|
||||
return {
|
||||
issuePathId: normalized,
|
||||
href: `/issues/${encodeURIComponent(normalized)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function splitTrailingPunctuation(token: string) {
|
||||
let core = token;
|
||||
let trailing = "";
|
||||
|
||||
while (core.length > 0) {
|
||||
const lastChar = core.at(-1);
|
||||
if (!lastChar || !/[),.;!?]/.test(lastChar)) break;
|
||||
if (lastChar === ")") {
|
||||
const openCount = (core.match(/\(/g) ?? []).length;
|
||||
const closeCount = (core.match(/\)/g) ?? []).length;
|
||||
if (closeCount <= openCount) break;
|
||||
}
|
||||
trailing = `${lastChar}${trailing}`;
|
||||
core = core.slice(0, -1);
|
||||
}
|
||||
|
||||
return { core, trailing };
|
||||
}
|
||||
|
||||
function createIssueLinkNode(value: string, href: string, childType: "text" | "inlineCode" = "text"): MarkdownNode {
|
||||
return {
|
||||
type: "link",
|
||||
url: href,
|
||||
children: [{ type: childType, value }],
|
||||
};
|
||||
}
|
||||
|
||||
function linkifyIssueReferencesInText(value: string): MarkdownNode[] | null {
|
||||
const nodes: MarkdownNode[] = [];
|
||||
let cursor = 0;
|
||||
let matched = false;
|
||||
|
||||
for (const match of value.matchAll(ISSUE_REFERENCE_TOKEN_RE)) {
|
||||
const raw = match[0];
|
||||
if (!raw) continue;
|
||||
|
||||
const start = match.index ?? 0;
|
||||
const end = start + raw.length;
|
||||
const { core, trailing } = splitTrailingPunctuation(raw);
|
||||
const issueRef = parseIssueReferenceFromHref(core);
|
||||
if (!issueRef) continue;
|
||||
|
||||
matched = true;
|
||||
if (start > cursor) {
|
||||
nodes.push({ type: "text", value: value.slice(cursor, start) });
|
||||
}
|
||||
nodes.push(createIssueLinkNode(core, issueRef.href));
|
||||
if (trailing) {
|
||||
nodes.push({ type: "text", value: trailing });
|
||||
}
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
if (!matched) return null;
|
||||
if (cursor < value.length) {
|
||||
nodes.push({ type: "text", value: value.slice(cursor) });
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function rewriteMarkdownTree(node: MarkdownNode) {
|
||||
if (!Array.isArray(node.children) || node.children.length === 0) return;
|
||||
if (node.type === "link" || node.type === "linkReference" || node.type === "code" || node.type === "definition" || node.type === "html") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextChildren: MarkdownNode[] = [];
|
||||
for (const child of node.children) {
|
||||
if (child.type === "inlineCode" && typeof child.value === "string") {
|
||||
const issueRef = parseIssueReferenceFromHref(child.value);
|
||||
if (issueRef) {
|
||||
nextChildren.push(createIssueLinkNode(child.value, issueRef.href, "inlineCode"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (child.type === "text" && typeof child.value === "string") {
|
||||
const linked = linkifyIssueReferencesInText(child.value);
|
||||
if (linked) {
|
||||
nextChildren.push(...linked);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
rewriteMarkdownTree(child);
|
||||
nextChildren.push(child);
|
||||
}
|
||||
node.children = nextChildren;
|
||||
}
|
||||
|
||||
export function remarkLinkIssueReferences() {
|
||||
return (tree: MarkdownNode) => {
|
||||
rewriteMarkdownTree(tree);
|
||||
};
|
||||
}
|
||||
@@ -312,6 +312,22 @@ describe("optimistic issue comments", () => {
|
||||
projectWorkspaceId: "workspace-1",
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
ancestors: [
|
||||
{
|
||||
id: "issue-9",
|
||||
identifier: "PAP-9",
|
||||
title: "Old parent",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
project: null,
|
||||
goal: null,
|
||||
},
|
||||
],
|
||||
title: "Fix property pane",
|
||||
description: null,
|
||||
status: "todo",
|
||||
@@ -449,6 +465,7 @@ describe("optimistic issue comments", () => {
|
||||
assigneeUserId: "board-2",
|
||||
labelIds: ["label-2"],
|
||||
blockedByIssueIds: ["issue-3"],
|
||||
parentId: "issue-4",
|
||||
projectId: "project-2",
|
||||
executionWorkspaceId: "exec-2",
|
||||
},
|
||||
@@ -460,6 +477,8 @@ describe("optimistic issue comments", () => {
|
||||
expect(next?.labelIds).toEqual(["label-2"]);
|
||||
expect(next?.labels?.map((label) => label.id)).toEqual(["label-2"]);
|
||||
expect(next?.blockedBy?.map((relation) => relation.id)).toEqual(["issue-3"]);
|
||||
expect(next?.parentId).toBe("issue-4");
|
||||
expect(next?.ancestors).toBeUndefined();
|
||||
expect(next?.projectId).toBe("project-2");
|
||||
expect(next?.project).toBeNull();
|
||||
expect(next?.executionWorkspaceId).toBe("exec-2");
|
||||
|
||||
@@ -169,6 +169,7 @@ export function applyOptimisticIssueFieldUpdate(
|
||||
assign("assigneeAgentId");
|
||||
assign("assigneeUserId");
|
||||
assign("projectId");
|
||||
assign("parentId");
|
||||
assign("projectWorkspaceId");
|
||||
assign("executionWorkspaceId");
|
||||
assign("executionWorkspacePreference");
|
||||
@@ -194,6 +195,10 @@ export function applyOptimisticIssueFieldUpdate(
|
||||
nextIssue.project = issue.project?.id === nextIssue.projectId ? issue.project : null;
|
||||
}
|
||||
|
||||
if (hasOwn("parentId")) {
|
||||
nextIssue.ancestors = undefined;
|
||||
}
|
||||
|
||||
if (hasOwn("executionWorkspaceId")) {
|
||||
nextIssue.currentExecutionWorkspace =
|
||||
issue.currentExecutionWorkspace?.id === nextIssue.executionWorkspaceId
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
type MarkdownNode = {
|
||||
type?: unknown;
|
||||
value?: unknown;
|
||||
children?: unknown;
|
||||
};
|
||||
|
||||
type MarkdownTextNode = {
|
||||
type: "text";
|
||||
value: string;
|
||||
};
|
||||
|
||||
type MarkdownBreakNode = {
|
||||
type: "break";
|
||||
};
|
||||
|
||||
type MarkdownParentNode = {
|
||||
children: MarkdownTreeNode[];
|
||||
};
|
||||
|
||||
type MarkdownTreeNode = MarkdownTextNode | MarkdownBreakNode | (MarkdownNode & { children?: MarkdownTreeNode[] });
|
||||
|
||||
function isParentNode(value: unknown): value is MarkdownParentNode {
|
||||
return typeof value === "object" && value !== null && Array.isArray((value as MarkdownNode).children);
|
||||
}
|
||||
|
||||
function buildSoftBreakReplacement(value: string): Array<MarkdownTextNode | MarkdownBreakNode> {
|
||||
const parts = value.split("\n");
|
||||
const replacement: Array<MarkdownTextNode | MarkdownBreakNode> = [];
|
||||
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
const part = parts[index];
|
||||
if (part.length > 0) {
|
||||
replacement.push({ type: "text", value: part });
|
||||
}
|
||||
if (index < parts.length - 1) {
|
||||
replacement.push({ type: "break" });
|
||||
}
|
||||
}
|
||||
|
||||
return replacement.length > 0 ? replacement : [{ type: "text", value: "" }];
|
||||
}
|
||||
|
||||
function transformNode(node: MarkdownTreeNode) {
|
||||
if (!isParentNode(node)) return;
|
||||
|
||||
for (let index = 0; index < node.children.length; index += 1) {
|
||||
const child = node.children[index];
|
||||
if (child?.type === "text" && typeof child.value === "string" && child.value.includes("\n")) {
|
||||
const replacement = buildSoftBreakReplacement(child.value);
|
||||
node.children.splice(index, 1, ...replacement);
|
||||
index += replacement.length - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
transformNode(child);
|
||||
}
|
||||
}
|
||||
|
||||
export function remarkSoftBreaks() {
|
||||
return (tree: MarkdownTreeNode) => {
|
||||
transformNode(tree);
|
||||
};
|
||||
}
|
||||
+18
-3
@@ -2,11 +2,13 @@ import * as React from "react";
|
||||
import * as RouterDom from "react-router-dom";
|
||||
import type { NavigateOptions, To } from "react-router-dom";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook";
|
||||
import {
|
||||
applyCompanyPrefix,
|
||||
extractCompanyPrefixFromPath,
|
||||
normalizeCompanyPrefix,
|
||||
} from "@/lib/company-routes";
|
||||
import { parseIssuePathIdFromPath } from "@/lib/issue-reference";
|
||||
|
||||
function resolveTo(to: To, companyPrefix: string | null): To {
|
||||
if (typeof to === "string") {
|
||||
@@ -40,10 +42,23 @@ function useActiveCompanyPrefix(): string | null {
|
||||
|
||||
export * from "react-router-dom";
|
||||
|
||||
export const Link = React.forwardRef<HTMLAnchorElement, React.ComponentProps<typeof RouterDom.Link>>(
|
||||
function CompanyLink({ to, ...props }, ref) {
|
||||
type CompanyLinkProps = React.ComponentProps<typeof RouterDom.Link> & {
|
||||
disableIssueQuicklook?: boolean;
|
||||
};
|
||||
|
||||
export const Link = React.forwardRef<HTMLAnchorElement, CompanyLinkProps>(
|
||||
function CompanyLink({ to, disableIssueQuicklook = false, ...props }, ref) {
|
||||
const companyPrefix = useActiveCompanyPrefix();
|
||||
return <RouterDom.Link ref={ref} to={resolveTo(to, companyPrefix)} {...props} />;
|
||||
const resolvedTo = resolveTo(to, companyPrefix);
|
||||
const issuePathId = disableIssueQuicklook
|
||||
? null
|
||||
: parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname);
|
||||
|
||||
if (issuePathId) {
|
||||
return <IssueLinkQuicklook ref={ref} to={resolvedTo} issuePathId={issuePathId} {...props} />;
|
||||
}
|
||||
|
||||
return <RouterDom.Link ref={ref} to={resolvedTo} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -532,10 +532,10 @@ function ExportPreviewPane({
|
||||
{parsed ? (
|
||||
<>
|
||||
<FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} />
|
||||
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc}>{parsed.body}</MarkdownBody>}
|
||||
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false}>{parsed.body}</MarkdownBody>}
|
||||
</>
|
||||
) : isMarkdown ? (
|
||||
<MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
|
||||
<MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false}>{textContent ?? ""}</MarkdownBody>
|
||||
) : imageSrc ? (
|
||||
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
|
||||
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
|
||||
@@ -983,6 +983,7 @@ export function CompanyExport() {
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Search files..."
|
||||
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
data-page-search-target="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -241,10 +241,10 @@ function ImportPreviewPane({
|
||||
{parsed ? (
|
||||
<>
|
||||
<FrontmatterCard data={parsed.data} />
|
||||
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc}>{parsed.body}</MarkdownBody>}
|
||||
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false}>{parsed.body}</MarkdownBody>}
|
||||
</>
|
||||
) : isMarkdown ? (
|
||||
<MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
|
||||
<MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false}>{textContent ?? ""}</MarkdownBody>
|
||||
) : imageSrc ? (
|
||||
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
|
||||
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
|
||||
|
||||
@@ -742,7 +742,7 @@ function SkillPane({
|
||||
/>
|
||||
)
|
||||
) : file.markdown && viewMode === "preview" ? (
|
||||
<MarkdownBody>{body}</MarkdownBody>
|
||||
<MarkdownBody softBreaks={false}>{body}</MarkdownBody>
|
||||
) : (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 font-mono text-sm text-foreground">
|
||||
<code>{file.content}</code>
|
||||
|
||||
Reference in New Issue
Block a user