[codex] Polish operator issue workflow UI (#9091)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board UI is where operators create issues, inspect issue-thread decisions, write markdown, and move between issue workflow surfaces. > - A broad recovered branch mixed these operator workflow polish fixes with unrelated backend, pipeline, plugin, eval, and work-product changes. > - Reviewers need the operator UI workflow fixes in a small PR that can be understood without pulling in the rest of that recovery branch. > - This pull request extracts only the issue workflow UI slice: interaction cards, markdown editing fallback behavior, create-issue work mode shortcuts, file-viewer URL handling, and issue navigation scroll behavior. > - The benefit is a lower-risk review path for operator workflow fixes while keeping unrelated execution-workspace and server work out of this PR. ## Linked Issues or Issue Description - Refs #8866, the closed broad source PR that this focused slice was extracted from. - Related: #8228 is an open UI polish PR, but its file list does not overlap this branch. - Related: #4090 is older merged operator workflow polish context. No public GitHub issue exists for this exact extracted slice. The problem is that several small operator issue-workflow fixes were bundled inside a broad recovery branch, making them hard to review and ship independently. ## What Changed - Removed interaction continuation wake-policy labels from issue-thread interaction card headers and updated the card test/story copy accordingly. - Added a markdown editor error boundary so rich editor render crashes fall back to a raw textarea instead of breaking the compose surface. - Made the create-issue work-mode shortcut accept ctrl-period and iOS hardware-keyboard command-period-as-Escape behavior without dismissing the dialog. - Fixed file-viewer URL navigation to compare against the live browser search string when router state is stale. - Restored scroll reset when navigating from an issue detail route back to the issue index, while preserving browser-history restoration behavior. ## Screenshots Before removing the continuation wake-policy badge from interaction card headers:  After removing the wake-policy badge:  ## Verification - `pnpm exec vitest run ui/src/components/IssueThreadInteractionCard.test.tsx ui/src/components/MarkdownEditor.test.tsx ui/src/components/NewIssueDialog.test.tsx ui/src/context/FileViewerContext.test.ts ui/src/lib/navigation-scroll.test.ts` — 5 files, 102 tests passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `git diff --check` — passed. ## Risks Low risk. The PR is limited to UI workflow components/helpers and their tests. Main behavioral risks are keyboard shortcut edge cases across browsers and fallback editor rendering, both covered by focused tests. ## Model Used OpenAI Codex, GPT-5-based coding agent (`gpt-5` family; exact served variant and context window are not exposed in this runtime), with terminal, Git, GitHub, and test execution tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [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
ef617bee5c
commit
bded6ac5c7
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<span className="text-current/60">/</span>
|
||||
{planStyles ? planStyles.label : statusLabel(interaction.status)}
|
||||
</span>
|
||||
{interaction.continuationPolicy === "wake_assignee"
|
||||
|| interaction.continuationPolicy === "wake_assignee_on_accept" ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-sm border border-border/70 bg-transparent px-2.5 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-foreground/70">
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
{interaction.continuationPolicy === "wake_assignee_on_accept"
|
||||
? "Wakes on confirm"
|
||||
: "Wakes responsible"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-lg font-bold text-foreground">
|
||||
|
|
|
|||
|
|
@ -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<void>) {
|
||||
let result: void | Promise<void> = 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(
|
||||
<MarkdownEditor
|
||||
value="5. python3 circleback/sync_insights.py --input <tmp> -- writes insights/<group>/*.md"
|
||||
onChange={handleChange}
|
||||
placeholder="Markdown body"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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 <tmp> -- writes insights/<group>/*.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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MarkdownEditorRef, MarkdownEditorProps>
|
|||
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<MarkdownEditorRef, MarkdownEditorProps>
|
|||
}}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
<MDXEditor
|
||||
ref={setEditorRef}
|
||||
markdown={editorValue}
|
||||
suppressHtmlProcessing
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
onChange={(next) => {
|
||||
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);
|
||||
<MarkdownEditorRichErrorBoundary onError={handleRichEditorError}>
|
||||
<MDXEditor
|
||||
ref={setEditorRef}
|
||||
markdown={editorValue}
|
||||
suppressHtmlProcessing
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
onChange={(next) => {
|
||||
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}
|
||||
/>
|
||||
</MarkdownEditorRichErrorBoundary>
|
||||
|
||||
{/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */}
|
||||
{mentionActive && filteredMentions.length > 0 && mentionMenuPosition &&
|
||||
|
|
|
|||
|
|
@ -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 <div {...props}>{children}</div>;
|
||||
},
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -297,6 +297,15 @@ function defaultExecutionWorkspaceModeForIssueDefaults(
|
|||
: defaultExecutionWorkspaceModeForProject(project);
|
||||
}
|
||||
|
||||
function isWorkModePeriodShortcut(e: Pick<React.KeyboardEvent, "code" | "ctrlKey" | "key" | "metaKey">) {
|
||||
const isPeriod = e.code === "Period" || e.key === ".";
|
||||
return (e.metaKey || e.ctrlKey) && isPeriod;
|
||||
}
|
||||
|
||||
function isWorkModeEscapeShortcut(e: Pick<KeyboardEvent, "key" | "metaKey">) {
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<FileViewerProvide
|
|||
|
||||
const navigateSearch = useCallback(
|
||||
(nextSearch: string, opts?: Partial<NavigateOptions>) => {
|
||||
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<FileViewerProvide
|
|||
}, [location.search, navigateSearch]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -746,7 +746,7 @@ export const ReviewSurface: Story = {
|
|||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ScenarioCard
|
||||
title="Pending"
|
||||
description="Answers stay local across the whole form and only wake the assignee once after final submit."
|
||||
description="Answers stay local across the whole form until the operator submits the final response."
|
||||
>
|
||||
<InteractiveAskUserQuestionsCard />
|
||||
</ScenarioCard>
|
||||
|
|
|
|||
Loading…
Reference in New Issue