mirror of
https://github.com/paperclipai/paperclip
synced 2026-05-09 00:22:26 +02:00
Compare commits
10 Commits
v2026.428.
...
pap-2115-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ae637977d | ||
|
|
d39aad353e | ||
|
|
11bc60046d | ||
|
|
1a103be0a3 | ||
|
|
f5ea682925 | ||
|
|
d1a388a3f3 | ||
|
|
879fde73f6 | ||
|
|
40cb008225 | ||
|
|
8f2ea97480 | ||
|
|
1238130944 |
@@ -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();
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user