Merge pull request #3355 from cryppadotta/pap-1331-issue-thread-ux

feat: polish issue thread markdown and references
This commit is contained in:
Dotta
2026-04-11 06:55:26 -05:00
committed by GitHub
15 changed files with 660 additions and 27 deletions
+112
View File
@@ -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: [],
},
]);
});
});
+77 -2
View File
@@ -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?.();
+1 -1
View File
@@ -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
+6 -1
View File
@@ -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>
);
+1 -1
View File
@@ -70,7 +70,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) {
+137 -14
View File
@@ -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>{"![](/api/attachments/test/content)"}</MarkdownBody>
</ThemeProvider>,
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>
<MarkdownBody>{"![](/api/attachments/test/content)"}</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}`}>
{"![Org chart](images/org-chart.png)"}
</MarkdownBody>
</ThemeProvider>,
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>
<MarkdownBody resolveImageSrc={(src) => `/resolved/${src}`}>
{"![Org chart](images/org-chart.png)"}
</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,80 @@ 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("[&amp;_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"');
});
});
+59 -3
View File
@@ -1,14 +1,23 @@
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 { 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;
linkIssueReferences?: 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 +26,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 +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 remarkPlugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
if (linkIssueReferences) {
remarkPlugins.push(remarkLinkIssueReferences);
}
if (softBreaks) {
remarkPlugins.push(remarkSoftBreaks);
}
const components: Components = {
pre: ({ node: _node, children: preChildren, ...preProps }) => {
const mermaidSource = extractMermaidSource(preChildren);
@@ -105,6 +152,15 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm
return <pre {...preProps}>{preChildren}</pre>;
},
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;
if (parsed) {
const targetHref = parsed.kind === "project"
@@ -159,7 +215,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>
+31
View 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
View 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",
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");
+5
View File
@@ -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
+63
View 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);
};
}
+3 -2
View File
@@ -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} linkIssueReferences={false}>{parsed.body}</MarkdownBody>}
</>
) : isMarkdown ? (
<MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
<MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false} linkIssueReferences={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>
+2 -2
View File
@@ -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} linkIssueReferences={false}>{parsed.body}</MarkdownBody>}
</>
) : isMarkdown ? (
<MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
<MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false} linkIssueReferences={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" />
+1 -1
View File
@@ -742,7 +742,7 @@ function SkillPane({
/>
)
) : 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">
<code>{file.content}</code>