From 958c11699e28d7fec77a16bfafd81f216ff5b208 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 10 Apr 2026 22:26:21 -0500 Subject: [PATCH 1/3] feat: polish issue thread markdown and references --- ui/src/adapters/transcript.test.ts | 112 +++++++++++++++ ui/src/adapters/transcript.ts | 79 +++++++++- ui/src/components/CommentThread.tsx | 2 +- ui/src/components/IssueChatThread.tsx | 7 +- ui/src/components/IssueDocumentsSection.tsx | 28 ++-- ui/src/components/MarkdownBody.test.tsx | 135 +++++++++++++++-- ui/src/components/MarkdownBody.tsx | 47 +++++- ui/src/lib/issue-reference.test.ts | 31 ++++ ui/src/lib/issue-reference.ts | 143 +++++++++++++++++++ ui/src/lib/optimistic-issue-comments.test.ts | 19 +++ ui/src/lib/optimistic-issue-comments.ts | 5 + ui/src/lib/remark-soft-breaks.ts | 63 ++++++++ ui/src/lib/router.tsx | 21 ++- ui/src/pages/CompanyExport.tsx | 5 +- ui/src/pages/CompanyImport.tsx | 4 +- ui/src/pages/CompanySkills.tsx | 2 +- 16 files changed, 659 insertions(+), 44 deletions(-) create mode 100644 ui/src/lib/issue-reference.test.ts create mode 100644 ui/src/lib/issue-reference.ts create mode 100644 ui/src/lib/remark-soft-breaks.ts 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..d2dade97 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -12,7 +12,6 @@ import { useLocation } from "@/lib/router"; import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator"; -import { deriveDocumentRevisionState } from "../lib/document-revisions"; import { queryKeys } from "../lib/queryKeys"; import { cn, relativeTime } from "../lib/utils"; import { MarkdownBody } from "./MarkdownBody"; @@ -70,7 +69,7 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) { } function renderBody(body: string, className?: string) { - return {body}; + return {body}; } function isPlanKey(key: string) { @@ -537,10 +536,10 @@ export function IssueDocumentsSection({ }, []); const previewRevision = useCallback((doc: IssueDocument, revisionId: string) => { - const revisionState = deriveDocumentRevisionState(doc, getDocumentRevisions(doc.key)); - const selectedRevision = revisionState.revisions.find((revision) => revision.id === revisionId); + const revisions = getDocumentRevisions(doc.key); + const selectedRevision = revisions.find((revision) => revision.id === revisionId); if (!selectedRevision) return; - if (selectedRevision.id === revisionState.currentRevision.id) { + if (selectedRevision.id === doc.latestRevisionId) { returnToLatestRevision(doc.key); return; } @@ -788,10 +787,7 @@ export function IssueDocumentsSection({ const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null; const activeConflict = documentConflict?.key === doc.key ? documentConflict : null; const isFolded = foldedDocumentKeys.includes(doc.key); - const rawRevisionHistory = getDocumentRevisions(doc.key); - const revisionState = deriveDocumentRevisionState(doc, rawRevisionHistory); - const revisionHistory = revisionState.revisions; - const currentRevision = revisionState.currentRevision; + const revisionHistory = getDocumentRevisions(doc.key); const selectedRevisionId = selectedRevisionIds[doc.key] ?? null; const selectedHistoricalRevision = selectedRevisionId ? revisionHistory.find((revision) => revision.id === selectedRevisionId) ?? null @@ -799,10 +795,10 @@ export function IssueDocumentsSection({ const isHistoricalPreview = Boolean(selectedHistoricalRevision); const displayedTitle = selectedHistoricalRevision ? selectedHistoricalRevision.title ?? "" - : activeDraft?.title ?? currentRevision.title ?? ""; - const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? currentRevision.body; - const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? currentRevision.revisionNumber; - const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? currentRevision.createdAt; + : activeDraft?.title ?? doc.title ?? ""; + const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? doc.body; + const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? doc.latestRevisionNumber; + const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? doc.updatedAt; const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key); const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote); @@ -849,12 +845,12 @@ export function IssueDocumentsSection({ Revision history - {revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && rawRevisionHistory.length === 0 ? ( + {revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && revisionHistory.length === 0 ? ( Loading revisions... ) : revisionHistory.length > 0 ? ( - + {revisionHistory.map((revision) => { - const isCurrentRevision = revision.id === currentRevision.id; + const isCurrentRevision = revision.id === doc.latestRevisionId; return ( ({ + 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( - - {"![](/api/attachments/test/content)"} - , + + + {"![](/api/attachments/test/content)"} + + , ); expect(html).toContain(''); @@ -19,11 +62,13 @@ describe("MarkdownBody", () => { it("resolves relative image paths when a resolver is provided", () => { const html = renderToStaticMarkup( - - `/resolved/${src}`}> - {"![Org chart](images/org-chart.png)"} - - , + + + `/resolved/${src}`}> + {"![Org chart](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", () => { 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,64 @@ describe("MarkdownBody", () => { expect(html).toContain('href="/skills/skill-789"'); expect(html).toContain('data-mention-kind="skill"'); }); + + it("uses soft-break styling by default", () => { + const html = renderMarkdown("First line\nSecond line"); + + expect(html).toContain("First line
"); + 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"); + }); }); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index a4542607..c0430a3a 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,14 +1,22 @@ import { isValidElement, useEffect, useId, useState, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; import Markdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; import { useTheme } from "../context/ThemeContext"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; +import { issuesApi } from "../api/issues"; +import { queryKeys } from "../lib/queryKeys"; +import { Link } from "@/lib/router"; +import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference"; +import { remarkSoftBreaks } from "../lib/remark-soft-breaks"; +import { StatusIcon } from "./StatusIcon"; interface MarkdownBodyProps { children: string; className?: string; style?: React.CSSProperties; + softBreaks?: boolean; /** Optional resolver for relative image paths (e.g. within export packages) */ resolveImageSrc?: (src: string) => string | null; /** Called when a user clicks an inline image */ @@ -17,6 +25,29 @@ interface MarkdownBodyProps { let mermaidLoaderPromise: Promise | 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 +125,11 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b ); } -export function MarkdownBody({ children, className, style, resolveImageSrc, onImageClick }: MarkdownBodyProps) { +export function MarkdownBody({ children, className, style, softBreaks = true, resolveImageSrc, onImageClick }: MarkdownBodyProps) { const { theme } = useTheme(); + const remarkPlugins = softBreaks + ? [remarkGfm, remarkLinkIssueReferences, remarkSoftBreaks] + : [remarkGfm, remarkLinkIssueReferences]; const components: Components = { pre: ({ node: _node, children: preChildren, ...preProps }) => { const mermaidSource = extractMermaidSource(preChildren); @@ -105,6 +139,15 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm return
      {preChildren}
      ; }, a: ({ href, children: linkChildren }) => { + const issueRef = parseIssueReferenceFromHref(href); + if (issueRef) { + return ( + + {linkChildren} + + ); + } + const parsed = href ? parseMentionChipHref(href) : null; if (parsed) { const targetHref = parsed.kind === "project" @@ -159,7 +202,7 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm )} style={style} > - 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/lib/router.tsx b/ui/src/lib/router.tsx index 5cf81c8d..3cae923e 100644 --- a/ui/src/lib/router.tsx +++ b/ui/src/lib/router.tsx @@ -2,11 +2,13 @@ import * as React from "react"; import * as RouterDom from "react-router-dom"; import type { NavigateOptions, To } from "react-router-dom"; import { useCompany } from "@/context/CompanyContext"; +import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook"; import { applyCompanyPrefix, extractCompanyPrefixFromPath, normalizeCompanyPrefix, } from "@/lib/company-routes"; +import { parseIssuePathIdFromPath } from "@/lib/issue-reference"; function resolveTo(to: To, companyPrefix: string | null): To { if (typeof to === "string") { @@ -40,10 +42,23 @@ function useActiveCompanyPrefix(): string | null { export * from "react-router-dom"; -export const Link = React.forwardRef>( - function CompanyLink({ to, ...props }, ref) { +type CompanyLinkProps = React.ComponentProps & { + disableIssueQuicklook?: boolean; +}; + +export const Link = React.forwardRef( + function CompanyLink({ to, disableIssueQuicklook = false, ...props }, ref) { const companyPrefix = useActiveCompanyPrefix(); - return ; + const resolvedTo = resolveTo(to, companyPrefix); + const issuePathId = disableIssueQuicklook + ? null + : parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname); + + if (issuePathId) { + return ; + } + + return ; }, ); diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index e82aeafb..61221398 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 ? (
{selectedFile} @@ -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..c772c7b4 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 ? (
{selectedFile} diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index cc2d5605..2f170dcd 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}

From dc94e3d1dfb200f5a327d7f428564294e97da299 Mon Sep 17 00:00:00 2001
From: Dotta 
Date: Fri, 10 Apr 2026 22:36:45 -0500
Subject: [PATCH 2/3] fix: keep thread polish independent of quicklook routing

---
 ui/src/lib/router.tsx | 21 +++------------------
 1 file changed, 3 insertions(+), 18 deletions(-)

diff --git a/ui/src/lib/router.tsx b/ui/src/lib/router.tsx
index 3cae923e..5cf81c8d 100644
--- a/ui/src/lib/router.tsx
+++ b/ui/src/lib/router.tsx
@@ -2,13 +2,11 @@ import * as React from "react";
 import * as RouterDom from "react-router-dom";
 import type { NavigateOptions, To } from "react-router-dom";
 import { useCompany } from "@/context/CompanyContext";
-import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook";
 import {
   applyCompanyPrefix,
   extractCompanyPrefixFromPath,
   normalizeCompanyPrefix,
 } from "@/lib/company-routes";
-import { parseIssuePathIdFromPath } from "@/lib/issue-reference";
 
 function resolveTo(to: To, companyPrefix: string | null): To {
   if (typeof to === "string") {
@@ -42,23 +40,10 @@ function useActiveCompanyPrefix(): string | null {
 
 export * from "react-router-dom";
 
-type CompanyLinkProps = React.ComponentProps & {
-  disableIssueQuicklook?: boolean;
-};
-
-export const Link = React.forwardRef(
-  function CompanyLink({ to, disableIssueQuicklook = false, ...props }, ref) {
+export const Link = React.forwardRef>(
+  function CompanyLink({ to, ...props }, ref) {
     const companyPrefix = useActiveCompanyPrefix();
-    const resolvedTo = resolveTo(to, companyPrefix);
-    const issuePathId = disableIssueQuicklook
-      ? null
-      : parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname);
-
-    if (issuePathId) {
-      return ;
-    }
-
-    return ;
+    return ;
   },
 );
 

From b48be80d5d1837ca2848ca7ce227b15bc201ec7e Mon Sep 17 00:00:00 2001
From: Dotta 
Date: Sat, 11 Apr 2026 06:40:37 -0500
Subject: [PATCH 3/3] fix: address PR 3355 review regressions

---
 ui/src/components/IssueDocumentsSection.tsx | 26 ++++++++++++---------
 ui/src/components/MarkdownBody.test.tsx     | 16 +++++++++++++
 ui/src/components/MarkdownBody.tsx          | 25 +++++++++++++++-----
 ui/src/pages/CompanyExport.tsx              |  4 ++--
 ui/src/pages/CompanyImport.tsx              |  4 ++--
 ui/src/pages/CompanySkills.tsx              |  2 +-
 6 files changed, 55 insertions(+), 22 deletions(-)

diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx
index d2dade97..0acbca9a 100644
--- a/ui/src/components/IssueDocumentsSection.tsx
+++ b/ui/src/components/IssueDocumentsSection.tsx
@@ -12,6 +12,7 @@ import { useLocation } from "@/lib/router";
 import { ApiError } from "../api/client";
 import { issuesApi } from "../api/issues";
 import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
+import { deriveDocumentRevisionState } from "../lib/document-revisions";
 import { queryKeys } from "../lib/queryKeys";
 import { cn, relativeTime } from "../lib/utils";
 import { MarkdownBody } from "./MarkdownBody";
@@ -536,10 +537,10 @@ export function IssueDocumentsSection({
   }, []);
 
   const previewRevision = useCallback((doc: IssueDocument, revisionId: string) => {
-    const revisions = getDocumentRevisions(doc.key);
-    const selectedRevision = revisions.find((revision) => revision.id === revisionId);
+    const revisionState = deriveDocumentRevisionState(doc, getDocumentRevisions(doc.key));
+    const selectedRevision = revisionState.revisions.find((revision) => revision.id === revisionId);
     if (!selectedRevision) return;
-    if (selectedRevision.id === doc.latestRevisionId) {
+    if (selectedRevision.id === revisionState.currentRevision.id) {
       returnToLatestRevision(doc.key);
       return;
     }
@@ -787,7 +788,10 @@ export function IssueDocumentsSection({
           const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
           const activeConflict = documentConflict?.key === doc.key ? documentConflict : null;
           const isFolded = foldedDocumentKeys.includes(doc.key);
-          const revisionHistory = getDocumentRevisions(doc.key);
+          const rawRevisionHistory = getDocumentRevisions(doc.key);
+          const revisionState = deriveDocumentRevisionState(doc, rawRevisionHistory);
+          const revisionHistory = revisionState.revisions;
+          const currentRevision = revisionState.currentRevision;
           const selectedRevisionId = selectedRevisionIds[doc.key] ?? null;
           const selectedHistoricalRevision = selectedRevisionId
             ? revisionHistory.find((revision) => revision.id === selectedRevisionId) ?? null
@@ -795,10 +799,10 @@ export function IssueDocumentsSection({
           const isHistoricalPreview = Boolean(selectedHistoricalRevision);
           const displayedTitle = selectedHistoricalRevision
             ? selectedHistoricalRevision.title ?? ""
-            : activeDraft?.title ?? doc.title ?? "";
-          const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? doc.body;
-          const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? doc.latestRevisionNumber;
-          const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? doc.updatedAt;
+            : activeDraft?.title ?? currentRevision.title ?? "";
+          const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? currentRevision.body;
+          const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? currentRevision.revisionNumber;
+          const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? currentRevision.createdAt;
           const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key);
           const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote);
 
@@ -845,12 +849,12 @@ export function IssueDocumentsSection({
                       
                       
                         Revision history
-                        {revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && revisionHistory.length === 0 ? (
+                        {revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && rawRevisionHistory.length === 0 ? (
                           Loading revisions...
                         ) : revisionHistory.length > 0 ? (
-                          
+                          
                             {revisionHistory.map((revision) => {
-                              const isCurrentRevision = revision.id === doc.latestRevisionId;
+                              const isCurrentRevision = revision.id === currentRevision.id;
                               return (
                                  {
     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 c0430a3a..c9fdf859 100644
--- a/ui/src/components/MarkdownBody.tsx
+++ b/ui/src/components/MarkdownBody.tsx
@@ -1,6 +1,6 @@
 import { isValidElement, useEffect, useId, useState, type ReactNode } from "react";
 import { useQuery } from "@tanstack/react-query";
-import Markdown, { type Components } from "react-markdown";
+import Markdown, { type Components, type Options } from "react-markdown";
 import remarkGfm from "remark-gfm";
 import { cn } from "../lib/utils";
 import { useTheme } from "../context/ThemeContext";
@@ -17,6 +17,7 @@ interface MarkdownBodyProps {
   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 */
@@ -125,11 +126,23 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
   );
 }
 
-export function MarkdownBody({ children, className, style, softBreaks = true, resolveImageSrc, onImageClick }: MarkdownBodyProps) {
+export function MarkdownBody({
+  children,
+  className,
+  style,
+  softBreaks = true,
+  linkIssueReferences = true,
+  resolveImageSrc,
+  onImageClick,
+}: MarkdownBodyProps) {
   const { theme } = useTheme();
-  const remarkPlugins = softBreaks
-    ? [remarkGfm, remarkLinkIssueReferences, remarkSoftBreaks]
-    : [remarkGfm, remarkLinkIssueReferences];
+  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);
@@ -139,7 +152,7 @@ export function MarkdownBody({ children, className, style, softBreaks = true, re
       return 
{preChildren}
; }, a: ({ href, children: linkChildren }) => { - const issueRef = parseIssueReferenceFromHref(href); + const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null; if (issueRef) { return ( diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 61221398..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 ? (
{selectedFile} diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index c772c7b4..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 ? (
{selectedFile} diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 2f170dcd..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}