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