diff --git a/ui/src/components/InlineEditor.test.tsx b/ui/src/components/InlineEditor.test.tsx
index 830d2ced..d5f70dd9 100644
--- a/ui/src/components/InlineEditor.test.tsx
+++ b/ui/src/components/InlineEditor.test.tsx
@@ -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 }) => (
+
{children}
+ ),
+}));
+
import { InlineEditor, queueContainedBlurCommit } from "./InlineEditor";
+/** Enter multiline edit mode by clicking the preview surface. */
+function enterMultilineEdit(container: HTMLDivElement) {
+ const preview = container.querySelector('[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();
});
+ // Non-empty value renders MarkdownBody preview; click to enter edit mode.
+ act(() => {
+ enterMultilineEdit(container);
+ });
+
const textarea = container.querySelector('[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();
+ });
+
+ 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();
+ });
+
+ const preview = container.querySelector('[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();
+ });
+
+ const preview = container.querySelector('[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();
});
+ // Non-empty value renders MarkdownBody preview; click to enter edit mode.
+ act(() => {
+ enterMultilineEdit(container);
+ });
+
const textarea = container.querySelector('[data-testid="multiline-md-mock"]');
expect(textarea).not.toBeNull();
diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx
index 2c878b3f..f866eecd 100644
--- a/ui/src/components/InlineEditor.tsx
+++ b/ui/src/components/InlineEditor.tsx
@@ -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(null);
const autosaveDebounceRef = useRef | null>(null);
const blurCommitFrameRef = useRef<(() => void) | null>(null);
+ const pendingFocusFrameRef = useRef(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 (
+ {
+ 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}
+ >
+
+ {previewValue}
+
+
+ );
+ }
+
return (
{
+ 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}
diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx
index 5c9a38ca..27a49bbf 100644
--- a/ui/src/components/IssueProperties.tsx
+++ b/ui/src/components/IssueProperties.tsx
@@ -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
| IssueRelationIssueSummary;
-}) {
- return (
-
- {issue.identifier ?? issue.title}
-
- );
-}
-
export function IssueProperties({
issue,
childIssues = [],
@@ -1146,7 +1131,7 @@ export function IssueProperties({
{(issue.blockedBy ?? []).map((relation) => (
-
+
))}
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
@@ -1159,7 +1144,7 @@ export function IssueProperties({
) : (
{(issue.blockedBy ?? []).map((relation) => (
-
+
))}
0 ? (
{blockingIssues.map((relation) => (
-
+
))}
) : null}
@@ -1192,7 +1177,7 @@ export function IssueProperties({
{childIssues.length > 0
? childIssues.map((child) => (
-
+
))
: null}
{onAddSubIssue ? (
diff --git a/ui/src/components/IssueReferencePill.tsx b/ui/src/components/IssueReferencePill.tsx
index 64f2c049..78864eda 100644
--- a/ui/src/components/IssueReferencePill.tsx
+++ b/ui/src/components/IssueReferencePill.tsx
@@ -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
&
Partial>;
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 ? : null}
- {issue.identifier ?? issue.title}
+ {children !== undefined ? children : {issue.identifier ?? issue.title}}
>
);
diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx
index 3f17f399..894fa8a7 100644
--- a/ui/src/components/MarkdownBody.test.tsx
+++ b/ui/src/components/MarkdownBody.test.tsx
@@ -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('PAP-1271');
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('PAP-1271:');
+ expect(html).toContain('/issues/PAP-1272]');
+ expect(html).toContain('issue://PAP-1273.');
});
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");
});
});
diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx
index 5ab6ca14..6ab4ff37 100644
--- a/ui/src/components/MarkdownBody.tsx
+++ b/ui/src/components/MarkdownBody.tsx
@@ -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 | 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 (
- {data ? : null}
- {children}
+ {status ? (
+
+ ) : null}
+ {children}
);
}
@@ -240,7 +247,7 @@ export function MarkdownBody({
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
if (issueRef) {
return (
-
+
{linkChildren}
);
diff --git a/ui/src/index.css b/ui/src/index.css
index 95643466..7d42f87e 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -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 . */
[class*="_popupContainer_"] {
z-index: 81 !important;
diff --git a/ui/src/lib/issue-reference.test.ts b/ui/src/lib/issue-reference.test.ts
index 8eeca53a..cd0f038a 100644
--- a/ui/src/lib/issue-reference.test.ts
+++ b/ui/src/lib/issue-reference.test.ts
@@ -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",
diff --git a/ui/src/lib/issue-reference.ts b/ui/src/lib/issue-reference.ts
index 5e03e10e..cbef86ff 100644
--- a/ui/src/lib/issue-reference.ts
+++ b/ui/src/lib/issue-reference.ts
@@ -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);
}