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 = {