Files
PaperClipAI/ui/src/lib/navigation-scroll.ts
T
DottaandPaperclip bded6ac5c7 [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:

![Before: issue interaction card header with wake-policy
badge](https://gist.githubusercontent.com/cryppadotta/3e1030e3b379ffbbbcec4074d6cce4fa/raw/9bf44ee2bfea3a9930a68883f7f5ed2200613a51/issue-interaction-card-before.svg)

After removing the wake-policy badge:

![After: issue interaction card header without wake-policy
badge](https://gist.githubusercontent.com/cryppadotta/3e1030e3b379ffbbbcec4074d6cce4fa/raw/fa43b83f657833841e0e790bd851e4f57d4ded8e/issue-interaction-card-after.svg)

## 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>
2026-07-06 16:34:57 -05:00

97 lines
3.2 KiB
TypeScript

export type NavigationType = "POP" | "PUSH" | "REPLACE";
export const SIDEBAR_SCROLL_RESET_STATE = {
paperclipSidebarScrollReset: true,
} as const;
export function shouldResetScrollOnNavigation(params: {
previousPathname: string | null;
pathname: string;
navigationType: NavigationType;
state: unknown;
}): boolean {
const { previousPathname, pathname, navigationType, state } = 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);
}
// Remembers the `#main-content` scroll offset per browser-history entry so a
// back/forward (POP) navigation can be restored to where the user left off.
// `#main-content` is a single element that survives route changes, so without
// this the offset from the page we navigated away from (e.g. a deep
// issue-detail scroll) bleeds into the page we return to (e.g. the inbox).
export class NavigationScrollMemory {
private positions = new Map<string, number>();
remember(key: string, scrollTop: number): void {
this.positions.set(key, Math.max(0, scrollTop));
}
recall(key: string): number {
return this.positions.get(key) ?? 0;
}
}
export function applyMainContentScrollTop(mainElement: HTMLElement | null, scrollTop: number): void {
if (!mainElement) return;
mainElement.scrollTo?.({ top: scrollTop, left: 0, behavior: "auto" });
mainElement.scrollTop = scrollTop;
mainElement.scrollLeft = 0;
}
export function resetNavigationScroll(mainElement: HTMLElement | null): void {
mainElement?.scrollTo?.({ top: 0, left: 0, behavior: "auto" });
if (mainElement) {
mainElement.scrollTop = 0;
mainElement.scrollLeft = 0;
}
const scrollingElement = document.scrollingElement ?? document.documentElement;
if (scrollingElement) {
scrollingElement.scrollTop = 0;
scrollingElement.scrollLeft = 0;
}
if (document.body) {
document.body.scrollTop = 0;
document.body.scrollLeft = 0;
}
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}
function hasSidebarScrollResetState(state: unknown): boolean {
if (!state || typeof state !== "object") return false;
return (state as Record<string, unknown>).paperclipSidebarScrollReset === true;
}
function isIssueDetailPathChange(previousPathname: string, pathname: string): boolean {
const previousIssueRef = readIssueDetailPathRef(previousPathname);
const nextIssueRef = readIssueDetailPathRef(pathname);
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") {
return segments[1] ?? null;
}
if (segments.length === 3 && segments[1] === "issues") {
return segments[2] ?? null;
}
return null;
}