Files
PaperClipAI/ui/src/context/FileViewerContext.test.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

224 lines
7.4 KiB
TypeScript

// @vitest-environment node
import { afterEach, describe, expect, it } from "vitest";
import {
FILE_VIEWER_NAVIGATE_OPTIONS,
readBrowseStateFromSearch,
readFileViewerStateFromSearch,
shouldNavigateFileViewerSearch,
writeBrowseStateToSearch,
writeFolderViewerStateToSearch,
writeFileViewerStateToSearch,
} from "./FileViewerContext";
describe("FILE_VIEWER_NAVIGATE_OPTIONS", () => {
it("preserves page scroll when the viewer updates URL search params", () => {
expect(FILE_VIEWER_NAVIGATE_OPTIONS.preventScrollReset).toBe(true);
expect(FILE_VIEWER_NAVIGATE_OPTIONS.replace).toBe(false);
});
});
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();
expect(readFileViewerStateFromSearch("?other=1")).toBeNull();
});
it("reads file, line, column, workspace from the search", () => {
const state = readFileViewerStateFromSearch("?file=ui/src/a.ts&line=42&column=3&workspace=project");
expect(state).toEqual({
path: "ui/src/a.ts",
line: 42,
column: 3,
workspace: "project",
projectId: null,
workspaceId: null,
});
});
it("defaults to auto workspace when param missing", () => {
const state = readFileViewerStateFromSearch("?file=ui/src/a.ts");
expect(state?.workspace).toBe("auto");
});
it("clamps invalid workspace to auto", () => {
const state = readFileViewerStateFromSearch("?file=ui/src/a.ts&workspace=bogus");
expect(state?.workspace).toBe("auto");
});
it("treats invalid line/column as null", () => {
const state = readFileViewerStateFromSearch("?file=x.ts&line=abc&column=-1");
expect(state?.line).toBeNull();
expect(state?.column).toBeNull();
});
});
describe("writeFileViewerStateToSearch", () => {
it("sets all params when opening", () => {
const next = writeFileViewerStateToSearch(
"?existing=1",
{
path: "ui/src/a.ts",
line: 42,
column: 3,
workspace: "project",
projectId: null,
workspaceId: null,
},
);
const params = new URLSearchParams(next);
expect(params.get("file")).toBe("ui/src/a.ts");
expect(params.get("line")).toBe("42");
expect(params.get("column")).toBe("3");
expect(params.get("workspace")).toBe("project");
expect(params.get("existing")).toBe("1");
});
it("omits workspace when auto", () => {
const next = writeFileViewerStateToSearch(
"",
{ path: "a.ts", line: null, column: null, workspace: "auto", projectId: null, workspaceId: null },
);
expect(next.includes("workspace")).toBe(false);
});
it("round-trips explicit target project workspace params", () => {
const targetPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md";
const next = writeFileViewerStateToSearch(
"?existing=1",
{
path: targetPath,
line: 7,
column: null,
workspace: "auto",
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
},
);
const state = readFileViewerStateFromSearch(next);
expect(state).toEqual({
path: targetPath,
line: 7,
column: null,
workspace: "auto",
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
});
});
it("clears viewer params when closing", () => {
const next = writeFileViewerStateToSearch(
"?file=a.ts&line=1&column=2&workspace=project&projectId=project-1&workspaceId=workspace-1&keep=yes",
null,
);
const params = new URLSearchParams(next);
expect(params.get("file")).toBeNull();
expect(params.get("line")).toBeNull();
expect(params.get("column")).toBeNull();
expect(params.get("workspace")).toBeNull();
expect(params.get("projectId")).toBeNull();
expect(params.get("workspaceId")).toBeNull();
expect(params.get("keep")).toBe("yes");
});
it("clears browse-origin viewer params when closing a selected file", () => {
const next = writeFileViewerStateToSearch(
"?tab=thread&browse=1&q=FileViewer&folder=ui/src&file=ui/src/FileViewer.tsx&line=4",
null,
);
const params = new URLSearchParams(next);
expect(params.get("file")).toBeNull();
expect(params.get("line")).toBeNull();
expect(params.get("browse")).toBeNull();
expect(params.get("q")).toBeNull();
expect(params.get("folder")).toBeNull();
expect(params.get("tab")).toBe("thread");
});
it("returns empty string when no params remain", () => {
const next = writeFileViewerStateToSearch("?file=a.ts", null);
expect(next).toBe("");
});
});
describe("folder browse search state", () => {
it("round-trips explicit target folder browse params", () => {
const targetPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/";
const next = writeFolderViewerStateToSearch("?tab=thread", {
path: targetPath,
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
});
expect(readFileViewerStateFromSearch(next)).toBeNull();
expect(readBrowseStateFromSearch(next)).toEqual({
q: null,
folderPath: targetPath,
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
});
});
it("updates browse params without closing the active file preview", () => {
const next = writeBrowseStateToSearch(
"?tab=thread&file=ui/src/components/FileViewerSheet.tsx&line=5&browse=1",
{
q: " FileViewerSheet ",
folderPath: "ui/src/components",
projectId: null,
workspaceId: null,
},
);
const params = new URLSearchParams(next);
expect(params.get("file")).toBe("ui/src/components/FileViewerSheet.tsx");
expect(params.get("line")).toBe("5");
expect(params.get("browse")).toBe("1");
expect(params.get("q")).toBe("FileViewerSheet");
expect(params.get("folder")).toBe("ui/src/components");
expect(params.get("tab")).toBe("thread");
});
it("clears browse params while preserving the file preview", () => {
const next = writeBrowseStateToSearch(
"?file=ui/src/components/FileViewerSheet.tsx&browse=1&q=FileViewerSheet&folder=ui/src/components",
{ q: null, folderPath: null },
);
const params = new URLSearchParams(next);
expect(params.get("file")).toBe("ui/src/components/FileViewerSheet.tsx");
expect(params.get("browse")).toBe("1");
expect(params.get("q")).toBeNull();
expect(params.get("folder")).toBeNull();
});
});