diff --git a/ui/src/adapters/transcript.test.ts b/ui/src/adapters/transcript.test.ts
index c33c9008..ddd164a5 100644
--- a/ui/src/adapters/transcript.test.ts
+++ b/ui/src/adapters/transcript.test.ts
@@ -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: [],
+ },
+ ]);
+ });
});
diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts
index 307aa5ae..95a81707 100644
--- a/ui/src/adapters/transcript.ts
+++ b/ui/src/adapters/transcript.ts
@@ -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?.();
diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx
index 02e0d4b4..f4ad17b5 100644
--- a/ui/src/components/CommentThread.tsx
+++ b/ui/src/components/CommentThread.tsx
@@ -369,7 +369,7 @@ function CommentCard({
- {comment.body}
+ {comment.body}
{companyId && !isPending ? (
+
{text}
);
diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx
index 6923befa..0acbca9a 100644
--- a/ui/src/components/IssueDocumentsSection.tsx
+++ b/ui/src/components/IssueDocumentsSection.tsx
@@ -70,7 +70,7 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
}
function renderBody(body: string, className?: string) {
- return {body};
+ return {body};
}
function isPlanKey(key: string) {
diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx
index fa8d46de..f30db991 100644
--- a/ui/src/components/MarkdownBody.test.tsx
+++ b/ui/src/components/MarkdownBody.test.tsx
@@ -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 }) => {children},
+}));
+
+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(
+
+
+ {children}
+
+ ,
+ );
+}
describe("MarkdownBody", () => {
it("renders markdown images without a resolver", () => {
const html = renderToStaticMarkup(
-
- {""}
- ,
+
+
+ {""}
+
+ ,
);
expect(html).toContain('
');
@@ -19,11 +62,13 @@ describe("MarkdownBody", () => {
it("resolves relative image paths when a resolver is provided", () => {
const html = renderToStaticMarkup(
-
- `/resolved/${src}`}>
- {""}
-
- ,
+
+
+ `/resolved/${src}`}>
+ {""}
+
+
+ ,
);
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(
-
-
- {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
-
- ,
+
+
+
+ {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
+
+
+ ,
);
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
");
+ expect(html).toContain("Second line");
+ });
+
+ it("can opt out of soft-break styling", () => {
+ const html = renderToStaticMarkup(
+
+
+
+ {"First line\nSecond line"}
+
+
+ ,
+ );
+
+ expect(html).not.toContain("
");
+ });
+
+ 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
");
+ expect(html).toContain("");
+ expect(html).toContain("");
+ });
+
+ 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("PAP-1271");
+ expect(html).toContain("text-green-600");
+ });
+
+ it("can opt out of issue reference linkification for offline previews", () => {
+ const html = renderToStaticMarkup(
+
+
+
+ {"Depends on PAP-1271 and [manual link](PAP-1271)."}
+
+
+ ,
+ );
+
+ expect(html).not.toContain('href="/issues/PAP-1271"');
+ expect(html).toContain("Depends on PAP-1271");
+ expect(html).toContain('href="PAP-1271"');
+ });
});
diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx
index a4542607..c9fdf859 100644
--- a/ui/src/components/MarkdownBody.tsx
+++ b/ui/src/components/MarkdownBody.tsx
@@ -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 | 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 (
+
+ {data ? : null}
+ {children}
+
+ );
+}
+
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 = [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 {preChildren};
},
a: ({ href, children: linkChildren }) => {
+ const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
+ if (issueRef) {
+ return (
+
+ {linkChildren}
+
+ );
+ }
+
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}
>
- url}>
+ url}>
{children}
diff --git a/ui/src/lib/issue-reference.test.ts b/ui/src/lib/issue-reference.test.ts
new file mode 100644
index 00000000..9c334b56
--- /dev/null
+++ b/ui/src/lib/issue-reference.test.ts
@@ -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",
+ });
+ });
+});
diff --git a/ui/src/lib/issue-reference.ts b/ui/src/lib/issue-reference.ts
new file mode 100644
index 00000000..3c6d1109
--- /dev/null
+++ b/ui/src/lib/issue-reference.ts
@@ -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);
+ };
+}
diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts
index bf0cdda5..e977ba0d 100644
--- a/ui/src/lib/optimistic-issue-comments.test.ts
+++ b/ui/src/lib/optimistic-issue-comments.test.ts
@@ -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");
diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts
index 73d9aea8..9acd4954 100644
--- a/ui/src/lib/optimistic-issue-comments.ts
+++ b/ui/src/lib/optimistic-issue-comments.ts
@@ -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
diff --git a/ui/src/lib/remark-soft-breaks.ts b/ui/src/lib/remark-soft-breaks.ts
new file mode 100644
index 00000000..dce07e89
--- /dev/null
+++ b/ui/src/lib/remark-soft-breaks.ts
@@ -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 {
+ const parts = value.split("\n");
+ const replacement: Array = [];
+
+ 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);
+ };
+}
diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx
index e82aeafb..8c4f46c9 100644
--- a/ui/src/pages/CompanyExport.tsx
+++ b/ui/src/pages/CompanyExport.tsx
@@ -532,10 +532,10 @@ function ExportPreviewPane({
{parsed ? (
<>
- {parsed.body.trim() && {parsed.body}}
+ {parsed.body.trim() && {parsed.body}}
>
) : isMarkdown ? (
- {textContent ?? ""}
+ {textContent ?? ""}
) : imageSrc ? (

@@ -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"
/>
diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx
index a4ab82b5..b2c6676c 100644
--- a/ui/src/pages/CompanyImport.tsx
+++ b/ui/src/pages/CompanyImport.tsx
@@ -241,10 +241,10 @@ function ImportPreviewPane({
{parsed ? (
<>
- {parsed.body.trim() && {parsed.body}}
+ {parsed.body.trim() && {parsed.body}}
>
) : isMarkdown ? (
- {textContent ?? ""}
+ {textContent ?? ""}
) : imageSrc ? (

diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx
index cc2d5605..5a953375 100644
--- a/ui/src/pages/CompanySkills.tsx
+++ b/ui/src/pages/CompanySkills.tsx
@@ -742,7 +742,7 @@ function SkillPane({
/>
)
) : file.markdown && viewMode === "preview" ? (
-
{body}
+
{body}
) : (
{file.content}