[codex] Refine markdown issue reference rendering (#4382)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Task references are a core part of how operators understand issue
relationships across the UI
> - Those references appear both in markdown bodies and in sidebar
relationship panels
> - The rendering had drifted between surfaces, and inline markdown
pills were reading awkwardly inside prose and lists
> - This pull request unifies the underlying issue-reference treatment,
routes issue descriptions through `MarkdownBody`, and switches inline
markdown references to a cleaner text-link presentation
> - The benefit is more consistent issue-reference UX with better
readability in markdown-heavy views

## What Changed

- unified sidebar and markdown issue-reference rendering around the
shared issue-reference components
- routed resting issue descriptions through `MarkdownBody` so
description previews inherit the richer issue-reference treatment
- replaced inline markdown pill chrome with a cleaner inline reference
presentation for prose contexts
- added and updated UI tests for `MarkdownBody` and `InlineEditor`

## Verification

- `pnpm exec vitest run --project @paperclipai/ui
ui/src/components/MarkdownBody.test.tsx
ui/src/components/InlineEditor.test.tsx`

## Risks

- Moderate UI risk: issue-reference rendering now differs intentionally
between inline markdown and relationship sidebars, so regressions would
show up as styling or hover-preview mismatches

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex GPT-5-based coding agent with tool use and code execution
in the Codex CLI environment

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-04-24 09:39:21 -05:00
committed by GitHub
parent 7ad225a198
commit 4fdbbeced3
9 changed files with 314 additions and 44 deletions
+89 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, forwardRef, useImperativeHandle, useRef } from "react";
import { act, forwardRef, useImperativeHandle, useRef, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -24,8 +24,22 @@ vi.mock("./MarkdownEditor", () => ({
}),
}));
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => (
<div data-testid="multiline-md-preview">{children}</div>
),
}));
import { InlineEditor, queueContainedBlurCommit } from "./InlineEditor";
/** Enter multiline edit mode by clicking the preview surface. */
function enterMultilineEdit(container: HTMLDivElement) {
const preview = container.querySelector<HTMLDivElement>('[data-testid="multiline-md-preview"]');
if (preview) {
preview.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -139,6 +153,11 @@ describe("InlineEditor", () => {
root.render(<InlineEditor value="hello" multiline nullable onSave={onSave} />);
});
// Non-empty value renders MarkdownBody preview; click to enter edit mode.
act(() => {
enterMultilineEdit(container);
});
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
expect(textarea).not.toBeNull();
@@ -165,6 +184,70 @@ describe("InlineEditor", () => {
outside.remove();
});
it("multiline defaults to MarkdownBody preview when value is non-empty, swaps to editor on click", () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
act(() => {
root.render(<InlineEditor value="Hello world" multiline onSave={onSave} />);
});
expect(container.querySelector('[data-testid="multiline-md-preview"]')).not.toBeNull();
expect(container.querySelector('[data-testid="multiline-md-mock"]')).toBeNull();
act(() => {
enterMultilineEdit(container);
});
expect(container.querySelector('[data-testid="multiline-md-mock"]')).not.toBeNull();
expect(container.querySelector('[data-testid="multiline-md-preview"]')).toBeNull();
act(() => {
root.unmount();
});
});
it("marks multiline preview textboxes as multiline", () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
act(() => {
root.render(<InlineEditor value="Hello world" multiline onSave={onSave} />);
});
const preview = container.querySelector<HTMLElement>('[role="textbox"]');
expect(preview).not.toBeNull();
expect(preview?.getAttribute("aria-multiline")).toBe("true");
expect(preview?.tabIndex).toBe(0);
act(() => {
root.unmount();
});
});
it("enters multiline edit mode from the keyboard preview surface", () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
act(() => {
root.render(<InlineEditor value="Hello world" multiline onSave={onSave} />);
});
const preview = container.querySelector<HTMLElement>('[role="textbox"]');
expect(preview).not.toBeNull();
act(() => {
preview!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
});
expect(container.querySelector('[data-testid="multiline-md-mock"]')).not.toBeNull();
expect(container.querySelector('[data-testid="multiline-md-preview"]')).toBeNull();
act(() => {
root.unmount();
});
});
it("syncs a new multiline value while focused when the user has not edited locally", () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
@@ -200,6 +283,11 @@ describe("InlineEditor", () => {
root.render(<InlineEditor value="Original" multiline onSave={onSave} />);
});
// Non-empty value renders MarkdownBody preview; click to enter edit mode.
act(() => {
enterMultilineEdit(container);
});
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
expect(textarea).not.toBeNull();
+90 -5
View File
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "../lib/utils";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
@@ -52,6 +53,7 @@ export function InlineEditor({
mentions,
}: InlineEditorProps) {
const [editing, setEditing] = useState(false);
const [multilineEditing, setMultilineEditing] = useState(false);
const [multilineFocused, setMultilineFocused] = useState(false);
const [draft, setDraft] = useState(value);
const lastPropValueRef = useRef(value);
@@ -59,6 +61,9 @@ export function InlineEditor({
const markdownRef = useRef<MarkdownEditorRef>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const blurCommitFrameRef = useRef<(() => void) | null>(null);
const pendingFocusFrameRef = useRef<number | null>(null);
const justEnteredEditRef = useRef(false);
const hasBeenFocusedRef = useRef(false);
const {
state: autosaveState,
markDirty,
@@ -86,6 +91,10 @@ export function InlineEditor({
blurCommitFrameRef.current();
blurCommitFrameRef.current = null;
}
if (pendingFocusFrameRef.current !== null) {
cancelAnimationFrame(pendingFocusFrameRef.current);
pendingFocusFrameRef.current = null;
}
};
}, []);
@@ -106,12 +115,39 @@ export function InlineEditor({
}, [editing, autoSize]);
useEffect(() => {
if (!editing || !multiline) return;
const frame = requestAnimationFrame(() => {
if (!multilineEditing || !multiline) return;
if (!justEnteredEditRef.current) return;
justEnteredEditRef.current = false;
if (pendingFocusFrameRef.current !== null) {
cancelAnimationFrame(pendingFocusFrameRef.current);
}
pendingFocusFrameRef.current = requestAnimationFrame(() => {
pendingFocusFrameRef.current = null;
markdownRef.current?.focus();
});
return () => cancelAnimationFrame(frame);
}, [editing, multiline]);
return () => {
if (pendingFocusFrameRef.current !== null) {
cancelAnimationFrame(pendingFocusFrameRef.current);
pendingFocusFrameRef.current = null;
}
};
}, [multilineEditing, multiline]);
// Once the editor has been focused at least once, it's blurred, and any
// autosave has settled, swap back to the MarkdownBody preview so inline
// issue refs render with status + quicklook.
useEffect(() => {
if (multilineFocused) {
hasBeenFocusedRef.current = true;
return;
}
if (!multiline || !multilineEditing) return;
if (!hasBeenFocusedRef.current) return;
if (autosaveState !== "idle") return;
hasBeenFocusedRef.current = false;
setMultilineEditing(false);
}, [multiline, multilineEditing, multilineFocused, autosaveState]);
const commit = useCallback(async (nextValue = draft) => {
const valueToSave = nextValue.trim();
@@ -176,6 +212,8 @@ export function InlineEditor({
setDraft(value);
if (multiline) {
setMultilineFocused(false);
setMultilineEditing(false);
hasBeenFocusedRef.current = false;
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
@@ -212,6 +250,45 @@ export function InlineEditor({
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, nullable, reset, runSave, value]);
if (multiline) {
const previewValue = autosaveState === "saved" || autosaveState === "idle" ? draft : value;
const hasValue = Boolean(previewValue.trim());
const showEditor = multilineEditing || multilineFocused || !hasValue;
if (!showEditor) {
const enterEditMode = () => {
if (multilineEditing) return;
justEnteredEditRef.current = true;
setMultilineEditing(true);
};
return (
<div
className={cn(markdownPad, "rounded transition-colors hover:bg-accent/20")}
onClick={(event) => {
if (event.defaultPrevented) return;
const target = event.target as HTMLElement | null;
if (target && target.closest("a,button,[data-mention-kind],[data-radix-popper-content-wrapper]")) {
return;
}
enterEditMode();
}}
onDragEnter={() => enterEditMode()}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
enterEditMode();
}}
role="textbox"
aria-multiline="true"
aria-label={placeholder}
tabIndex={0}
>
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
{previewValue}
</MarkdownBody>
</div>
);
}
return (
<div
className={cn(
@@ -219,12 +296,20 @@ export function InlineEditor({
"rounded transition-colors",
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
)}
onFocusCapture={() => {
onFocusCapture={(event) => {
// Ignore focus events where the active element isn't actually inside
// the wrapper (React 19 can emit a synthetic focus after a blur).
const active = document.activeElement;
if (!(active instanceof Node) || !event.currentTarget.contains(active)) return;
cancelPendingBlurCommit();
setMultilineFocused(true);
}}
onBlurCapture={(event) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
if (pendingFocusFrameRef.current !== null) {
cancelAnimationFrame(pendingFocusFrameRef.current);
pendingFocusFrameRef.current = null;
}
scheduleBlurCommit(event.currentTarget);
}}
onKeyDown={handleKeyDown}
+5 -20
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link } from "@/lib/router";
import type { Issue, IssueLabel, IssueRelationIssueSummary, Project, WorkspaceRuntimeService } from "@paperclipai/shared";
import type { Issue, IssueLabel, Project, WorkspaceRuntimeService } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { accessApi } from "../api/access";
import { agentsApi } from "../api/agents";
@@ -197,21 +197,6 @@ function PropertyPicker({
);
}
function IssuePillLink({
issue,
}: {
issue: Pick<Issue, "id" | "identifier" | "title"> | IssueRelationIssueSummary;
}) {
return (
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="inline-flex max-w-full items-center rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50"
>
<span className="truncate">{issue.identifier ?? issue.title}</span>
</Link>
);
}
export function IssueProperties({
issue,
childIssues = [],
@@ -1146,7 +1131,7 @@ export function IssueProperties({
<div>
<PropertyRow label="Blocked by">
{(issue.blockedBy ?? []).map((relation) => (
<IssuePillLink key={relation.id} issue={relation} />
<IssueReferencePill key={relation.id} issue={relation} />
))}
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
</PropertyRow>
@@ -1159,7 +1144,7 @@ export function IssueProperties({
) : (
<PropertyRow label="Blocked by">
{(issue.blockedBy ?? []).map((relation) => (
<IssuePillLink key={relation.id} issue={relation} />
<IssueReferencePill key={relation.id} issue={relation} />
))}
<Popover
open={blockedByOpen}
@@ -1182,7 +1167,7 @@ export function IssueProperties({
{blockingIssues.length > 0 ? (
<div className="flex flex-wrap gap-1">
{blockingIssues.map((relation) => (
<IssuePillLink key={relation.id} issue={relation} />
<IssueReferencePill key={relation.id} issue={relation} />
))}
</div>
) : null}
@@ -1192,7 +1177,7 @@ export function IssueProperties({
<div className="flex flex-wrap items-center gap-1.5">
{childIssues.length > 0
? childIssues.map((child) => (
<IssuePillLink key={child.id} issue={child} />
<IssueReferencePill key={child.id} issue={child} />
))
: null}
{onAddSubIssue ? (
+4 -1
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { cn } from "../lib/utils";
@@ -7,11 +8,13 @@ export function IssueReferencePill({
issue,
strikethrough,
className,
children,
}: {
issue: Pick<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
Partial<Pick<IssueRelationIssueSummary, "status">>;
strikethrough?: boolean;
className?: string;
children?: ReactNode;
}) {
const issueLabel = issue.identifier ?? issue.title;
const classNames = cn(
@@ -24,7 +27,7 @@ export function IssueReferencePill({
const content = (
<>
{issue.status ? <StatusIcon status={issue.status} className="h-3 w-3 shrink-0" /> : null}
<span>{issue.identifier ?? issue.title}</span>
{children !== undefined ? children : <span>{issue.identifier ?? issue.title}</span>}
</>
);
+58 -2
View File
@@ -33,7 +33,7 @@ vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
function renderMarkdown(children: string, seededIssues: Array<{ identifier: string; status: string }> = []) {
function renderMarkdown(children: string, seededIssues: Array<{ identifier: string; status: string; title?: string }> = []) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -47,6 +47,7 @@ function renderMarkdown(children: string, seededIssues: Array<{ identifier: stri
id: issue.identifier,
identifier: issue.identifier,
status: issue.status,
title: issue.title,
});
}
@@ -156,9 +157,22 @@ describe("MarkdownBody", () => {
expect(html).toContain('href="/issues/PAP-1271"');
expect(html).toContain("text-green-600");
expect(html).toContain(">PAP-1271<");
expect(html).toContain('data-mention-kind="issue"');
expect(html).toContain("paperclip-markdown-issue-ref");
expect(html).not.toContain("paperclip-mention-chip--issue");
});
it("uses concise issue aria labels until a distinct title is available", () => {
const html = renderMarkdown("Depends on PAP-1271 and PAP-1272.", [
{ identifier: "PAP-1271", status: "done" },
{ identifier: "PAP-1272", status: "blocked", title: "Fix hover state" },
]);
expect(html).toContain('aria-label="Issue PAP-1271"');
expect(html).toContain('aria-label="Issue PAP-1272: Fix hover state"');
expect(html).not.toContain('aria-label="Issue PAP-1271: 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" },
@@ -167,9 +181,33 @@ describe("MarkdownBody", () => {
expect(html).toContain('href="/issues/PAP-1179"');
expect(html).toContain("text-red-600");
expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<");
expect(html).toContain('data-mention-kind="issue"');
expect(html).not.toContain("paperclip-mention-chip--issue");
});
it("linkifies plain internal issue paths in markdown text", () => {
const html = renderMarkdown("See /issues/PAP-1179 and /PAP/issues/pap-1180 for context.", [
{ identifier: "PAP-1179", status: "blocked" },
{ identifier: "PAP-1180", status: "done" },
]);
expect(html).toContain('href="/issues/PAP-1179"');
expect(html).toContain('href="/issues/PAP-1180"');
expect(html).toContain(">/issues/PAP-1179<");
expect(html).toContain(">/PAP/issues/pap-1180<");
expect(html).toContain("text-red-600");
expect(html).toContain("text-green-600");
});
it("does not auto-link non-issue internal route paths", () => {
const html = renderMarkdown("Use /issues/new for the creation form, /issues/PAP-42extra as text, and /api/issues for data.");
expect(html).toContain("Use /issues/new for the creation form, /issues/PAP-42extra as text, and /api/issues for data.");
expect(html).not.toContain('href="/issues/new"');
expect(html).not.toContain('href="/issues/PAP-42"');
expect(html).not.toContain('data-mention-kind="issue"');
});
it("rewrites issue scheme links to internal issue links", () => {
const html = renderMarkdown("See issue://PAP-1310 and issue://:PAP-1311.", [
{ identifier: "PAP-1310", status: "done" },
@@ -192,6 +230,22 @@ describe("MarkdownBody", () => {
expect(html).toContain('href="/issues/PAP-1271"');
expect(html).toContain('<code style="overflow-wrap:anywhere;word-break:break-word">PAP-1271</code>');
expect(html).toContain("text-green-600");
expect(html).toContain("paperclip-markdown-issue-ref");
});
it("keeps trailing punctuation outside auto-linked issue references", () => {
const html = renderMarkdown("See PAP-1271: /issues/PAP-1272] and issue://PAP-1273.", [
{ identifier: "PAP-1271", status: "done" },
{ identifier: "PAP-1272", status: "blocked" },
{ identifier: "PAP-1273", status: "todo" },
]);
expect(html).toContain('<a href="/issues/PAP-1271"');
expect(html).toContain('>PAP-1271</a>:');
expect(html).toContain('<a href="/issues/PAP-1272"');
expect(html).toContain('>/issues/PAP-1272</a>]');
expect(html).toContain('<a href="/issues/PAP-1273"');
expect(html).toContain('>issue://PAP-1273</a>.');
});
it("can opt out of issue reference linkification for offline previews", () => {
@@ -277,7 +331,7 @@ describe("MarkdownBody", () => {
expect(html).toContain('style="max-width:100%;overflow-x:auto"');
});
it("renders internal issue links and bare identifiers as issue chips", () => {
it("renders internal issue links and bare identifiers as inline issue refs", () => {
const html = renderMarkdown(`See PAP-42 and [linked task](${buildIssueReferenceHref("PAP-77")}) for follow-up.`, [
{ identifier: "PAP-42", status: "done" },
{ identifier: "PAP-77", status: "blocked" },
@@ -286,5 +340,7 @@ describe("MarkdownBody", () => {
expect(html).toContain('href="/issues/PAP-42"');
expect(html).toContain('href="/issues/PAP-77"');
expect(html).toContain('data-mention-kind="issue"');
expect(html).toContain("paperclip-markdown-issue-ref");
expect(html).not.toContain("paperclip-mention-chip--issue");
});
});
+15 -8
View File
@@ -4,11 +4,11 @@ import { Github } from "lucide-react";
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "../lib/utils";
import { Link } from "@/lib/router";
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";
@@ -29,11 +29,9 @@ let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = nul
function MarkdownIssueLink({
issuePathId,
href,
children,
}: {
issuePathId: string;
href: string;
children: ReactNode;
}) {
const { data } = useQuery({
@@ -42,14 +40,23 @@ function MarkdownIssueLink({
staleTime: 60_000,
});
const identifier = data?.identifier ?? issuePathId;
const title = data?.title ?? identifier;
const status = data?.status;
const issueLabel = title !== identifier ? `Issue ${identifier}: ${title}` : `Issue ${identifier}`;
return (
<Link
to={href}
className="inline-flex items-center gap-1 align-baseline font-medium"
to={`/issues/${identifier}`}
data-mention-kind="issue"
className="paperclip-markdown-issue-ref"
title={title}
aria-label={issueLabel}
>
{data ? <StatusIcon status={data.status} className="h-3.5 w-3.5" /> : null}
<span>{children}</span>
{status ? (
<StatusIcon status={status} className="mr-1 h-3 w-3 align-[-0.125em]" />
) : null}
{children}
</Link>
);
}
@@ -240,7 +247,7 @@ export function MarkdownBody({
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
if (issueRef) {
return (
<MarkdownIssueLink issuePathId={issueRef.issuePathId} href={issueRef.href}>
<MarkdownIssueLink issuePathId={issueRef.issuePathId}>
{linkChildren}
</MarkdownIssueLink>
);
+40 -4
View File
@@ -448,11 +448,23 @@
font-size: 0.75rem;
line-height: 1.3;
text-decoration: none;
vertical-align: middle;
vertical-align: baseline;
white-space: nowrap;
user-select: none;
}
/* Strip the MDXEditor's default inline-code styling from the text inside chips
(the link label otherwise picks up a monospace font + gray tint). */
.paperclip-mdxeditor-content a.paperclip-mention-chip,
.paperclip-mdxeditor-content a.paperclip-mention-chip code,
.paperclip-mdxeditor-content a.paperclip-project-mention-chip,
.paperclip-mdxeditor-content a.paperclip-project-mention-chip code {
font-family: inherit;
background: none;
color: inherit;
padding: 0;
}
.paperclip-mdxeditor-content a.paperclip-mention-chip::before,
a.paperclip-mention-chip::before {
content: "";
@@ -768,6 +780,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
background: color-mix(in oklab, var(--accent) 42%, transparent);
}
/* Inline issue references in markdown: no pill chrome, just a status icon
beside the link label — keeps the pair from splitting across lines. */
.paperclip-markdown-issue-ref {
display: inline;
white-space: nowrap;
}
.dark .paperclip-markdown a {
color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%);
}
@@ -832,9 +851,11 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
background: transparent;
}
/* Project mention chips rendered inside MarkdownBody */
/* Mention chips rendered inline in prose (MarkdownBody or inline anchors) */
a.paperclip-mention-chip,
a.paperclip-project-mention-chip {
a.paperclip-project-mention-chip,
span.paperclip-mention-chip,
span.paperclip-project-mention-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
@@ -845,10 +866,25 @@ a.paperclip-project-mention-chip {
font-size: 0.75rem;
line-height: 1.3;
text-decoration: none;
vertical-align: middle;
/* Align the pill relative to the surrounding text baseline instead of its
x-height midpoint so it sits on the text line rather than floating above. */
vertical-align: baseline;
white-space: nowrap;
}
/* When the identifier inside a chip is backtick-wrapped in markdown, strip the
inline-code monospace/gray styling so the pill label reads cleanly. */
.paperclip-markdown a.paperclip-mention-chip code,
.paperclip-markdown a.paperclip-project-mention-chip code,
.paperclip-markdown span.paperclip-mention-chip code,
.paperclip-markdown span.paperclip-project-mention-chip code {
font-family: inherit;
background: none;
color: inherit;
padding: 0;
font-size: inherit;
}
/* Keep MDXEditor popups above app dialogs, even when they portal to <body>. */
[class*="_popupContainer_"] {
z-index: 81 !important;
+5
View File
@@ -4,6 +4,7 @@ import { parseIssuePathIdFromPath, parseIssueReferenceFromHref } from "./issue-r
describe("issue-reference", () => {
it("extracts issue ids from company-scoped issue paths", () => {
expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271");
expect(parseIssuePathIdFromPath("/PAP/issues/pap-1272")).toBe("PAP-1272");
expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179");
expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull();
});
@@ -32,6 +33,10 @@ describe("issue-reference", () => {
issuePathId: "PAP-1179",
href: "/issues/PAP-1179",
});
expect(parseIssueReferenceFromHref("/PAP/issues/pap-1180")).toEqual({
issuePathId: "PAP-1180",
href: "/issues/PAP-1180",
});
expect(parseIssueReferenceFromHref("issue://PAP-1310")).toEqual({
issuePathId: "PAP-1310",
href: "/issues/PAP-1310",
+8 -3
View File
@@ -7,7 +7,7 @@ type MarkdownNode = {
const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]+-\d+$/i;
const ISSUE_SCHEME_RE = /^issue:\/\/:?([^?#\s]+)(?:[?#].*)?$/i;
const ISSUE_REFERENCE_TOKEN_RE = /issue:\/\/:?[^\s<>()]+|https?:\/\/[^\s<>()]+|\b[A-Z][A-Z0-9]+-\d+\b/gi;
const ISSUE_REFERENCE_TOKEN_RE = /issue:\/\/:?[^\s<>()]+|https?:\/\/[^\s<>()]+|\/(?:[^\s<>()/]+\/)*issues\/[A-Z][A-Z0-9]+-\d+(?=$|[\s<>)\],.;!?:])|\b[A-Z][A-Z0-9]+-\d+\b/gi;
export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null {
if (!pathOrUrl) return null;
@@ -29,7 +29,7 @@ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined):
if (issueIndex === -1 || issueIndex === segments.length - 1) return null;
const issuePathId = decodeURIComponent(segments[issueIndex + 1] ?? "");
if (!issuePathId || issuePathId.startsWith(":")) return null;
return issuePathId;
return BARE_ISSUE_IDENTIFIER_RE.test(issuePathId) ? issuePathId.toUpperCase() : issuePathId;
}
export function parseIssueReferenceFromHref(href: string | null | undefined) {
@@ -66,12 +66,17 @@ function splitTrailingPunctuation(token: string) {
while (core.length > 0) {
const lastChar = core.at(-1);
if (!lastChar || !/[),.;!?]/.test(lastChar)) break;
if (!lastChar || !/[),.;!?:\]]/.test(lastChar)) break;
if (lastChar === ")") {
const openCount = (core.match(/\(/g) ?? []).length;
const closeCount = (core.match(/\)/g) ?? []).length;
if (closeCount <= openCount) 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);
}