mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
Merge pull request #3355 from cryppadotta/pap-1331-issue-thread-ux
feat: polish issue thread markdown and references
This commit is contained in:
commit
e1bf9d66a7
15 changed files with 660 additions and 27 deletions
|
|
@ -70,4 +70,116 @@ describe("buildTranscript", () => {
|
||||||
expect(first).toEqual([]);
|
expect(first).toEqual([]);
|
||||||
expect(second).toEqual([{ kind: "stdout", ts, text: "literal:finish" }]);
|
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 };
|
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
||||||
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
|
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
|
||||||
|
type RedactionOptions = { enabled: boolean };
|
||||||
|
|
||||||
function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) {
|
function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) {
|
||||||
if (typeof source === "function") {
|
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(
|
export function buildTranscript(
|
||||||
chunks: RunLogChunk[],
|
chunks: RunLogChunk[],
|
||||||
parserSource: StdoutLineParser | TranscriptParserSource,
|
parserSource: StdoutLineParser | TranscriptParserSource,
|
||||||
|
|
@ -59,14 +120,28 @@ export function buildTranscript(
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
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();
|
const trailing = stdoutBuffer.trim();
|
||||||
if (trailing) {
|
if (trailing) {
|
||||||
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
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?.();
|
reset?.();
|
||||||
|
|
|
||||||
|
|
@ -369,7 +369,7 @@ function CommentCard({
|
||||||
<CopyMarkdownButton text={comment.body} />
|
<CopyMarkdownButton text={comment.body} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
<MarkdownBody className="text-sm" softBreaks>{comment.body}</MarkdownBody>
|
||||||
{companyId && !isPending ? (
|
{companyId && !isPending ? (
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
<PluginSlotOutlet
|
<PluginSlotOutlet
|
||||||
|
|
|
||||||
|
|
@ -424,7 +424,12 @@ function commentDateLabel(date: Date | string | undefined): string {
|
||||||
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
||||||
const { onImageClick } = useContext(IssueChatCtx);
|
const { onImageClick } = useContext(IssueChatCtx);
|
||||||
return (
|
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}
|
{text}
|
||||||
</MarkdownBody>
|
</MarkdownBody>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBody(body: string, className?: 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) {
|
function isPlanKey(key: string) {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,60 @@
|
||||||
// @vitest-environment node
|
// @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 { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||||
import { ThemeProvider } from "../context/ThemeContext";
|
import { ThemeProvider } from "../context/ThemeContext";
|
||||||
import { MarkdownBody } from "./MarkdownBody";
|
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", () => {
|
describe("MarkdownBody", () => {
|
||||||
it("renders markdown images without a resolver", () => {
|
it("renders markdown images without a resolver", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<ThemeProvider>
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
<MarkdownBody>{""}</MarkdownBody>
|
<ThemeProvider>
|
||||||
</ThemeProvider>,
|
<MarkdownBody>{""}</MarkdownBody>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain('<img src="/api/attachments/test/content" alt=""/>');
|
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", () => {
|
it("resolves relative image paths when a resolver is provided", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<ThemeProvider>
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
<MarkdownBody resolveImageSrc={(src) => `/resolved/${src}`}>
|
<ThemeProvider>
|
||||||
{""}
|
<MarkdownBody resolveImageSrc={(src) => `/resolved/${src}`}>
|
||||||
</MarkdownBody>
|
{""}
|
||||||
</ThemeProvider>,
|
</MarkdownBody>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain('src="/resolved/images/org-chart.png"');
|
expect(html).toContain('src="/resolved/images/org-chart.png"');
|
||||||
|
|
@ -32,11 +77,13 @@ describe("MarkdownBody", () => {
|
||||||
|
|
||||||
it("renders agent, project, and skill mentions as chips", () => {
|
it("renders agent, project, and skill mentions as chips", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<ThemeProvider>
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
<MarkdownBody>
|
<ThemeProvider>
|
||||||
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
|
<MarkdownBody>
|
||||||
</MarkdownBody>
|
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
|
||||||
</ThemeProvider>,
|
</MarkdownBody>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain('href="/agents/agent-123"');
|
expect(html).toContain('href="/agents/agent-123"');
|
||||||
|
|
@ -48,4 +95,80 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain('href="/skills/skill-789"');
|
expect(html).toContain('href="/skills/skill-789"');
|
||||||
expect(html).toContain('data-mention-kind="skill"');
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can opt out of issue reference linkification for offline previews", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<MarkdownBody linkIssueReferences={false}>
|
||||||
|
{"Depends on PAP-1271 and [manual link](PAP-1271)."}
|
||||||
|
</MarkdownBody>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).not.toContain('href="/issues/PAP-1271"');
|
||||||
|
expect(html).toContain("Depends on PAP-1271");
|
||||||
|
expect(html).toContain('href="PAP-1271"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
import { isValidElement, useEffect, useId, useState, type ReactNode } from "react";
|
import { isValidElement, useEffect, useId, useState, type ReactNode } from "react";
|
||||||
import Markdown, { type Components } from "react-markdown";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Markdown, { type Components, type Options } from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
|
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 {
|
interface MarkdownBodyProps {
|
||||||
children: string;
|
children: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
softBreaks?: boolean;
|
||||||
|
linkIssueReferences?: boolean;
|
||||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||||
resolveImageSrc?: (src: string) => string | null;
|
resolveImageSrc?: (src: string) => string | null;
|
||||||
/** Called when a user clicks an inline image */
|
/** Called when a user clicks an inline image */
|
||||||
|
|
@ -17,6 +26,29 @@ interface MarkdownBodyProps {
|
||||||
|
|
||||||
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
|
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() {
|
function loadMermaid() {
|
||||||
if (!mermaidLoaderPromise) {
|
if (!mermaidLoaderPromise) {
|
||||||
mermaidLoaderPromise = import("mermaid").then((module) => module.default);
|
mermaidLoaderPromise = import("mermaid").then((module) => module.default);
|
||||||
|
|
@ -94,8 +126,23 @@ 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,
|
||||||
|
linkIssueReferences = true,
|
||||||
|
resolveImageSrc,
|
||||||
|
onImageClick,
|
||||||
|
}: MarkdownBodyProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const remarkPlugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
|
||||||
|
if (linkIssueReferences) {
|
||||||
|
remarkPlugins.push(remarkLinkIssueReferences);
|
||||||
|
}
|
||||||
|
if (softBreaks) {
|
||||||
|
remarkPlugins.push(remarkSoftBreaks);
|
||||||
|
}
|
||||||
const components: Components = {
|
const components: Components = {
|
||||||
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
||||||
const mermaidSource = extractMermaidSource(preChildren);
|
const mermaidSource = extractMermaidSource(preChildren);
|
||||||
|
|
@ -105,6 +152,15 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm
|
||||||
return <pre {...preProps}>{preChildren}</pre>;
|
return <pre {...preProps}>{preChildren}</pre>;
|
||||||
},
|
},
|
||||||
a: ({ href, children: linkChildren }) => {
|
a: ({ href, children: linkChildren }) => {
|
||||||
|
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
|
||||||
|
if (issueRef) {
|
||||||
|
return (
|
||||||
|
<MarkdownIssueLink issuePathId={issueRef.issuePathId} href={issueRef.href}>
|
||||||
|
{linkChildren}
|
||||||
|
</MarkdownIssueLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = href ? parseMentionChipHref(href) : null;
|
const parsed = href ? parseMentionChipHref(href) : null;
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const targetHref = parsed.kind === "project"
|
const targetHref = parsed.kind === "project"
|
||||||
|
|
@ -159,7 +215,7 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm
|
||||||
)}
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<Markdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => url}>
|
<Markdown remarkPlugins={remarkPlugins} components={components} urlTransform={(url) => url}>
|
||||||
{children}
|
{children}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
31
ui/src/lib/issue-reference.test.ts
Normal file
31
ui/src/lib/issue-reference.test.ts
Normal file
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
143
ui/src/lib/issue-reference.ts
Normal file
143
ui/src/lib/issue-reference.ts
Normal file
|
|
@ -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",
|
projectWorkspaceId: "workspace-1",
|
||||||
goalId: null,
|
goalId: null,
|
||||||
parentId: 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",
|
title: "Fix property pane",
|
||||||
description: null,
|
description: null,
|
||||||
status: "todo",
|
status: "todo",
|
||||||
|
|
@ -449,6 +465,7 @@ describe("optimistic issue comments", () => {
|
||||||
assigneeUserId: "board-2",
|
assigneeUserId: "board-2",
|
||||||
labelIds: ["label-2"],
|
labelIds: ["label-2"],
|
||||||
blockedByIssueIds: ["issue-3"],
|
blockedByIssueIds: ["issue-3"],
|
||||||
|
parentId: "issue-4",
|
||||||
projectId: "project-2",
|
projectId: "project-2",
|
||||||
executionWorkspaceId: "exec-2",
|
executionWorkspaceId: "exec-2",
|
||||||
},
|
},
|
||||||
|
|
@ -460,6 +477,8 @@ describe("optimistic issue comments", () => {
|
||||||
expect(next?.labelIds).toEqual(["label-2"]);
|
expect(next?.labelIds).toEqual(["label-2"]);
|
||||||
expect(next?.labels?.map((label) => label.id)).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?.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?.projectId).toBe("project-2");
|
||||||
expect(next?.project).toBeNull();
|
expect(next?.project).toBeNull();
|
||||||
expect(next?.executionWorkspaceId).toBe("exec-2");
|
expect(next?.executionWorkspaceId).toBe("exec-2");
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ export function applyOptimisticIssueFieldUpdate(
|
||||||
assign("assigneeAgentId");
|
assign("assigneeAgentId");
|
||||||
assign("assigneeUserId");
|
assign("assigneeUserId");
|
||||||
assign("projectId");
|
assign("projectId");
|
||||||
|
assign("parentId");
|
||||||
assign("projectWorkspaceId");
|
assign("projectWorkspaceId");
|
||||||
assign("executionWorkspaceId");
|
assign("executionWorkspaceId");
|
||||||
assign("executionWorkspacePreference");
|
assign("executionWorkspacePreference");
|
||||||
|
|
@ -194,6 +195,10 @@ export function applyOptimisticIssueFieldUpdate(
|
||||||
nextIssue.project = issue.project?.id === nextIssue.projectId ? issue.project : null;
|
nextIssue.project = issue.project?.id === nextIssue.projectId ? issue.project : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasOwn("parentId")) {
|
||||||
|
nextIssue.ancestors = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (hasOwn("executionWorkspaceId")) {
|
if (hasOwn("executionWorkspaceId")) {
|
||||||
nextIssue.currentExecutionWorkspace =
|
nextIssue.currentExecutionWorkspace =
|
||||||
issue.currentExecutionWorkspace?.id === nextIssue.executionWorkspaceId
|
issue.currentExecutionWorkspace?.id === nextIssue.executionWorkspaceId
|
||||||
|
|
|
||||||
63
ui/src/lib/remark-soft-breaks.ts
Normal file
63
ui/src/lib/remark-soft-breaks.ts
Normal file
|
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -532,10 +532,10 @@ function ExportPreviewPane({
|
||||||
{parsed ? (
|
{parsed ? (
|
||||||
<>
|
<>
|
||||||
<FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} />
|
<FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} />
|
||||||
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc}>{parsed.body}</MarkdownBody>}
|
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false} linkIssueReferences={false}>{parsed.body}</MarkdownBody>}
|
||||||
</>
|
</>
|
||||||
) : isMarkdown ? (
|
) : isMarkdown ? (
|
||||||
<MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
|
<MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false} linkIssueReferences={false}>{textContent ?? ""}</MarkdownBody>
|
||||||
) : imageSrc ? (
|
) : imageSrc ? (
|
||||||
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
|
<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" />
|
<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)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
placeholder="Search files..."
|
placeholder="Search files..."
|
||||||
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
data-page-search-target="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -241,10 +241,10 @@ function ImportPreviewPane({
|
||||||
{parsed ? (
|
{parsed ? (
|
||||||
<>
|
<>
|
||||||
<FrontmatterCard data={parsed.data} />
|
<FrontmatterCard data={parsed.data} />
|
||||||
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc}>{parsed.body}</MarkdownBody>}
|
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false} linkIssueReferences={false}>{parsed.body}</MarkdownBody>}
|
||||||
</>
|
</>
|
||||||
) : isMarkdown ? (
|
) : isMarkdown ? (
|
||||||
<MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
|
<MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false} linkIssueReferences={false}>{textContent ?? ""}</MarkdownBody>
|
||||||
) : imageSrc ? (
|
) : imageSrc ? (
|
||||||
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
|
<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" />
|
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
|
||||||
|
|
|
||||||
|
|
@ -742,7 +742,7 @@ function SkillPane({
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : file.markdown && viewMode === "preview" ? (
|
) : file.markdown && viewMode === "preview" ? (
|
||||||
<MarkdownBody>{body}</MarkdownBody>
|
<MarkdownBody softBreaks={false} linkIssueReferences={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">
|
<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>
|
<code>{file.content}</code>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue