diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 5aebdc8c3a..20a9177689 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -318,7 +318,7 @@ describe("IssueThreadInteractionCard", () => { ); }); - it("labels accept-only continuation policies in the card header", () => { + it("does not expose continuation wake policy labels in the card header", () => { const host = renderCard({ interaction: { ...pendingRequestConfirmationInteraction, @@ -326,7 +326,8 @@ describe("IssueThreadInteractionCard", () => { }, }); - expect(host.textContent).toContain("Wakes on confirm"); + expect(host.textContent).not.toContain("Wakes on confirm"); + expect(host.textContent).not.toContain("Wakes assignee"); }); it("renders request confirmation target links and stale-target expiry", () => { diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 22adf75344..6536768ad3 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { Agent } from "@paperclipai/shared"; -import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, FileText, GitBranch, ImagePlus, ListChecks, Loader2, MessageSquareQuote, X, XCircle } from "lucide-react"; +import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, X, XCircle } from "lucide-react"; import { Link } from "@/lib/router"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { @@ -1941,15 +1941,6 @@ export function IssueThreadInteractionCard({ / {planStyles ? planStyles.label : statusLabel(interaction.status)} - {interaction.continuationPolicy === "wake_assignee" - || interaction.continuationPolicy === "wake_assignee_on_accept" ? ( - - - {interaction.continuationPolicy === "wake_assignee_on_accept" - ? "Wakes on confirm" - : "Wakes responsible"} - - ) : null}
diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 82bfeca97b..e607c6c27d 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { buildIssueReferenceHref, buildProjectMentionHref, buildRoutineMentionHref, buildSkillMentionHref } from "@paperclipai/shared"; @@ -20,6 +20,7 @@ const mdxEditorMockState = vi.hoisted(() => ({ emitMountEmptyReset: false, emitMountParseError: false, emitMountSilentEmptyState: false, + throwOnRender: false, markdownValues: [] as string[], suppressHtmlProcessingValues: [] as boolean[], })); @@ -59,6 +60,9 @@ vi.mock("@mdxeditor/editor", async () => { }, forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>, ) { + if (mdxEditorMockState.throwOnRender) { + throw new Error("Rich editor render crashed"); + } mdxEditorMockState.markdownValues.push(markdown); mdxEditorMockState.suppressHtmlProcessingValues.push(Boolean(suppressHtmlProcessing)); const [content, setContent] = React.useState(markdown); @@ -148,6 +152,14 @@ vi.mock("../lib/paste-normalization", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + async function flush() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -155,13 +167,20 @@ async function flush() { } function createFileDragEvent(type: string) { - const event = new Event(type, { bubbles: true, cancelable: true }) as Event & { + const event = ( + typeof DragEvent === "function" + ? new DragEvent(type, { bubbles: true, cancelable: true }) + : new Event(type, { bubbles: true, cancelable: true }) + ) as Event & { dataTransfer: { types: string[]; files: File[]; dropEffect?: string }; }; - event.dataTransfer = { - types: ["Files"], - files: [], - }; + Object.defineProperty(event, "dataTransfer", { + configurable: true, + value: { + types: ["Files"], + files: [], + }, + }); return event; } @@ -223,6 +242,7 @@ describe("MarkdownEditor", () => { mdxEditorMockState.emitMountEmptyReset = false; mdxEditorMockState.emitMountParseError = false; mdxEditorMockState.emitMountSilentEmptyState = false; + mdxEditorMockState.throwOnRender = false; mdxEditorMockState.markdownValues = []; mdxEditorMockState.suppressHtmlProcessingValues = []; }); @@ -386,6 +406,44 @@ describe("MarkdownEditor", () => { }); }); + it("falls back to a raw textarea when the rich editor crashes during render", async () => { + mdxEditorMockState.throwOnRender = true; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const handleChange = vi.fn(); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + }); + + await vi.waitFor(() => { + expect(container.querySelector("textarea")).not.toBeNull(); + }); + const textarea = container.querySelector("textarea"); + expect(textarea).not.toBeNull(); + expect(textarea?.value).toBe("5. python3 circleback/sync_insights.py --input -- writes insights//*.md"); + expect(container.textContent).toContain("Rich editor unavailable for this markdown"); + expect(consoleError).toHaveBeenCalledWith( + "Markdown rich editor failed; falling back to raw textarea", + expect.objectContaining({ + error: expect.any(Error), + componentStack: expect.any(String), + }), + ); + consoleError.mockRestore(); + 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(); @@ -435,16 +493,18 @@ describe("MarkdownEditor", () => { const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null; expect(scope).not.toBeNull(); - act(() => { + await act(async () => { scope?.dispatchEvent(createFileDragEvent("dragenter")); }); + await flush(); expect(scope?.className).toContain("ring-1"); expect(container.textContent).toContain("Drop image to upload"); - act(() => { + await act(async () => { scope?.dispatchEvent(createFileDragEvent("dragleave")); }); + await flush(); expect(scope?.className).not.toContain("ring-1"); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index ac21745d70..b111f253c1 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -1,5 +1,7 @@ import { + Component, type ClipboardEvent, + type ErrorInfo, forwardRef, useCallback, useEffect, @@ -11,6 +13,7 @@ import { type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type TouchEvent as ReactTouchEvent, + type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { @@ -93,6 +96,30 @@ export interface MarkdownEditorRef { insertMarkdown: (markdown: string) => void; } +class MarkdownEditorRichErrorBoundary extends Component< + { children: ReactNode; onError: (error: unknown) => void }, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: unknown, info: ErrorInfo) { + console.error("Markdown rich editor failed; falling back to raw textarea", { + error, + componentStack: info.componentStack, + }); + this.props.onError(error); + } + + render() { + if (this.state.hasError) return null; + return this.props.children; + } +} + 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; @@ -176,6 +203,12 @@ function isSafeMarkdownLinkUrl(url: string): boolean { return !/^(javascript|data|vbscript):/i.test(trimmed); } +function richEditorErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return "Rich editor failed to render"; +} + /* ---- Mention detection helpers ---- */ interface MentionState { @@ -1092,6 +1125,10 @@ export const MarkdownEditor = forwardRef ref.current.insertMarkdown(normalizeMarkdown(rawText)); }, []); + const handleRichEditorError = useCallback((error: unknown) => { + setRichEditorError(richEditorErrorMessage(error)); + }, []); + const mentionMenuPosition = mentionState ? computeMentionMenuPosition( mentionState, @@ -1258,47 +1295,49 @@ export const MarkdownEditor = forwardRef }} onPasteCapture={handlePasteCapture} > - { - if (readOnly) return; - const echo = echoIgnoreMarkdownRef.current; - if (echo !== null && next === echo) { - echoIgnoreMarkdownRef.current = null; - latestValueRef.current = next; - return; - } - if (echo !== null) { - echoIgnoreMarkdownRef.current = null; - } - - if (initialChildOnChangeRef.current) { - initialChildOnChangeRef.current = false; - if (next === "" && editorValue !== "") { - echoIgnoreMarkdownRef.current = editorValue; - ref.current?.setMarkdown(editorValue); + + { + if (readOnly) return; + const echo = echoIgnoreMarkdownRef.current; + if (echo !== null && next === echo) { + echoIgnoreMarkdownRef.current = null; + latestValueRef.current = next; return; } - } - latestValueRef.current = next; - 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", - contentClassName, - )} - additionalLexicalNodes={[MentionAwareLinkNode, mentionAwareLinkNodeReplacement]} - plugins={plugins} - /> + if (echo !== null) { + echoIgnoreMarkdownRef.current = null; + } + + if (initialChildOnChangeRef.current) { + initialChildOnChangeRef.current = false; + if (next === "" && editorValue !== "") { + echoIgnoreMarkdownRef.current = editorValue; + ref.current?.setMarkdown(editorValue); + return; + } + } + latestValueRef.current = next; + onChange(next); + }} + onBlur={() => onBlur?.()} + onError={(payload) => { + handleRichEditorError(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", + contentClassName, + )} + additionalLexicalNodes={[MentionAwareLinkNode, mentionAwareLinkNodeReplacement]} + plugins={plugins} + /> + {/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */} {mentionActive && filteredMentions.length > 0 && mentionMenuPosition && diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 025437f8ad..3de44e7e57 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -14,6 +14,7 @@ const dialogState = vi.hoisted(() => ({ })); const dialogContentState = vi.hoisted(() => ({ + onEscapeKeyDown: null as null | ((event: KeyboardEvent) => void), onPointerDownOutside: null as null | ((event: { detail: { originalEvent: { target: EventTarget | null } }; preventDefault: () => void; @@ -208,14 +209,15 @@ vi.mock("@/components/ui/dialog", () => ({ DialogContent: ({ children, showCloseButton: _showCloseButton, - onEscapeKeyDown: _onEscapeKeyDown, + onEscapeKeyDown, onPointerDownOutside, ...props }: ComponentProps<"div"> & { showCloseButton?: boolean; - onEscapeKeyDown?: (event: unknown) => void; + onEscapeKeyDown?: (event: KeyboardEvent) => void; onPointerDownOutside?: (event: unknown) => void; }) => { + dialogContentState.onEscapeKeyDown = onEscapeKeyDown ?? null; dialogContentState.onPointerDownOutside = onPointerDownOutside as typeof dialogContentState.onPointerDownOutside; return
{children}
; }, @@ -330,6 +332,7 @@ describe("NewIssueDialog", () => { dialogState.newIssueOpen = true; dialogState.newIssueDefaults = {}; dialogState.closeNewIssue.mockReset(); + dialogContentState.onEscapeKeyDown = null; dialogContentState.onPointerDownOutside = null; toastState.pushToast.mockReset(); mockIssuesApi.create.mockReset(); @@ -923,7 +926,7 @@ describe("NewIssueDialog", () => { await act(async () => { modeChip()?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, - code: "Period", + code: "", key: ".", metaKey: true, })); @@ -953,6 +956,76 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("cycles work modes when iOS reports cmd-period as Escape", async () => { + const { root } = renderDialog(container); + await flush(); + + const modeChip = () => container.querySelector("[data-issue-work-mode-chip]"); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard"); + expect(dialogContentState.onEscapeKeyDown).not.toBeNull(); + + const commandPeriodAsEscape = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + metaKey: true, + }); + await act(async () => { + dialogContentState.onEscapeKeyDown?.(commandPeriodAsEscape); + }); + + expect(commandPeriodAsEscape.defaultPrevented).toBe(true); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + expect(dialogState.closeNewIssue).not.toHaveBeenCalled(); + + const plainEscape = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + }); + await act(async () => { + dialogContentState.onEscapeKeyDown?.(plainEscape); + }); + + expect(plainEscape.defaultPrevented).toBe(false); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + + const controlEscape = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: "Escape", + }); + await act(async () => { + dialogContentState.onEscapeKeyDown?.(controlEscape); + }); + + expect(controlEscape.defaultPrevented).toBe(false); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + + act(() => root.unmount()); + }); + + it("cycles work modes with ctrl-period", async () => { + const { root } = renderDialog(container); + await flush(); + + const modeChip = () => container.querySelector("[data-issue-work-mode-chip]"); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard"); + + await act(async () => { + modeChip()?.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + code: "Period", + key: ".", + ctrlKey: true, + })); + }); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + + act(() => root.unmount()); + }); + it("submits the parent assignee when a sub-issue opens with inherited defaults", async () => { dialogState.newIssueDefaults = { parentId: "issue-1", diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 60cbd4cfed..d2a6871bf1 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -297,6 +297,15 @@ function defaultExecutionWorkspaceModeForIssueDefaults( : defaultExecutionWorkspaceModeForProject(project); } +function isWorkModePeriodShortcut(e: Pick) { + const isPeriod = e.code === "Period" || e.key === "."; + return (e.metaKey || e.ctrlKey) && isPeriod; +} + +function isWorkModeEscapeShortcut(e: Pick) { + return e.metaKey && e.key === "Escape"; +} + const IssueTitleTextarea = memo(function IssueTitleTextarea({ value, pending, @@ -1035,7 +1044,7 @@ export function NewIssueDialog() { } function handleKeyDown(e: React.KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.code === "Period") { + if (isWorkModePeriodShortcut(e)) { e.preventDefault(); setWorkMode((current) => nextWorkMode(current)); return; @@ -1275,6 +1284,15 @@ export function NewIssueDialog() { )} onKeyDown={handleKeyDown} onEscapeKeyDown={(event) => { + if (event.defaultPrevented) return; + // iOS Safari maps command-period to Escape for hardware keyboards. + // Treat modifier-Escape as the same mode-cycle shortcut so the + // dialog does not dismiss before the shortcut can run. + if (isWorkModeEscapeShortcut(event)) { + event.preventDefault(); + setWorkMode((current) => nextWorkMode(current)); + return; + } if (createIssue.isPending) { event.preventDefault(); } diff --git a/ui/src/context/FileViewerContext.test.ts b/ui/src/context/FileViewerContext.test.ts index 5388497bcc..6a6ff19867 100644 --- a/ui/src/context/FileViewerContext.test.ts +++ b/ui/src/context/FileViewerContext.test.ts @@ -1,10 +1,11 @@ // @vitest-environment node -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { FILE_VIEWER_NAVIGATE_OPTIONS, readBrowseStateFromSearch, readFileViewerStateFromSearch, + shouldNavigateFileViewerSearch, writeBrowseStateToSearch, writeFolderViewerStateToSearch, writeFileViewerStateToSearch, @@ -17,6 +18,35 @@ describe("FILE_VIEWER_NAVIGATE_OPTIONS", () => { }); }); +describe("shouldNavigateFileViewerSearch", () => { + const originalWindow = globalThis.window; + + afterEach(() => { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + }); + }); + + it("uses the browser URL search when router state is stale", () => { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { search: "?browse=1" } }, + }); + + expect(shouldNavigateFileViewerSearch("", "")).toBe(true); + }); + + it("keeps no-op navigation suppressed when the browser URL already matches", () => { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { search: "" } }, + }); + + expect(shouldNavigateFileViewerSearch("", "?browse=1")).toBe(false); + }); +}); + describe("readFileViewerStateFromSearch", () => { it("returns null when no file param is present", () => { expect(readFileViewerStateFromSearch("")).toBeNull(); diff --git a/ui/src/context/FileViewerContext.tsx b/ui/src/context/FileViewerContext.tsx index 77ce2dac34..062b9959b7 100644 --- a/ui/src/context/FileViewerContext.tsx +++ b/ui/src/context/FileViewerContext.tsx @@ -53,6 +53,15 @@ export const FILE_VIEWER_NAVIGATE_OPTIONS = { preventScrollReset: true, } satisfies NavigateOptions; +export function getCurrentFileViewerSearch(fallbackSearch: string): string { + if (typeof window === "undefined") return fallbackSearch; + return window.location.search; +} + +export function shouldNavigateFileViewerSearch(nextSearch: string, fallbackSearch: string): boolean { + return nextSearch !== getCurrentFileViewerSearch(fallbackSearch); +} + export function readFileViewerStateFromSearch(search: string): FileViewerUrlState | null { const params = new URLSearchParams(search); const path = params.get("file"); @@ -198,7 +207,7 @@ function EnabledFileViewerProvider({ issueId, children }: Omit) => { - if (nextSearch === location.search) return; + if (!shouldNavigateFileViewerSearch(nextSearch, location.search)) return; navigate( { pathname: location.pathname, hash: location.hash, search: nextSearch }, { ...FILE_VIEWER_NAVIGATE_OPTIONS, ...opts, state: location.state }, @@ -285,9 +294,7 @@ function EnabledFileViewerProvider({ issueId, children }: Omit { - const currentSearch = typeof window === "undefined" - ? location.search - : (window.location.search || location.search); + const currentSearch = getCurrentFileViewerSearch(location.search); const params = new URLSearchParams(writeFileViewerStateToSearch(currentSearch, null).replace(/^\?/, "")); params.delete("browse"); params.delete("q"); diff --git a/ui/src/lib/navigation-scroll.test.ts b/ui/src/lib/navigation-scroll.test.ts index 8bd40f9ae2..4a30336a3c 100644 --- a/ui/src/lib/navigation-scroll.test.ts +++ b/ui/src/lib/navigation-scroll.test.ts @@ -41,6 +41,37 @@ describe("navigation-scroll", () => { ).toBe(false); }); + it("resets scroll when navigating into the top-level issues page", () => { + expect( + shouldResetScrollOnNavigation({ + previousPathname: "/issues/PAP-1389", + pathname: "/issues", + navigationType: "PUSH", + state: null, + }), + ).toBe(true); + + expect( + shouldResetScrollOnNavigation({ + previousPathname: "/PAP/issues/PAP-1389", + pathname: "/PAP/issues", + navigationType: "REPLACE", + state: null, + }), + ).toBe(true); + }); + + it("does not reset issues page scroll on browser history restoration", () => { + expect( + shouldResetScrollOnNavigation({ + previousPathname: "/issues/PAP-1389", + pathname: "/issues", + navigationType: "POP", + state: null, + }), + ).toBe(false); + }); + it("resets scroll when navigating directly between issue detail routes", () => { expect( shouldResetScrollOnNavigation({ diff --git a/ui/src/lib/navigation-scroll.ts b/ui/src/lib/navigation-scroll.ts index bbe2b61c57..0629507da1 100644 --- a/ui/src/lib/navigation-scroll.ts +++ b/ui/src/lib/navigation-scroll.ts @@ -14,6 +14,7 @@ export function shouldResetScrollOnNavigation(params: { if (previousPathname === null) return false; if (previousPathname === pathname) return false; if (navigationType === "POP") return false; + if (isIssueIndexPath(pathname)) return true; if (isIssueDetailPathChange(previousPathname, pathname)) return true; return hasSidebarScrollResetState(state); } @@ -75,6 +76,14 @@ function isIssueDetailPathChange(previousPathname: string, pathname: string): bo return previousIssueRef !== null && nextIssueRef !== null && previousIssueRef !== nextIssueRef; } +function isIssueIndexPath(pathname: string): boolean { + const segments = pathname.split("/").filter(Boolean); + return ( + (segments.length === 1 && segments[0] === "issues") + || (segments.length === 2 && segments[1] === "issues") + ); +} + function readIssueDetailPathRef(pathname: string): string | null { const segments = pathname.split("/").filter(Boolean); if (segments.length === 2 && segments[0] === "issues") { diff --git a/ui/storybook/stories/issue-thread-interactions.stories.tsx b/ui/storybook/stories/issue-thread-interactions.stories.tsx index c65bc78c66..9c2729d2c1 100644 --- a/ui/storybook/stories/issue-thread-interactions.stories.tsx +++ b/ui/storybook/stories/issue-thread-interactions.stories.tsx @@ -746,7 +746,7 @@ export const ReviewSurface: Story = {