mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[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:
parent
7ad225a198
commit
4fdbbeced3
9 changed files with 314 additions and 44 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
// @vitest-environment jsdom
|
// @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 { createRoot } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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";
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
|
@ -139,6 +153,11 @@ describe("InlineEditor", () => {
|
||||||
root.render(<InlineEditor value="hello" multiline nullable onSave={onSave} />);
|
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"]');
|
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
|
||||||
expect(textarea).not.toBeNull();
|
expect(textarea).not.toBeNull();
|
||||||
|
|
||||||
|
|
@ -165,6 +184,70 @@ describe("InlineEditor", () => {
|
||||||
outside.remove();
|
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", () => {
|
it("syncs a new multiline value while focused when the user has not edited locally", () => {
|
||||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
@ -200,6 +283,11 @@ describe("InlineEditor", () => {
|
||||||
root.render(<InlineEditor value="Original" multiline onSave={onSave} />);
|
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"]');
|
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
|
||||||
expect(textarea).not.toBeNull();
|
expect(textarea).not.toBeNull();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||||
|
|
||||||
|
|
@ -52,6 +53,7 @@ export function InlineEditor({
|
||||||
mentions,
|
mentions,
|
||||||
}: InlineEditorProps) {
|
}: InlineEditorProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [multilineEditing, setMultilineEditing] = useState(false);
|
||||||
const [multilineFocused, setMultilineFocused] = useState(false);
|
const [multilineFocused, setMultilineFocused] = useState(false);
|
||||||
const [draft, setDraft] = useState(value);
|
const [draft, setDraft] = useState(value);
|
||||||
const lastPropValueRef = useRef(value);
|
const lastPropValueRef = useRef(value);
|
||||||
|
|
@ -59,6 +61,9 @@ export function InlineEditor({
|
||||||
const markdownRef = useRef<MarkdownEditorRef>(null);
|
const markdownRef = useRef<MarkdownEditorRef>(null);
|
||||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const blurCommitFrameRef = useRef<(() => void) | null>(null);
|
const blurCommitFrameRef = useRef<(() => void) | null>(null);
|
||||||
|
const pendingFocusFrameRef = useRef<number | null>(null);
|
||||||
|
const justEnteredEditRef = useRef(false);
|
||||||
|
const hasBeenFocusedRef = useRef(false);
|
||||||
const {
|
const {
|
||||||
state: autosaveState,
|
state: autosaveState,
|
||||||
markDirty,
|
markDirty,
|
||||||
|
|
@ -86,6 +91,10 @@ export function InlineEditor({
|
||||||
blurCommitFrameRef.current();
|
blurCommitFrameRef.current();
|
||||||
blurCommitFrameRef.current = null;
|
blurCommitFrameRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (pendingFocusFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(pendingFocusFrameRef.current);
|
||||||
|
pendingFocusFrameRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -106,12 +115,39 @@ export function InlineEditor({
|
||||||
}, [editing, autoSize]);
|
}, [editing, autoSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing || !multiline) return;
|
if (!multilineEditing || !multiline) return;
|
||||||
const frame = requestAnimationFrame(() => {
|
if (!justEnteredEditRef.current) return;
|
||||||
|
justEnteredEditRef.current = false;
|
||||||
|
if (pendingFocusFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(pendingFocusFrameRef.current);
|
||||||
|
}
|
||||||
|
pendingFocusFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
pendingFocusFrameRef.current = null;
|
||||||
markdownRef.current?.focus();
|
markdownRef.current?.focus();
|
||||||
});
|
});
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => {
|
||||||
}, [editing, multiline]);
|
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 commit = useCallback(async (nextValue = draft) => {
|
||||||
const valueToSave = nextValue.trim();
|
const valueToSave = nextValue.trim();
|
||||||
|
|
@ -176,6 +212,8 @@ export function InlineEditor({
|
||||||
setDraft(value);
|
setDraft(value);
|
||||||
if (multiline) {
|
if (multiline) {
|
||||||
setMultilineFocused(false);
|
setMultilineFocused(false);
|
||||||
|
setMultilineEditing(false);
|
||||||
|
hasBeenFocusedRef.current = false;
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
document.activeElement.blur();
|
document.activeElement.blur();
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +250,45 @@ export function InlineEditor({
|
||||||
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, nullable, reset, runSave, value]);
|
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, nullable, reset, runSave, value]);
|
||||||
|
|
||||||
if (multiline) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -219,12 +296,20 @@ export function InlineEditor({
|
||||||
"rounded transition-colors",
|
"rounded transition-colors",
|
||||||
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
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();
|
cancelPendingBlurCommit();
|
||||||
setMultilineFocused(true);
|
setMultilineFocused(true);
|
||||||
}}
|
}}
|
||||||
onBlurCapture={(event) => {
|
onBlurCapture={(event) => {
|
||||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||||
|
if (pendingFocusFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(pendingFocusFrameRef.current);
|
||||||
|
pendingFocusFrameRef.current = null;
|
||||||
|
}
|
||||||
scheduleBlurCommit(event.currentTarget);
|
scheduleBlurCommit(event.currentTarget);
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { Link } from "@/lib/router";
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { accessApi } from "../api/access";
|
import { accessApi } from "../api/access";
|
||||||
import { agentsApi } from "../api/agents";
|
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({
|
export function IssueProperties({
|
||||||
issue,
|
issue,
|
||||||
childIssues = [],
|
childIssues = [],
|
||||||
|
|
@ -1146,7 +1131,7 @@ export function IssueProperties({
|
||||||
<div>
|
<div>
|
||||||
<PropertyRow label="Blocked by">
|
<PropertyRow label="Blocked by">
|
||||||
{(issue.blockedBy ?? []).map((relation) => (
|
{(issue.blockedBy ?? []).map((relation) => (
|
||||||
<IssuePillLink key={relation.id} issue={relation} />
|
<IssueReferencePill key={relation.id} issue={relation} />
|
||||||
))}
|
))}
|
||||||
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
|
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
|
|
@ -1159,7 +1144,7 @@ export function IssueProperties({
|
||||||
) : (
|
) : (
|
||||||
<PropertyRow label="Blocked by">
|
<PropertyRow label="Blocked by">
|
||||||
{(issue.blockedBy ?? []).map((relation) => (
|
{(issue.blockedBy ?? []).map((relation) => (
|
||||||
<IssuePillLink key={relation.id} issue={relation} />
|
<IssueReferencePill key={relation.id} issue={relation} />
|
||||||
))}
|
))}
|
||||||
<Popover
|
<Popover
|
||||||
open={blockedByOpen}
|
open={blockedByOpen}
|
||||||
|
|
@ -1182,7 +1167,7 @@ export function IssueProperties({
|
||||||
{blockingIssues.length > 0 ? (
|
{blockingIssues.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{blockingIssues.map((relation) => (
|
{blockingIssues.map((relation) => (
|
||||||
<IssuePillLink key={relation.id} issue={relation} />
|
<IssueReferencePill key={relation.id} issue={relation} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -1192,7 +1177,7 @@ export function IssueProperties({
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{childIssues.length > 0
|
{childIssues.length > 0
|
||||||
? childIssues.map((child) => (
|
? childIssues.map((child) => (
|
||||||
<IssuePillLink key={child.id} issue={child} />
|
<IssueReferencePill key={child.id} issue={child} />
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
{onAddSubIssue ? (
|
{onAddSubIssue ? (
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
|
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
@ -7,11 +8,13 @@ export function IssueReferencePill({
|
||||||
issue,
|
issue,
|
||||||
strikethrough,
|
strikethrough,
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
}: {
|
}: {
|
||||||
issue: Pick<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
|
issue: Pick<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
|
||||||
Partial<Pick<IssueRelationIssueSummary, "status">>;
|
Partial<Pick<IssueRelationIssueSummary, "status">>;
|
||||||
strikethrough?: boolean;
|
strikethrough?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const issueLabel = issue.identifier ?? issue.title;
|
const issueLabel = issue.identifier ?? issue.title;
|
||||||
const classNames = cn(
|
const classNames = cn(
|
||||||
|
|
@ -24,7 +27,7 @@ export function IssueReferencePill({
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{issue.status ? <StatusIcon status={issue.status} className="h-3 w-3 shrink-0" /> : null}
|
{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>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ vi.mock("../api/issues", () => ({
|
||||||
issuesApi: mockIssuesApi,
|
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({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
|
|
@ -47,6 +47,7 @@ function renderMarkdown(children: string, seededIssues: Array<{ identifier: stri
|
||||||
id: issue.identifier,
|
id: issue.identifier,
|
||||||
identifier: issue.identifier,
|
identifier: issue.identifier,
|
||||||
status: issue.status,
|
status: issue.status,
|
||||||
|
title: issue.title,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,9 +157,22 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain('href="/issues/PAP-1271"');
|
expect(html).toContain('href="/issues/PAP-1271"');
|
||||||
expect(html).toContain("text-green-600");
|
expect(html).toContain("text-green-600");
|
||||||
expect(html).toContain(">PAP-1271<");
|
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");
|
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", () => {
|
it("rewrites full issue URLs to internal issue links", () => {
|
||||||
const html = renderMarkdown("See http://localhost:3100/PAP/issues/PAP-1179.", [
|
const html = renderMarkdown("See http://localhost:3100/PAP/issues/PAP-1179.", [
|
||||||
{ identifier: "PAP-1179", status: "blocked" },
|
{ identifier: "PAP-1179", status: "blocked" },
|
||||||
|
|
@ -167,9 +181,33 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain('href="/issues/PAP-1179"');
|
expect(html).toContain('href="/issues/PAP-1179"');
|
||||||
expect(html).toContain("text-red-600");
|
expect(html).toContain("text-red-600");
|
||||||
expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<");
|
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");
|
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", () => {
|
it("rewrites issue scheme links to internal issue links", () => {
|
||||||
const html = renderMarkdown("See issue://PAP-1310 and issue://:PAP-1311.", [
|
const html = renderMarkdown("See issue://PAP-1310 and issue://:PAP-1311.", [
|
||||||
{ identifier: "PAP-1310", status: "done" },
|
{ identifier: "PAP-1310", status: "done" },
|
||||||
|
|
@ -192,6 +230,22 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain('href="/issues/PAP-1271"');
|
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('<code style="overflow-wrap:anywhere;word-break:break-word">PAP-1271</code>');
|
||||||
expect(html).toContain("text-green-600");
|
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", () => {
|
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"');
|
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.`, [
|
const html = renderMarkdown(`See PAP-42 and [linked task](${buildIssueReferenceHref("PAP-77")}) for follow-up.`, [
|
||||||
{ identifier: "PAP-42", status: "done" },
|
{ identifier: "PAP-42", status: "done" },
|
||||||
{ identifier: "PAP-77", status: "blocked" },
|
{ identifier: "PAP-77", status: "blocked" },
|
||||||
|
|
@ -286,5 +340,7 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain('href="/issues/PAP-42"');
|
expect(html).toContain('href="/issues/PAP-42"');
|
||||||
expect(html).toContain('href="/issues/PAP-77"');
|
expect(html).toContain('href="/issues/PAP-77"');
|
||||||
expect(html).toContain('data-mention-kind="issue"');
|
expect(html).toContain('data-mention-kind="issue"');
|
||||||
|
expect(html).toContain("paperclip-markdown-issue-ref");
|
||||||
|
expect(html).not.toContain("paperclip-mention-chip--issue");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import { Github } from "lucide-react";
|
||||||
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
|
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { Link } from "@/lib/router";
|
|
||||||
import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference";
|
import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference";
|
||||||
import { remarkSoftBreaks } from "../lib/remark-soft-breaks";
|
import { remarkSoftBreaks } from "../lib/remark-soft-breaks";
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
|
|
@ -29,11 +29,9 @@ let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = nul
|
||||||
|
|
||||||
function MarkdownIssueLink({
|
function MarkdownIssueLink({
|
||||||
issuePathId,
|
issuePathId,
|
||||||
href,
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
issuePathId: string;
|
issuePathId: string;
|
||||||
href: string;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
|
|
@ -42,14 +40,23 @@ function MarkdownIssueLink({
|
||||||
staleTime: 60_000,
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={`/issues/${identifier}`}
|
||||||
className="inline-flex items-center gap-1 align-baseline font-medium"
|
|
||||||
data-mention-kind="issue"
|
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}
|
{status ? (
|
||||||
<span>{children}</span>
|
<StatusIcon status={status} className="mr-1 h-3 w-3 align-[-0.125em]" />
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -240,7 +247,7 @@ export function MarkdownBody({
|
||||||
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
|
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
|
||||||
if (issueRef) {
|
if (issueRef) {
|
||||||
return (
|
return (
|
||||||
<MarkdownIssueLink issuePathId={issueRef.issuePathId} href={issueRef.href}>
|
<MarkdownIssueLink issuePathId={issueRef.issuePathId}>
|
||||||
{linkChildren}
|
{linkChildren}
|
||||||
</MarkdownIssueLink>
|
</MarkdownIssueLink>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -448,11 +448,23 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
vertical-align: middle;
|
vertical-align: baseline;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
user-select: none;
|
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,
|
.paperclip-mdxeditor-content a.paperclip-mention-chip::before,
|
||||||
a.paperclip-mention-chip::before {
|
a.paperclip-mention-chip::before {
|
||||||
content: "";
|
content: "";
|
||||||
|
|
@ -768,6 +780,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||||
background: color-mix(in oklab, var(--accent) 42%, transparent);
|
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 {
|
.dark .paperclip-markdown a {
|
||||||
color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%);
|
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;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Project mention chips rendered inside MarkdownBody */
|
/* Mention chips rendered inline in prose (MarkdownBody or inline anchors) */
|
||||||
a.paperclip-mention-chip,
|
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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
|
@ -845,10 +866,25 @@ a.paperclip-project-mention-chip {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
text-decoration: none;
|
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;
|
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>. */
|
/* Keep MDXEditor popups above app dialogs, even when they portal to <body>. */
|
||||||
[class*="_popupContainer_"] {
|
[class*="_popupContainer_"] {
|
||||||
z-index: 81 !important;
|
z-index: 81 !important;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { parseIssuePathIdFromPath, parseIssueReferenceFromHref } from "./issue-r
|
||||||
describe("issue-reference", () => {
|
describe("issue-reference", () => {
|
||||||
it("extracts issue ids from company-scoped issue paths", () => {
|
it("extracts issue ids from company-scoped issue paths", () => {
|
||||||
expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271");
|
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/PAP-1179")).toBe("PAP-1179");
|
||||||
expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull();
|
expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
@ -32,6 +33,10 @@ describe("issue-reference", () => {
|
||||||
issuePathId: "PAP-1179",
|
issuePathId: "PAP-1179",
|
||||||
href: "/issues/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({
|
expect(parseIssueReferenceFromHref("issue://PAP-1310")).toEqual({
|
||||||
issuePathId: "PAP-1310",
|
issuePathId: "PAP-1310",
|
||||||
href: "/issues/PAP-1310",
|
href: "/issues/PAP-1310",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ type MarkdownNode = {
|
||||||
|
|
||||||
const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]+-\d+$/i;
|
const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]+-\d+$/i;
|
||||||
const ISSUE_SCHEME_RE = /^issue:\/\/:?([^?#\s]+)(?:[?#].*)?$/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 {
|
export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null {
|
||||||
if (!pathOrUrl) return 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;
|
if (issueIndex === -1 || issueIndex === segments.length - 1) return null;
|
||||||
const issuePathId = decodeURIComponent(segments[issueIndex + 1] ?? "");
|
const issuePathId = decodeURIComponent(segments[issueIndex + 1] ?? "");
|
||||||
if (!issuePathId || issuePathId.startsWith(":")) return null;
|
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) {
|
export function parseIssueReferenceFromHref(href: string | null | undefined) {
|
||||||
|
|
@ -66,12 +66,17 @@ function splitTrailingPunctuation(token: string) {
|
||||||
|
|
||||||
while (core.length > 0) {
|
while (core.length > 0) {
|
||||||
const lastChar = core.at(-1);
|
const lastChar = core.at(-1);
|
||||||
if (!lastChar || !/[),.;!?]/.test(lastChar)) break;
|
if (!lastChar || !/[),.;!?:\]]/.test(lastChar)) break;
|
||||||
if (lastChar === ")") {
|
if (lastChar === ")") {
|
||||||
const openCount = (core.match(/\(/g) ?? []).length;
|
const openCount = (core.match(/\(/g) ?? []).length;
|
||||||
const closeCount = (core.match(/\)/g) ?? []).length;
|
const closeCount = (core.match(/\)/g) ?? []).length;
|
||||||
if (closeCount <= openCount) break;
|
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}`;
|
trailing = `${lastChar}${trailing}`;
|
||||||
core = core.slice(0, -1);
|
core = core.slice(0, -1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue