Compare commits

...

10 Commits

Author SHA1 Message Date
Dotta
7ae637977d PAP-2118 address final greptile polish
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 09:26:51 -05:00
Dotta
d39aad353e PAP-2118 make markdown preview keyboard editable
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 09:03:10 -05:00
Dotta
11bc60046d PAP-2118 tighten issue path token boundary
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 08:55:39 -05:00
Dotta
1a103be0a3 PAP-2118 render saved markdown preview draft
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 08:50:01 -05:00
Dotta
f5ea682925 PAP-2118 refine inline editor focus followup
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 08:44:34 -05:00
Dotta
d1a388a3f3 PAP-2118 address greptile markdown followups
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 08:20:04 -05:00
Dotta
879fde73f6 PAP-2118 fix markdown issue path linkification
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 00:36:57 -05:00
Dotta
40cb008225 PAP-2086 render markdown issue refs inline without pill chrome
Reviewer feedback: the pill wrapping used for sidebar lists sat too
low inside paragraphs and bulleted lists, and they just want issue
references to read as text. Inside MarkdownBody we now render a
plain inline link with a StatusIcon beside the label and a title
tooltip for the quicklook, instead of wrapping the reference in an
IssueReferencePill. Sidebar surfaces (Blocked by, Blocking, Sub-
issues, Related Tasks) keep the pill styling.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 00:24:10 -05:00
Dotta
8f2ea97480 PAP-2086 route issue description pills through MarkdownBody
Description bodies rendered via InlineEditor previously stayed in the MDX
editor even at rest, which produced plain outlined PAP-XXXX chips with no
status dot and no quicklook — the QA gap flagged on the ticket. Idle
multiline now renders MarkdownBody so issue refs pick up IssueReferencePill
+ Link (status circle + hover preview card); click or drag-enter swaps the
MDX editor back in, and we only return to the preview once the editor has
been focused at least once, blurred, and autosave has settled.

Also fix the chip typography user feedback: switch vertical-align from
middle to baseline so pills sit on the text line, and neutralize the
inline-code monospace/gray styling that leaked into backtick-wrapped chip
labels in both the MDX editor and the MarkdownBody preview.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 00:24:10 -05:00
Dotta
1238130944 PAP-2086 unify task pill rendering across markdown and sidebar
Sub-issues, blocking, and blocked-by sidebar lists now reuse
IssueReferencePill (with status icon and quicklook hovercard),
matching the existing Related Tasks behavior. Markdown task
references render through the same component for consistent
chip styling, status icon, and quicklook on hover, while
preserving the original markdown link text as the visible label.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 00:24:10 -05:00
9 changed files with 314 additions and 44 deletions

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();

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}

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 ? (

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>}
</>
);

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");
});
});

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>
);

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;

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",

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);
}