mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
[codex] Improve issue detail and issue-list UX (#3678)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - A core part of that is the operator experience around reading issue state, agent chat, and sub-task structure > - The current branch had a long run of issue-detail and issue-list UX fixes that all improve how humans follow and steer active work > - Those changes mostly live in the UI/chat surface and should be reviewed together instead of mixed with workspace/runtime work > - This pull request packages the issue-detail, chat, markdown, and sub-issue list improvements into one standalone change > - The benefit is a cleaner, less jumpy, more reliable issue workflow on desktop and mobile without coupling it to unrelated server/runtime refactors ## What Changed - Stabilized issue chat runtime wiring, optimistic comment handling, queued-comment cancellation, and composer anchoring during live updates - Fixed several issue-detail rendering and navigation regressions including placeholder bleed, local polling scope, mobile inbox-to-issue transitions, and visible refresh resets - Improved markdown and rich-content handling with advisory image normalization, editor fallback behavior, touch mention recovery, and `issue:` quicklook links - Refined sub-issue behavior with parent-derived defaults, current-user inheritance fixes, empty-state cleanup, and a reusable issue-list presentation for sub-issues - Added targeted UI tests for the new issue-detail, chat scroll/message, placeholder-data, markdown, and issue-list behaviors ## Verification - `pnpm vitest run ui/src/components/IssueChatThread.test.tsx ui/src/components/MarkdownEditor.test.tsx ui/src/components/IssuesList.test.tsx ui/src/context/LiveUpdatesProvider.test.tsx ui/src/lib/issue-chat-messages.test.ts ui/src/lib/issue-chat-scroll.test.ts ui/src/lib/issue-detail-subissues.test.ts ui/src/lib/query-placeholder-data.test.tsx ui/src/hooks/usePaperclipIssueRuntime.test.tsx` ## Risks - Medium: this branch touches the highest-traffic issue-detail UI paths, so regressions would show up as chat/thread or sub-issue UX glitches - The changes are UI-heavy and would benefit from reviewer screenshots or a quick manual browser pass before merge ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## 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 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
5d1ed71779
commit
6e6f538630
41 changed files with 4141 additions and 590 deletions
|
|
@ -31,7 +31,7 @@ function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) {
|
|||
}
|
||||
|
||||
export function BreadcrumbBar() {
|
||||
const { breadcrumbs } = useBreadcrumbs();
|
||||
const { breadcrumbs, mobileToolbar } = useBreadcrumbs();
|
||||
const { toggleSidebar, isMobile } = useSidebar();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
|
||||
|
|
@ -45,6 +45,14 @@ export function BreadcrumbBar() {
|
|||
|
||||
const globalToolbarSlots = <GlobalToolbarPlugins context={globalToolbarSlotContext} />;
|
||||
|
||||
if (isMobile && mobileToolbar) {
|
||||
return (
|
||||
<div className="border-b border-border px-2 h-12 shrink-0 flex items-center">
|
||||
{mobileToolbar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (breadcrumbs.length === 0) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
|
||||
|
|
|
|||
|
|
@ -178,6 +178,52 @@ describe("CommentThread", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("hides the reopen control and infers reopen for closed agent-assigned issues", async () => {
|
||||
const root = createRoot(container);
|
||||
const onAdd = vi.fn(async () => {});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<CommentThread
|
||||
comments={[]}
|
||||
issueStatus="done"
|
||||
currentAssigneeValue="agent:agent-1"
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Re-open");
|
||||
|
||||
const editor = container.querySelector('textarea[aria-label="Comment editor"]') as HTMLTextAreaElement | null;
|
||||
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent === "Comment",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(editor).not.toBeNull();
|
||||
expect(submitButton).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(editor, "Please pick this back up");
|
||||
editor?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.click();
|
||||
});
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("Please pick this back up", true, undefined);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders linked approvals inline in the timeline", () => {
|
||||
const root = createRoot(container);
|
||||
const agent: Agent = {
|
||||
|
|
|
|||
|
|
@ -134,6 +134,11 @@ function parseReassignment(target: string): CommentReassignment | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) {
|
||||
const isClosed = issueStatus === "done" || issueStatus === "cancelled";
|
||||
return isClosed && assigneeValue.startsWith("agent:");
|
||||
}
|
||||
|
||||
function humanizeValue(value: string | null): string {
|
||||
if (!value) return "None";
|
||||
return value.replace(/_/g, " ");
|
||||
|
|
@ -647,6 +652,7 @@ export function CommentThread({
|
|||
pendingApprovalAction = null,
|
||||
onVote,
|
||||
onAdd,
|
||||
issueStatus,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
imageUploadHandler,
|
||||
|
|
@ -663,7 +669,6 @@ export function CommentThread({
|
|||
composerDisabledReason = null,
|
||||
}: CommentThreadProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
|
|
@ -784,14 +789,17 @@ export function CommentThread({
|
|||
if (!trimmed) return;
|
||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
|
||||
const reopen = shouldImplicitlyReopenComment(
|
||||
issueStatus,
|
||||
hasReassignment ? reassignTarget : currentAssigneeValue,
|
||||
) ? true : undefined;
|
||||
const submittedBody = trimmed;
|
||||
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
try {
|
||||
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
|
||||
await onAdd(submittedBody, reopen, reassignment ?? undefined);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(true);
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} catch {
|
||||
setBody((current) =>
|
||||
|
|
@ -935,15 +943,6 @@ export function CommentThread({
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopen}
|
||||
onChange={(e) => setReopen(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Re-open
|
||||
</label>
|
||||
{enableReassign && reassignOptions.length > 0 && (
|
||||
<InlineEntitySelector
|
||||
value={reassignTarget}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,30 @@ import type { ReactNode } from "react";
|
|||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
||||
import { IssueChatThread, canStopIssueChatRun, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
||||
|
||||
const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||
markdownEditorFocusMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const { appendMock } = vi.hoisted(() => ({
|
||||
appendMock: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const { threadMessagesMock } = vi.hoisted(() => ({
|
||||
threadMessagesMock: vi.fn(() => <div data-testid="thread-messages" />),
|
||||
}));
|
||||
|
||||
const {
|
||||
captureComposerViewportSnapshotMock,
|
||||
restoreComposerViewportSnapshotMock,
|
||||
shouldPreserveComposerViewportMock,
|
||||
} = vi.hoisted(() => ({
|
||||
captureComposerViewportSnapshotMock: vi.fn(),
|
||||
restoreComposerViewportSnapshotMock: vi.fn(),
|
||||
shouldPreserveComposerViewportMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@assistant-ui/react", () => ({
|
||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ThreadPrimitive: {
|
||||
|
|
@ -32,7 +46,7 @@ vi.mock("@assistant-ui/react", () => ({
|
|||
Content: () => null,
|
||||
Parts: () => null,
|
||||
},
|
||||
useAui: () => ({ thread: () => ({ append: vi.fn() }) }),
|
||||
useAui: () => ({ thread: () => ({ append: appendMock }) }),
|
||||
useAuiState: () => false,
|
||||
useMessage: () => ({
|
||||
id: "message",
|
||||
|
|
@ -51,6 +65,16 @@ vi.mock("./transcript/useLiveRunTranscripts", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/issue-chat-scroll", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/issue-chat-scroll")>();
|
||||
return {
|
||||
...actual,
|
||||
captureComposerViewportSnapshot: captureComposerViewportSnapshotMock.mockImplementation(actual.captureComposerViewportSnapshot),
|
||||
restoreComposerViewportSnapshot: restoreComposerViewportSnapshotMock.mockImplementation(actual.restoreComposerViewportSnapshot),
|
||||
shouldPreserveComposerViewport: shouldPreserveComposerViewportMock.mockImplementation(actual.shouldPreserveComposerViewport),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
|
@ -126,8 +150,12 @@ describe("IssueChatThread", () => {
|
|||
afterEach(() => {
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
appendMock.mockReset();
|
||||
markdownEditorFocusMock.mockReset();
|
||||
threadMessagesMock.mockReset();
|
||||
captureComposerViewportSnapshotMock.mockClear();
|
||||
restoreComposerViewportSnapshotMock.mockClear();
|
||||
shouldPreserveComposerViewportMock.mockClear();
|
||||
});
|
||||
|
||||
it("drops the count heading and does not use an internal scrollbox", () => {
|
||||
|
|
@ -338,9 +366,67 @@ describe("IssueChatThread", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("hides the reopen control and infers reopen for closed agent-assigned issue replies", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
issueStatus="done"
|
||||
currentAssigneeValue="agent:agent-1"
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Re-open");
|
||||
|
||||
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent === "Send",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(editor).not.toBeNull();
|
||||
expect(submitButton).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(editor, "Please pick this back up");
|
||||
editor?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.click();
|
||||
});
|
||||
|
||||
expect(appendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: [{ type: "text", text: "Please pick this back up" }],
|
||||
runConfig: {
|
||||
custom: {
|
||||
reopen: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes a composer focus handle that forwards to the editor", () => {
|
||||
const root = createRoot(container);
|
||||
const composerRef = createRef<{ focus: () => void }>();
|
||||
const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>();
|
||||
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||
const requestAnimationFrameMock = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
|
|
@ -387,6 +473,159 @@ describe("IssueChatThread", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("restores a cancelled queued draft into the composer handle", () => {
|
||||
const root = createRoot(container);
|
||||
const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>();
|
||||
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||
const requestAnimationFrameMock = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
composerRef={composerRef}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||
expect(editor).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
composerRef.current?.restoreDraft("Queued message");
|
||||
});
|
||||
|
||||
expect(editor?.value).toBe("Queued message");
|
||||
expect(markdownEditorFocusMock).toHaveBeenCalledTimes(1);
|
||||
expect(scrollByMock).toHaveBeenCalledWith({ top: 96, behavior: "smooth" });
|
||||
|
||||
scrollByMock.mockRestore();
|
||||
requestAnimationFrameMock.mockRestore();
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not restore the composer viewport for passive live updates by default", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[{
|
||||
id: "run-1",
|
||||
issueId: "issue-1",
|
||||
status: "running",
|
||||
invocationSource: "comment",
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-04-06T12:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-06T12:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent 1",
|
||||
adapterType: "codex_local",
|
||||
}]}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(restoreComposerViewportSnapshotMock).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("requests composer viewport restoration when live messages arrive during active composer interaction", () => {
|
||||
const root = createRoot(container);
|
||||
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||
shouldPreserveComposerViewportMock.mockReturnValue(true);
|
||||
captureComposerViewportSnapshotMock.mockReturnValue({ composerViewportTop: 420 });
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[{
|
||||
id: "run-1",
|
||||
issueId: "issue-1",
|
||||
status: "running",
|
||||
invocationSource: "comment",
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-04-06T12:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-06T12:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent 1",
|
||||
adapterType: "codex_local",
|
||||
}]}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(restoreComposerViewportSnapshotMock).toHaveBeenCalled();
|
||||
|
||||
scrollByMock.mockRestore();
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("folds chain-of-thought when the same message transitions from running to complete", () => {
|
||||
expect(resolveAssistantMessageFoldedState({
|
||||
messageId: "message-1",
|
||||
|
|
@ -406,4 +645,20 @@ describe("IssueChatThread", () => {
|
|||
previousIsFoldable: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the stop-run action for active run-linked messages even without embedded run status", () => {
|
||||
expect(canStopIssueChatRun({
|
||||
runId: "run-1",
|
||||
runStatus: null,
|
||||
activeRunIds: new Set(["run-1"]),
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the stop-run action for completed historical runs", () => {
|
||||
expect(canStopIssueChatRun({
|
||||
runId: "run-1",
|
||||
runStatus: "cancelled",
|
||||
activeRunIds: new Set<string>(),
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
|
|
@ -36,8 +37,10 @@ import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from
|
|||
import {
|
||||
buildIssueChatMessages,
|
||||
formatDurationWords,
|
||||
stabilizeThreadMessages,
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
type StableThreadMessageCacheEntry,
|
||||
type IssueChatTranscriptEntry,
|
||||
type SegmentTiming,
|
||||
} from "../lib/issue-chat-messages";
|
||||
|
|
@ -65,6 +68,11 @@ import { Identity } from "./Identity";
|
|||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
|
||||
import {
|
||||
captureComposerViewportSnapshot,
|
||||
restoreComposerViewportSnapshot,
|
||||
shouldPreserveComposerViewport,
|
||||
} from "../lib/issue-chat-scroll";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import {
|
||||
|
|
@ -80,7 +88,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
|
||||
interface IssueChatMessageContext {
|
||||
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
||||
|
|
@ -88,12 +96,16 @@ interface IssueChatMessageContext {
|
|||
feedbackTermsUrl: string | null;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
activeRunIds: ReadonlySet<string>;
|
||||
onVote?: (
|
||||
commentId: string,
|
||||
vote: FeedbackVoteValue,
|
||||
options?: { allowSharing?: boolean; reason?: string },
|
||||
) => Promise<void>;
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
stoppingRunId?: string | null;
|
||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
onCancelQueued?: (commentId: string) => void;
|
||||
interruptingQueuedRunId?: string | null;
|
||||
onImageClick?: (src: string) => void;
|
||||
}
|
||||
|
|
@ -102,6 +114,7 @@ const IssueChatCtx = createContext<IssueChatMessageContext>({
|
|||
feedbackVoteByTargetId: new Map(),
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
feedbackTermsUrl: null,
|
||||
activeRunIds: new Set<string>(),
|
||||
});
|
||||
|
||||
export function resolveAssistantMessageFoldedState(args: {
|
||||
|
|
@ -125,6 +138,17 @@ export function resolveAssistantMessageFoldedState(args: {
|
|||
return currentFolded;
|
||||
}
|
||||
|
||||
export function canStopIssueChatRun(args: {
|
||||
runId: string | null;
|
||||
runStatus: string | null;
|
||||
activeRunIds: ReadonlySet<string>;
|
||||
}) {
|
||||
const { runId, runStatus, activeRunIds } = args;
|
||||
if (!runId) return false;
|
||||
if (activeRunIds.has(runId)) return true;
|
||||
return runStatus === "queued" || runStatus === "running";
|
||||
}
|
||||
|
||||
function findCoTSegmentIndex(
|
||||
messageParts: ReadonlyArray<{ type: string }>,
|
||||
cotParts: ReadonlyArray<{ type: string }>,
|
||||
|
|
@ -162,6 +186,7 @@ interface CommentReassignment {
|
|||
|
||||
export interface IssueChatComposerHandle {
|
||||
focus: () => void;
|
||||
restoreDraft: (submittedBody: string) => void;
|
||||
}
|
||||
|
||||
interface IssueChatComposerProps {
|
||||
|
|
@ -199,6 +224,7 @@ interface IssueChatThreadProps {
|
|||
) => Promise<void>;
|
||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||
onCancelRun?: () => Promise<void>;
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
onAttachImage?: (file: File) => Promise<void>;
|
||||
draftKey?: string;
|
||||
|
|
@ -217,7 +243,9 @@ interface IssueChatThreadProps {
|
|||
hasOutputForRun?: (runId: string) => boolean;
|
||||
includeSucceededRunsWithoutOutput?: boolean;
|
||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
onCancelQueued?: (commentId: string) => void;
|
||||
interruptingQueuedRunId?: string | null;
|
||||
stoppingRunId?: string | null;
|
||||
onImageClick?: (src: string) => void;
|
||||
composerRef?: Ref<IssueChatComposerHandle>;
|
||||
}
|
||||
|
|
@ -412,6 +440,11 @@ function parseReassignment(target: string): PaperclipIssueRuntimeReassignment |
|
|||
return null;
|
||||
}
|
||||
|
||||
function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) {
|
||||
const isClosed = issueStatus === "done" || issueStatus === "cancelled";
|
||||
return isClosed && assigneeValue.startsWith("agent:");
|
||||
}
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function commentDateLabel(date: Date | string | undefined): string {
|
||||
|
|
@ -873,10 +906,11 @@ function IssueChatToolPart({
|
|||
}
|
||||
|
||||
function IssueChatUserMessage() {
|
||||
const { onInterruptQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
|
||||
const { onInterruptQueued, onCancelQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id;
|
||||
const queued = custom.queueState === "queued" || custom.clientStatus === "queued";
|
||||
const pending = custom.clientStatus === "pending";
|
||||
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
|
||||
|
|
@ -911,6 +945,16 @@ function IssueChatUserMessage() {
|
|||
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
||||
</Button>
|
||||
) : null}
|
||||
{onCancelQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
|
||||
onClick={() => onCancelQueued(commentId)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
|
|
@ -976,6 +1020,9 @@ function IssueChatAssistantMessage() {
|
|||
feedbackTermsUrl,
|
||||
onVote,
|
||||
agentMap,
|
||||
activeRunIds,
|
||||
onStopRun,
|
||||
stoppingRunId,
|
||||
} = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
|
|
@ -988,6 +1035,7 @@ function IssueChatAssistantMessage() {
|
|||
const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null;
|
||||
const runId = typeof custom.runId === "string" ? custom.runId : null;
|
||||
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
||||
const runStatus = typeof custom.runStatus === "string" ? custom.runStatus : null;
|
||||
const agentId = authorAgentId ?? runAgentId;
|
||||
const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined;
|
||||
const commentId = typeof custom.commentId === "string" ? custom.commentId : null;
|
||||
|
|
@ -997,6 +1045,7 @@ function IssueChatAssistantMessage() {
|
|||
const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : "";
|
||||
const isRunning = message.role === "assistant" && message.status?.type === "running";
|
||||
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
|
||||
const canStopRun = canStopIssueChatRun({ runId, runStatus, activeRunIds });
|
||||
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
|
||||
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
|
||||
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
||||
|
|
@ -1162,6 +1211,18 @@ function IssueChatAssistantMessage() {
|
|||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
Copy message
|
||||
</DropdownMenuItem>
|
||||
{canStopRun && onStopRun && runId ? (
|
||||
<DropdownMenuItem
|
||||
disabled={stoppingRunId === runId}
|
||||
className="text-red-700 focus:text-red-800 dark:text-red-300 dark:focus:text-red-200"
|
||||
onSelect={() => {
|
||||
void onStopRun(runId);
|
||||
}}
|
||||
>
|
||||
<Square className="mr-2 h-3.5 w-3.5 fill-current" />
|
||||
{stoppingRunId === runId ? "Stopping…" : "Stop run"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{runHref ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={runHref} target="_blank" rel="noreferrer noopener">
|
||||
|
|
@ -1557,7 +1618,6 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
}, forwardedRef) {
|
||||
const api = useAui();
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
|
|
@ -1567,6 +1627,23 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
function queueViewportRestore(snapshot: ReturnType<typeof captureComposerViewportSnapshot>) {
|
||||
if (!snapshot) return;
|
||||
requestAnimationFrame(() => {
|
||||
restoreComposerViewportSnapshot(snapshot, composerContainerRef.current);
|
||||
});
|
||||
}
|
||||
|
||||
function focusComposer() {
|
||||
if (typeof composerContainerRef.current?.scrollIntoView === "function") {
|
||||
composerContainerRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" });
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
setBody(loadDraft(draftKey));
|
||||
|
|
@ -1591,12 +1668,15 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
}, [effectiveSuggestedAssigneeValue]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: () => {
|
||||
composerContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" });
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
focus: focusComposer,
|
||||
restoreDraft: (submittedBody: string) => {
|
||||
setBody((current) =>
|
||||
restoreSubmittedCommentDraft({
|
||||
currentBody: current,
|
||||
submittedBody,
|
||||
}),
|
||||
);
|
||||
focusComposer();
|
||||
},
|
||||
}), []);
|
||||
|
||||
|
|
@ -1606,12 +1686,17 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
|
||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : undefined;
|
||||
const reopen = shouldImplicitlyReopenComment(
|
||||
issueStatus,
|
||||
hasReassignment ? reassignTarget : currentAssigneeValue,
|
||||
) ? true : undefined;
|
||||
const submittedBody = trimmed;
|
||||
const viewportSnapshot = captureComposerViewportSnapshot(composerContainerRef.current);
|
||||
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
try {
|
||||
await api.thread().append({
|
||||
const appendPromise = api.thread().append({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: submittedBody }],
|
||||
metadata: { custom: {} },
|
||||
|
|
@ -1623,8 +1708,9 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
},
|
||||
},
|
||||
});
|
||||
queueViewportRestore(viewportSnapshot);
|
||||
await appendPromise;
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(issueStatus === "done" || issueStatus === "cancelled");
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} catch {
|
||||
setBody((current) =>
|
||||
|
|
@ -1635,6 +1721,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
queueViewportRestore(viewportSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1707,16 +1794,6 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopen}
|
||||
onChange={(event) => setReopen(event.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Re-open
|
||||
</label>
|
||||
|
||||
{enableReassign && reassignOptions.length > 0 ? (
|
||||
<InlineEntitySelector
|
||||
value={reassignTarget}
|
||||
|
|
@ -1781,6 +1858,7 @@ export function IssueChatThread({
|
|||
onVote,
|
||||
onAdd,
|
||||
onCancelRun,
|
||||
onStopRun,
|
||||
imageUploadHandler,
|
||||
onAttachImage,
|
||||
draftKey,
|
||||
|
|
@ -1799,13 +1877,18 @@ export function IssueChatThread({
|
|||
hasOutputForRun: hasOutputForRunOverride,
|
||||
includeSucceededRunsWithoutOutput = false,
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId = null,
|
||||
stoppingRunId = null,
|
||||
onImageClick,
|
||||
composerRef,
|
||||
}: IssueChatThreadProps) {
|
||||
const location = useLocation();
|
||||
const hasScrolledRef = useRef(false);
|
||||
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerViewportSnapshotRef = useRef<ReturnType<typeof captureComposerViewportSnapshot>>(null);
|
||||
const preserveComposerViewportRef = useRef(false);
|
||||
const displayLiveRuns = useMemo(() => {
|
||||
const deduped = new Map<string, LiveRunForIssue>();
|
||||
for (const run of liveRuns) {
|
||||
|
|
@ -1834,14 +1917,22 @@ export function IssueChatThread({
|
|||
activeRun,
|
||||
});
|
||||
}, [activeRun, displayLiveRuns, linkedRuns]);
|
||||
const activeRunIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const run of displayLiveRuns) {
|
||||
if (run.status === "queued" || run.status === "running") {
|
||||
ids.add(run.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [displayLiveRuns]);
|
||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||
runs: enableLiveTranscriptPolling ? transcriptRuns : [],
|
||||
companyId,
|
||||
});
|
||||
const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun;
|
||||
const resolvedHasOutputForRun = hasOutputForRunOverride ?? hasOutputForRun;
|
||||
|
||||
const messages = useMemo(
|
||||
const rawMessages = useMemo(
|
||||
() =>
|
||||
buildIssueChatMessages({
|
||||
comments,
|
||||
|
|
@ -1872,6 +1963,18 @@ export function IssueChatThread({
|
|||
currentUserId,
|
||||
],
|
||||
);
|
||||
const stableMessagesRef = useRef<readonly import("@assistant-ui/react").ThreadMessage[]>([]);
|
||||
const stableMessageCacheRef = useRef<Map<string, StableThreadMessageCacheEntry>>(new Map());
|
||||
const messages = useMemo(() => {
|
||||
const stabilized = stabilizeThreadMessages(
|
||||
rawMessages,
|
||||
stableMessagesRef.current,
|
||||
stableMessageCacheRef.current,
|
||||
);
|
||||
stableMessagesRef.current = stabilized.messages;
|
||||
stableMessageCacheRef.current = stabilized.cache;
|
||||
return stabilized.messages;
|
||||
}, [rawMessages]);
|
||||
|
||||
const isRunning = displayLiveRuns.some((run) => run.status === "queued" || run.status === "running");
|
||||
const feedbackVoteByTargetId = useMemo(() => {
|
||||
|
|
@ -1890,6 +1993,19 @@ export function IssueChatThread({
|
|||
onCancel: onCancelRun,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const composerElement = composerViewportAnchorRef.current;
|
||||
if (preserveComposerViewportRef.current) {
|
||||
restoreComposerViewportSnapshot(
|
||||
composerViewportSnapshotRef.current,
|
||||
composerElement,
|
||||
);
|
||||
}
|
||||
|
||||
composerViewportSnapshotRef.current = captureComposerViewportSnapshot(composerElement);
|
||||
preserveComposerViewportRef.current = shouldPreserveComposerViewport(composerElement);
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return;
|
||||
|
|
@ -1912,8 +2028,12 @@ export function IssueChatThread({
|
|||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
activeRunIds,
|
||||
onVote,
|
||||
onStopRun,
|
||||
stoppingRunId,
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
onImageClick,
|
||||
}),
|
||||
|
|
@ -1923,8 +2043,12 @@ export function IssueChatThread({
|
|||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
activeRunIds,
|
||||
onVote,
|
||||
onStopRun,
|
||||
stoppingRunId,
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
onImageClick,
|
||||
],
|
||||
|
|
@ -1990,20 +2114,22 @@ export function IssueChatThread({
|
|||
</IssueChatErrorBoundary>
|
||||
|
||||
{showComposer ? (
|
||||
<IssueChatComposer
|
||||
ref={composerRef}
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentions}
|
||||
agentMap={agentMap}
|
||||
composerDisabledReason={composerDisabledReason}
|
||||
issueStatus={issueStatus}
|
||||
/>
|
||||
<div ref={composerViewportAnchorRef}>
|
||||
<IssueChatComposer
|
||||
ref={composerRef}
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentions}
|
||||
agentMap={agentMap}
|
||||
composerDisabledReason={composerDisabledReason}
|
||||
issueStatus={issueStatus}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</IssueChatCtx.Provider>
|
||||
|
|
|
|||
|
|
@ -226,6 +226,79 @@ describe("IssuesList", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps server-side search scoped to the provided parent issue filters", async () => {
|
||||
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
|
||||
const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" });
|
||||
|
||||
mockIssuesApi.list.mockResolvedValue([serverIssue]);
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[localIssue]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
initialSearch="server"
|
||||
searchFilters={{ parentId: "parent-1" }}
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
|
||||
q: "server",
|
||||
projectId: undefined,
|
||||
parentId: "parent-1",
|
||||
});
|
||||
expect(container.textContent).toContain("Server result");
|
||||
expect(container.textContent).not.toContain("Local issue");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the supplied create defaults and label for sub-issue lists", async () => {
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[createIssue()]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
baseCreateIssueDefaults={{ parentId: "parent-1", projectId: "project-1" }}
|
||||
createIssueLabel="Sub-issue"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.includes("New Sub-issue"),
|
||||
);
|
||||
expect(button).not.toBeUndefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.includes("New Sub-issue"),
|
||||
);
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(dialogState.openNewIssue).toHaveBeenCalledWith({
|
||||
parentId: "parent-1",
|
||||
projectId: "project-1",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("debounces search updates so typing does not notify the page on every keystroke", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
|
|
|||
|
|
@ -50,9 +50,10 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, Columns3, User, Search } from "lucide-react";
|
||||
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search } from "lucide-react";
|
||||
import { KanbanBoard } from "./KanbanBoard";
|
||||
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
|
||||
import type { Issue, Project } from "@paperclipai/shared";
|
||||
const ISSUE_SEARCH_DEBOUNCE_MS = 150;
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ export type IssueViewState = IssueFilterState & {
|
|||
sortDir: "asc" | "desc";
|
||||
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
|
||||
viewMode: "list" | "board";
|
||||
nestingEnabled: boolean;
|
||||
collapsedGroups: string[];
|
||||
collapsedParents: string[];
|
||||
};
|
||||
|
|
@ -73,6 +75,7 @@ const defaultViewState: IssueViewState = {
|
|||
sortDir: "desc",
|
||||
groupBy: "none",
|
||||
viewMode: "list",
|
||||
nestingEnabled: true,
|
||||
collapsedGroups: [],
|
||||
collapsedParents: [],
|
||||
};
|
||||
|
|
@ -118,6 +121,7 @@ interface Agent {
|
|||
}
|
||||
|
||||
type ProjectOption = Pick<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
|
||||
type IssueListRequestFilters = NonNullable<Parameters<typeof issuesApi.list>[1]>;
|
||||
|
||||
interface IssuesListProps {
|
||||
issues: Issue[];
|
||||
|
|
@ -131,9 +135,9 @@ interface IssuesListProps {
|
|||
issueLinkState?: unknown;
|
||||
initialAssignees?: string[];
|
||||
initialSearch?: string;
|
||||
searchFilters?: {
|
||||
participantAgentId?: string;
|
||||
};
|
||||
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
|
||||
baseCreateIssueDefaults?: Record<string, unknown>;
|
||||
createIssueLabel?: string;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
onSearchChange?: (search: string) => void;
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
|
|
@ -214,6 +218,8 @@ export function IssuesList({
|
|||
initialAssignees,
|
||||
initialSearch,
|
||||
searchFilters,
|
||||
baseCreateIssueDefaults,
|
||||
createIssueLabel,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
onSearchChange,
|
||||
onUpdateIssue,
|
||||
|
|
@ -484,8 +490,8 @@ export function IssuesList({
|
|||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
|
||||
|
||||
const newIssueDefaults = useCallback((groupKey?: string) => {
|
||||
const defaults: Record<string, string> = {};
|
||||
if (projectId) defaults.projectId = projectId;
|
||||
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
|
||||
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
|
||||
if (groupKey) {
|
||||
if (viewState.groupBy === "status") defaults.status = groupKey;
|
||||
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
|
||||
|
|
@ -494,11 +500,19 @@ export function IssuesList({
|
|||
else defaults.assigneeAgentId = groupKey;
|
||||
}
|
||||
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
|
||||
defaults.parentId = groupKey;
|
||||
const parentIssue = issueById.get(groupKey);
|
||||
if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId));
|
||||
else defaults.parentId = groupKey;
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}, [projectId, viewState.groupBy]);
|
||||
}, [baseCreateIssueDefaults, currentUserId, issueById, projectId, viewState.groupBy]);
|
||||
|
||||
const createActionLabel = createIssueLabel ? `Create ${createIssueLabel}` : "Create Issue";
|
||||
const createButtonLabel = createIssueLabel ? `New ${createIssueLabel}` : "New Issue";
|
||||
const openCreateIssueDialog = useCallback((groupKey?: string) => {
|
||||
openNewIssue(newIssueDefaults(groupKey));
|
||||
}, [newIssueDefaults, openNewIssue]);
|
||||
|
||||
const filterToWorkspace = useCallback((workspaceId: string) => {
|
||||
updateView({ workspaces: [workspaceId] });
|
||||
|
|
@ -530,9 +544,9 @@ export function IssuesList({
|
|||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-2 sm:gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||
<Button size="sm" variant="outline" onClick={() => openNewIssue(newIssueDefaults())}>
|
||||
<Button size="sm" variant="outline" onClick={() => openCreateIssueDialog()}>
|
||||
<Plus className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">New Issue</span>
|
||||
<span className="hidden sm:inline">{createButtonLabel}</span>
|
||||
</Button>
|
||||
<IssueSearchInput
|
||||
value={issueSearch}
|
||||
|
|
@ -562,6 +576,19 @@ export function IssuesList({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{viewState.viewMode === "list" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn("hidden h-8 w-8 shrink-0 sm:inline-flex", viewState.nestingEnabled && "bg-accent")}
|
||||
onClick={() => updateView({ nestingEnabled: !viewState.nestingEnabled })}
|
||||
title={viewState.nestingEnabled ? "Disable parent-child nesting" : "Enable parent-child nesting"}
|
||||
>
|
||||
<ListTree className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<IssueColumnPicker
|
||||
availableColumns={availableIssueColumns}
|
||||
visibleColumnSet={visibleIssueColumnSet}
|
||||
|
|
@ -670,8 +697,8 @@ export function IssuesList({
|
|||
<EmptyState
|
||||
icon={CircleDot}
|
||||
message="No issues match the current filters or search."
|
||||
action="Create Issue"
|
||||
onAction={() => openNewIssue(newIssueDefaults())}
|
||||
action={createActionLabel}
|
||||
onAction={() => openCreateIssueDialog()}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -707,7 +734,7 @@ export function IssuesList({
|
|||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="ml-auto text-muted-foreground"
|
||||
onClick={() => openNewIssue(newIssueDefaults(group.key))}
|
||||
onClick={() => openCreateIssueDialog(group.key)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -715,7 +742,9 @@ export function IssuesList({
|
|||
)}
|
||||
<CollapsibleContent>
|
||||
{(() => {
|
||||
const { roots, childMap } = buildIssueTree(group.items);
|
||||
const { roots, childMap } = viewState.nestingEnabled
|
||||
? buildIssueTree(group.items)
|
||||
: { roots: group.items, childMap: new Map<string, Issue[]>() };
|
||||
|
||||
const renderIssueRow = (issue: Issue, depth: number) => {
|
||||
const children = childMap.get(issue.id) ?? [];
|
||||
|
|
@ -817,15 +846,15 @@ export function IssuesList({
|
|||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
Assignee
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -146,6 +146,20 @@ describe("MarkdownBody", () => {
|
|||
expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<");
|
||||
});
|
||||
|
||||
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" },
|
||||
{ identifier: "PAP-1311", status: "blocked" },
|
||||
]);
|
||||
|
||||
expect(html).toContain('href="/issues/PAP-1310"');
|
||||
expect(html).toContain('href="/issues/PAP-1311"');
|
||||
expect(html).toContain(">issue://PAP-1310<");
|
||||
expect(html).toContain(">issue://:PAP-1311<");
|
||||
expect(html).toContain("text-green-600");
|
||||
expect(html).toContain("text-red-600");
|
||||
});
|
||||
|
||||
it("linkifies issue identifiers inside inline code spans", () => {
|
||||
const html = renderMarkdown("Reference `PAP-1271` here.", [
|
||||
{ identifier: "PAP-1271", status: "done" },
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import { buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import {
|
||||
computeMentionMenuPosition,
|
||||
findClosestAutocompleteAnchor,
|
||||
|
|
@ -16,6 +16,9 @@ import {
|
|||
|
||||
const mdxEditorMockState = vi.hoisted(() => ({
|
||||
emitMountEmptyReset: false,
|
||||
emitMountParseError: false,
|
||||
emitMountSilentEmptyState: false,
|
||||
markdownValues: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("@mdxeditor/editor", async () => {
|
||||
|
|
@ -36,19 +39,29 @@ vi.mock("@mdxeditor/editor", async () => {
|
|||
markdown,
|
||||
placeholder,
|
||||
onChange,
|
||||
onError,
|
||||
className,
|
||||
}: {
|
||||
markdown: string;
|
||||
placeholder?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
className?: string;
|
||||
},
|
||||
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
|
||||
) {
|
||||
mdxEditorMockState.markdownValues.push(markdown);
|
||||
const [content, setContent] = React.useState(markdown);
|
||||
const editableRef = React.useRef<HTMLDivElement>(null);
|
||||
const handle = React.useMemo(() => ({
|
||||
setMarkdown: (value: string) => setContent(value),
|
||||
focus: () => {},
|
||||
focus: () => editableRef.current?.focus(),
|
||||
}), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setContent(markdown);
|
||||
}, [markdown]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setForwardedRef(forwardedRef, null);
|
||||
const timer = window.setTimeout(() => {
|
||||
|
|
@ -57,6 +70,16 @@ vi.mock("@mdxeditor/editor", async () => {
|
|||
setContent("");
|
||||
onChange?.("");
|
||||
}
|
||||
if (mdxEditorMockState.emitMountSilentEmptyState) {
|
||||
setContent("");
|
||||
}
|
||||
if (mdxEditorMockState.emitMountParseError) {
|
||||
setContent("");
|
||||
onError?.({
|
||||
error: "Unsupported markdown syntax",
|
||||
source: markdown,
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
|
|
@ -64,7 +87,17 @@ vi.mock("@mdxeditor/editor", async () => {
|
|||
};
|
||||
}, []);
|
||||
|
||||
return <div data-testid="mdx-editor">{content || placeholder || ""}</div>;
|
||||
return (
|
||||
<div
|
||||
ref={editableRef}
|
||||
data-testid="mdx-editor"
|
||||
className={className}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
>
|
||||
{content || placeholder || ""}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -105,16 +138,33 @@ async function flush() {
|
|||
|
||||
describe("MarkdownEditor", () => {
|
||||
let container: HTMLDivElement;
|
||||
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
originalRangeRect = Range.prototype.getBoundingClientRect;
|
||||
Range.prototype.getBoundingClientRect = () => ({
|
||||
x: 32,
|
||||
y: 24,
|
||||
width: 12,
|
||||
height: 18,
|
||||
top: 24,
|
||||
right: 44,
|
||||
bottom: 42,
|
||||
left: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
Range.prototype.getBoundingClientRect = originalRangeRect;
|
||||
vi.clearAllMocks();
|
||||
mdxEditorMockState.emitMountEmptyReset = false;
|
||||
mdxEditorMockState.emitMountParseError = false;
|
||||
mdxEditorMockState.emitMountSilentEmptyState = false;
|
||||
mdxEditorMockState.markdownValues = [];
|
||||
});
|
||||
|
||||
it("applies async external value updates once the editor ref becomes ready", async () => {
|
||||
|
|
@ -172,6 +222,94 @@ describe("MarkdownEditor", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("converts advisory-style html image tags to markdown image syntax before mounting the editor", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value={`Before\n\n<img width="10" height="10" alt="image" src="https://example.com/test.png" />\n\nAfter`}
|
||||
onChange={() => {}}
|
||||
placeholder="Markdown body"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(mdxEditorMockState.markdownValues.at(-1)).toContain("");
|
||||
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
|
||||
expect(container.textContent).toContain("Before");
|
||||
expect(container.textContent).toContain("After");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a raw textarea when the rich parser rejects the markdown", async () => {
|
||||
mdxEditorMockState.emitMountParseError = true;
|
||||
const handleChange = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value="Affected versions: <= v0.3.1"
|
||||
onChange={handleChange}
|
||||
placeholder="Markdown body"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelector("textarea")).not.toBeNull();
|
||||
});
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
expect(textarea).not.toBeNull();
|
||||
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
|
||||
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a raw textarea when the rich editor mounts into the placeholder without callbacks", async () => {
|
||||
mdxEditorMockState.emitMountSilentEmptyState = true;
|
||||
const handleChange = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value="Affected versions: <= v0.3.1"
|
||||
onChange={handleChange}
|
||||
placeholder="Add a description..."
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelector("textarea")).not.toBeNull();
|
||||
});
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
expect(textarea).not.toBeNull();
|
||||
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
|
||||
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
|
||||
expect(
|
||||
computeMentionMenuPosition(
|
||||
|
|
@ -312,4 +450,64 @@ describe("MarkdownEditor", () => {
|
|||
|
||||
editable.remove();
|
||||
});
|
||||
|
||||
it("accepts mention selection from touchstart taps", async () => {
|
||||
const handleChange = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value="@Pap"
|
||||
onChange={handleChange}
|
||||
mentions={[
|
||||
{
|
||||
id: "project:project-123",
|
||||
kind: "project",
|
||||
name: "Paperclip App",
|
||||
projectId: "project-123",
|
||||
projectColor: "#336699",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const editable = container.querySelector('[contenteditable="true"]');
|
||||
expect(editable).not.toBeNull();
|
||||
|
||||
const textNode = editable?.firstChild;
|
||||
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode!, "@Pap".length);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("selectionchange"));
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const option = Array.from(document.body.querySelectorAll('button[type="button"]'))
|
||||
.find((node) => node.textContent?.includes("Paperclip App"));
|
||||
expect(option).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
option?.dispatchEvent(new Event("touchstart", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
`[@Paperclip App](${buildProjectMentionHref("project-123", "#336699")}) `,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type TouchEvent as ReactTouchEvent,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
|
|
@ -75,10 +78,76 @@ export interface MarkdownEditorRef {
|
|||
focus: () => void;
|
||||
}
|
||||
|
||||
function readHtmlAttribute(attrs: string, name: string): string | null {
|
||||
const match = new RegExp(`${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attrs);
|
||||
return match?.[2] ?? match?.[3] ?? match?.[4] ?? null;
|
||||
}
|
||||
|
||||
function convertHtmlImagesToMarkdown(text: string): string {
|
||||
return text.replace(/<img\b([^>]*?)\/?>/gi, (tag, attrs: string) => {
|
||||
const src = readHtmlAttribute(attrs, "src");
|
||||
if (!src) return tag;
|
||||
const alt = readHtmlAttribute(attrs, "alt") ?? "image";
|
||||
const title = readHtmlAttribute(attrs, "title");
|
||||
const escapedAlt = alt.replace(/[[\]]/g, "\\$&");
|
||||
const escapedTitle = title?.replace(/"/g, '\\"');
|
||||
return escapedTitle
|
||||
? ``
|
||||
: ``;
|
||||
});
|
||||
}
|
||||
|
||||
function prepareMarkdownForEditor(value: string): string {
|
||||
const normalizedLineEndings = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
return convertHtmlImagesToMarkdown(normalizedLineEndings);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function hasMeaningfulEditorContent(node: Node | null): boolean {
|
||||
if (!node) return false;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return (node.textContent ?? "").trim().length > 0;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const element = node as HTMLElement;
|
||||
if (["IMG", "HR", "TABLE", "VIDEO", "IFRAME"].includes(element.tagName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Array.from(element.childNodes).some((child) => hasMeaningfulEditorContent(child));
|
||||
}
|
||||
|
||||
function isRichEditorDomEmpty(
|
||||
editable: HTMLElement,
|
||||
expectedValue: string,
|
||||
placeholder?: string,
|
||||
): boolean {
|
||||
const expectedText = expectedValue.trim();
|
||||
if (!expectedText) return false;
|
||||
|
||||
const visibleText = (editable.textContent ?? "").trim();
|
||||
if (visibleText.length === 0) {
|
||||
return !Array.from(editable.childNodes).some((child) => hasMeaningfulEditorContent(child));
|
||||
}
|
||||
|
||||
const normalizedPlaceholder = placeholder?.trim();
|
||||
if (
|
||||
normalizedPlaceholder
|
||||
&& visibleText === normalizedPlaceholder
|
||||
&& expectedText !== normalizedPlaceholder
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSafeMarkdownLinkUrl(url: string): boolean {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return true;
|
||||
|
|
@ -417,12 +486,14 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
mentions,
|
||||
onSubmit,
|
||||
}: MarkdownEditorProps, forwardedRef) {
|
||||
const editorValue = useMemo(() => prepareMarkdownForEditor(value), [value]);
|
||||
const { slashCommands } = useEditorAutocomplete();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const ref = useRef<MDXEditorMethods>(null);
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
const latestValueRef = useRef(value);
|
||||
const fallbackTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const valueRef = useRef(editorValue);
|
||||
valueRef.current = editorValue;
|
||||
const latestValueRef = useRef(editorValue);
|
||||
const initialChildOnChangeRef = useRef(true);
|
||||
/**
|
||||
* After imperative `setMarkdown` (prop sync, mentions, image upload), MDXEditor may emit `onChange`
|
||||
|
|
@ -432,6 +503,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
const echoIgnoreMarkdownRef = useRef<string | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [richEditorError, setRichEditorError] = useState<string | null>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
// Stable ref for imageUploadHandler so plugins don't recreate on every render
|
||||
|
|
@ -443,6 +515,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
const mentionStateRef = useRef<MentionState | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const skillEnterArmedRef = useRef(false);
|
||||
const autocompleteSelectionHandledRef = useRef(false);
|
||||
const mentionActive = mentionState !== null && (
|
||||
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|
||||
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
|
||||
|
|
@ -491,9 +564,59 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: () => {
|
||||
if (richEditorError) {
|
||||
fallbackTextareaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
},
|
||||
}), []);
|
||||
}), [richEditorError]);
|
||||
|
||||
const autoSizeFallbackTextarea = useCallback((element: HTMLTextAreaElement | null) => {
|
||||
if (!element) return;
|
||||
element.style.height = "auto";
|
||||
element.style.height = `${element.scrollHeight}px`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!richEditorError) return;
|
||||
autoSizeFallbackTextarea(fallbackTextareaRef.current);
|
||||
}, [autoSizeFallbackTextarea, richEditorError, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (richEditorError || editorValue.trim().length === 0) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let timeoutId = 0;
|
||||
const scheduleCheck = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
timeoutId = window.setTimeout(() => {
|
||||
const editable = container.querySelector('[contenteditable="true"]');
|
||||
if (!(editable instanceof HTMLElement)) return;
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement === editable || editable.contains(activeElement)) return;
|
||||
if (isRichEditorDomEmpty(editable, editorValue, placeholder)) {
|
||||
setRichEditorError("Rich editor failed to load content");
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
scheduleCheck();
|
||||
const observer = new MutationObserver(() => {
|
||||
scheduleCheck();
|
||||
});
|
||||
observer.observe(container, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [editorValue, placeholder, richEditorError]);
|
||||
|
||||
// Whether the image plugin should be included (boolean is stable across renders
|
||||
// as long as the handler presence doesn't toggle)
|
||||
|
|
@ -558,15 +681,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
}, [hasImageUpload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== latestValueRef.current) {
|
||||
if (editorValue !== latestValueRef.current) {
|
||||
if (ref.current) {
|
||||
// Pair with onChange echo suppression (echoIgnoreMarkdownRef).
|
||||
echoIgnoreMarkdownRef.current = value;
|
||||
ref.current.setMarkdown(value);
|
||||
latestValueRef.current = value;
|
||||
echoIgnoreMarkdownRef.current = editorValue;
|
||||
ref.current.setMarkdown(editorValue);
|
||||
latestValueRef.current = editorValue;
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
}, [editorValue]);
|
||||
|
||||
const decorateProjectMentions = useCallback(() => {
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
|
|
@ -676,6 +799,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
};
|
||||
}, [checkMention, mentionActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mentionActive) return;
|
||||
autocompleteSelectionHandledRef.current = false;
|
||||
}, [mentionActive]);
|
||||
|
||||
useEffect(() => {
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
if (!editable) return;
|
||||
|
|
@ -696,7 +824,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
// Read from ref to avoid stale-closure issues (selectionchange can
|
||||
// update state between the last render and this callback firing).
|
||||
const state = mentionStateRef.current;
|
||||
if (!state) return;
|
||||
if (!state) return false;
|
||||
const current = latestValueRef.current;
|
||||
const next = applyMention(current, state, option);
|
||||
if (next !== current) {
|
||||
|
|
@ -729,10 +857,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return true;
|
||||
},
|
||||
[decorateProjectMentions, onChange],
|
||||
);
|
||||
|
||||
const handleAutocompletePress = useCallback((
|
||||
event: ReactMouseEvent<HTMLButtonElement> | ReactPointerEvent<HTMLButtonElement> | ReactTouchEvent<HTMLButtonElement>,
|
||||
option: AutocompleteOption,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (autocompleteSelectionHandledRef.current) return;
|
||||
const handled = selectMention(option);
|
||||
if (handled) {
|
||||
autocompleteSelectionHandledRef.current = true;
|
||||
}
|
||||
}, [selectMention]);
|
||||
|
||||
function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
|
||||
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
||||
}
|
||||
|
|
@ -761,6 +903,52 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
)
|
||||
: null;
|
||||
|
||||
if (richEditorError) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative paperclip-mdxeditor-scope",
|
||||
bordered ? "rounded-md border border-border bg-transparent" : "bg-transparent",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-3 pt-2 text-xs text-muted-foreground">
|
||||
<p>Rich editor unavailable for this markdown. Showing raw source instead.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 underline underline-offset-2 hover:text-foreground"
|
||||
onClick={() => {
|
||||
setRichEditorError(null);
|
||||
}}
|
||||
>
|
||||
Retry rich editor
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={fallbackTextareaRef}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value);
|
||||
autoSizeFallbackTextarea(event.target);
|
||||
}}
|
||||
onBlur={() => onBlur?.()}
|
||||
onKeyDown={(event) => {
|
||||
if (onSubmit && event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"min-h-[12rem] w-full resize-none bg-transparent px-3 pb-3 pt-2 font-mono text-sm leading-6 outline-none",
|
||||
contentClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -868,7 +1056,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
>
|
||||
<MDXEditor
|
||||
ref={setEditorRef}
|
||||
markdown={value}
|
||||
markdown={editorValue}
|
||||
placeholder={placeholder}
|
||||
onChange={(next) => {
|
||||
const echo = echoIgnoreMarkdownRef.current;
|
||||
|
|
@ -883,9 +1071,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
|
||||
if (initialChildOnChangeRef.current) {
|
||||
initialChildOnChangeRef.current = false;
|
||||
if (next === "" && value !== "") {
|
||||
echoIgnoreMarkdownRef.current = value;
|
||||
ref.current?.setMarkdown(value);
|
||||
if (next === "" && editorValue !== "") {
|
||||
echoIgnoreMarkdownRef.current = editorValue;
|
||||
ref.current?.setMarkdown(editorValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -893,6 +1081,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
onChange(next);
|
||||
}}
|
||||
onBlur={() => onBlur?.()}
|
||||
onError={(payload) => {
|
||||
setRichEditorError(payload.error);
|
||||
}}
|
||||
className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")}
|
||||
contentEditableClassName={cn(
|
||||
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
|
||||
|
|
@ -917,10 +1108,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
||||
i === mentionIndex && "bg-accent",
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault(); // prevent blur
|
||||
selectMention(option);
|
||||
}}
|
||||
onPointerDown={(e) => handleAutocompletePress(e, option)}
|
||||
onMouseDown={(e) => handleAutocompletePress(e, option)}
|
||||
onTouchStart={(e) => handleAutocompletePress(e, option)}
|
||||
onMouseEnter={() => {
|
||||
if (mentionStateRef.current?.trigger === "skill") {
|
||||
skillEnterArmedRef.current = true;
|
||||
|
|
|
|||
|
|
@ -372,6 +372,43 @@ describe("NewIssueDialog", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("submits the parent assignee when a sub-issue opens with inherited defaults", async () => {
|
||||
dialogState.newIssueDefaults = {
|
||||
parentId: "issue-1",
|
||||
parentIdentifier: "PAP-1",
|
||||
parentTitle: "Parent issue",
|
||||
title: "Child issue",
|
||||
projectId: "project-1",
|
||||
goalId: "goal-1",
|
||||
assigneeAgentId: "agent-1",
|
||||
};
|
||||
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Create Sub-Issue"));
|
||||
expect(submitButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
title: "Child issue",
|
||||
parentId: "issue-1",
|
||||
goalId: "goal-1",
|
||||
projectId: "project-1",
|
||||
assigneeAgentId: "agent-1",
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the mobile dialog bounded with an internal flexible scroll region", async () => {
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue