mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-16 15:15:35 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents ask humans for decisions through approval cards, and a chat gateway plugin can forward those cards to Slack with an "Open task" button > - The button opens the bare task page; to read the document under approval, the reviewer must click four more times (open the side pane, open the Artifacts tab, open the artifact, maximize the pane) > - Approvals are the highest-frequency human touchpoint, so each removed click matters > - This pull request adds a `viewer=full` option to the existing `#document-<key>` deep link; the link now opens the target document and maximizes the side pane > - The benefit is one-click access from an external notification to a full-size reading surface for the document under approval ## Linked Issues or Issue Description **What existing behavior does this improve?** The issue page already supports `#document-<key>` deep links. They open the document in the side pane, but at the pane's default width. **Current behavior** An external link cannot request the maximized (full-size) document view. A reviewer who follows an approval notification must maximize the pane by hand each time. **Proposed behavior** `#document-<key>&viewer=full` opens the document and maximizes the side pane. Plan documents open in the Plan tab, maximized. Mobile keeps the full-screen sheet. Unknown `viewer` values are ignored, so old links and new links stay compatible in both directions. **Reason and benefit** Chat notifications about approvals can now land the reviewer directly on a full-size view of the document they must read. This removes four clicks from every approval review. **Breaking changes** None. The parameter is optional and additive. Links without it keep today's behavior. ## What Changed - `ui/src/lib/document-annotation-hash.ts`: parse and build an optional `viewer=full` parameter in document hashes. - `ui/src/lib/issue-document-deep-link.ts`: thread a `maximize` flag on properties-pane routes; the continuation-summary route is unchanged. - `ui/src/context/PanelContext.tsx`: add a one-shot panel maximize request (`requestPanelMaximize` / `clearPanelMaximizeRequest`). - `ui/src/components/PropertiesPanel.tsx`: the resizable panel host consumes a pending request once it is visible and laid out, then clears it. - `ui/src/pages/IssueDetail.tsx`: request the maximize on the desktop deep-link path only; mobile keeps the sheet. - Tests for all of the above. ## Verification - `cd ui && pnpm typecheck` — clean. - `cd ui && pnpm vitest run src/lib/document-annotation-hash.test.ts src/lib/issue-document-deep-link.test.ts src/components/PropertiesPanel.test.tsx` — 31/31 green. - New cases cover: `viewer` parse/build round trip, unknown values ignored, maximize routing for document and plan tabs, a pending request consumed on mount, and a request held while the panel is hidden. - Manual check: open an issue with `#document-<key>&viewer=full` in the URL; the pane opens on that document, maximized. Remove the parameter; the pane opens at its normal width. ## Risks - Low risk. The parameter is optional; no data, schema, or API changes. - The maximize request lives in React context as a one-shot flag. It is cleared on first consumption, so a stale request cannot re-maximize the pane on later navigations. - If a link carries `viewer=full` on a web build older than this change, the parameter is ignored and the document still opens. ## Model Used - Claude Fable 5 (`claude-fable-5`), Anthropic. Agentic coding session with extended thinking and tool use (file edits, shell, test runs). ## 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) - [ ] 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 - [ ] 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
252 lines
9.7 KiB
TypeScript
252 lines
9.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
import { PropertiesPanel } from "./PropertiesPanel";
|
|
|
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|
getExperimental: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/api/instanceSettings", () => ({
|
|
instanceSettingsApi: mockInstanceSettingsApi,
|
|
}));
|
|
|
|
const mockPanelState = vi.hoisted(() => ({
|
|
panelContent: null as unknown,
|
|
panelContentMode: "padded" as const,
|
|
panelVisible: true,
|
|
panelMaximizeRequested: false,
|
|
}));
|
|
const mockSetPanelVisible = vi.hoisted(() => vi.fn());
|
|
const mockClearPanelMaximizeRequest = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../context/PanelContext", () => ({
|
|
usePanel: () => ({
|
|
panelContent: mockPanelState.panelContent,
|
|
panelContentMode: mockPanelState.panelContentMode,
|
|
panelVisible: mockPanelState.panelVisible,
|
|
openPanel: vi.fn(),
|
|
closePanel: vi.fn(),
|
|
setPanelVisible: mockSetPanelVisible,
|
|
togglePanelVisible: vi.fn(),
|
|
panelMaximizeRequested: mockPanelState.panelMaximizeRequested,
|
|
requestPanelMaximize: vi.fn(),
|
|
clearPanelMaximizeRequest: mockClearPanelMaximizeRequest,
|
|
}),
|
|
}));
|
|
|
|
async function flushReact() {
|
|
for (let index = 0; index < 5; index += 1) {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
}
|
|
flushSync(() => {});
|
|
}
|
|
|
|
describe("PropertiesPanel", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
let originalInnerWidth: number;
|
|
|
|
async function renderPanel({
|
|
panelVisible = true,
|
|
taskDetailLayout = false,
|
|
}: { panelVisible?: boolean; taskDetailLayout?: boolean } = {}) {
|
|
mockPanelState.panelContent = <div data-testid="panel-content">content</div>;
|
|
mockPanelState.panelVisible = panelVisible;
|
|
root = createRoot(container);
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
flushSync(() => {
|
|
root!.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<TooltipProvider>
|
|
<PropertiesPanel taskDetailLayout={taskDetailLayout} />
|
|
</TooltipProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
originalInnerWidth = window.innerWidth;
|
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1440 });
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
window.localStorage.clear();
|
|
mockSetPanelVisible.mockClear();
|
|
mockClearPanelMaximizeRequest.mockClear();
|
|
mockPanelState.panelMaximizeRequested = false;
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => {
|
|
root?.unmount();
|
|
});
|
|
root = null;
|
|
container.remove();
|
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("classic task interface on (legacy panel)", () => {
|
|
beforeEach(() => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableStreamlinedUi: true,
|
|
enableClassicTaskInterface: true,
|
|
});
|
|
});
|
|
|
|
it("renders the fixed-width panel with no grip and no maximize button", async () => {
|
|
await renderPanel();
|
|
const aside = container.querySelector("aside");
|
|
expect(aside).not.toBeNull();
|
|
expect(aside!.style.width).toBe("320px");
|
|
expect(aside!.querySelector('[role="separator"]')).toBeNull();
|
|
expect(container.querySelector('[aria-label="Maximize side panel"]')).toBeNull();
|
|
// Inner wrapper keeps the hardcoded width classes exactly as today.
|
|
expect(aside!.querySelector(".w-80")).not.toBeNull();
|
|
});
|
|
|
|
it("collapses to width 0 when the panel is hidden", async () => {
|
|
await renderPanel({ panelVisible: false });
|
|
const aside = container.querySelector("aside");
|
|
expect(aside!.style.width).toBe("0px");
|
|
expect(aside!.style.opacity).toBe("0");
|
|
});
|
|
});
|
|
|
|
describe("classic task interface off (default resizable pane)", () => {
|
|
beforeEach(() => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableStreamlinedUi: true,
|
|
enableClassicTaskInterface: false,
|
|
});
|
|
});
|
|
|
|
it("renders the Paper task-detail rail width with a drag grip and a maximize button", async () => {
|
|
await renderPanel({ taskDetailLayout: true });
|
|
const aside = container.querySelector("aside");
|
|
expect(aside).not.toBeNull();
|
|
expect(aside!.style.width).toBe("434px");
|
|
expect(aside!.querySelector('[role="separator"][aria-label="Resize panel"]')).not.toBeNull();
|
|
expect(container.querySelector('[aria-label="Maximize side panel"]')).not.toBeNull();
|
|
const inner = aside!.querySelector<HTMLDivElement>(":scope > div:not([role])");
|
|
expect(inner!.style.width).toBe("434px");
|
|
expect(inner!.style.minWidth).toBe("434px");
|
|
expect(aside!.querySelector("header")?.className).toContain(
|
|
"h-(--side-panel-header-height)",
|
|
);
|
|
});
|
|
|
|
it("removes the left divider only while the sidebar is maximized", async () => {
|
|
await renderPanel({ taskDetailLayout: true });
|
|
const aside = container.querySelector("aside")!;
|
|
const maximize = container.querySelector<HTMLButtonElement>(
|
|
'[aria-label="Maximize side panel"]',
|
|
)!;
|
|
|
|
expect(aside.className).toContain("border-l");
|
|
maximize.click();
|
|
await flushReact();
|
|
|
|
expect(aside.className).not.toContain("border-l");
|
|
expect(container.querySelector("section")?.getAttribute("data-maximized")).toBe("true");
|
|
});
|
|
|
|
it("consumes a pending deep-link maximize request on mount (LOOA-2181)", async () => {
|
|
mockPanelState.panelMaximizeRequested = true;
|
|
await renderPanel({ taskDetailLayout: true });
|
|
expect(mockClearPanelMaximizeRequest).toHaveBeenCalled();
|
|
expect(container.querySelector("section")?.getAttribute("data-maximized")).toBe("true");
|
|
});
|
|
|
|
it("holds a deep-link maximize request while the panel is hidden", async () => {
|
|
mockPanelState.panelMaximizeRequested = true;
|
|
await renderPanel({ panelVisible: false });
|
|
expect(mockClearPanelMaximizeRequest).not.toHaveBeenCalled();
|
|
expect(container.querySelector("section")?.getAttribute("data-maximized")).not.toBe("true");
|
|
});
|
|
|
|
it("uses an X to close the Streamlined task-detail sidebar", async () => {
|
|
await renderPanel({ taskDetailLayout: true });
|
|
const close = container.querySelector<HTMLButtonElement>('[aria-label="Close side panel"]');
|
|
expect(close).not.toBeNull();
|
|
expect(close!.querySelector(".lucide-x")).not.toBeNull();
|
|
expect(container.querySelector('[aria-label="Toggle side panel"]')).toBeNull();
|
|
|
|
close!.click();
|
|
expect(mockSetPanelVisible).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("keeps the production toggle control outside Streamlined task detail", async () => {
|
|
await renderPanel();
|
|
const toggle = container.querySelector<HTMLButtonElement>('[aria-label="Toggle side panel"]');
|
|
expect(toggle).not.toBeNull();
|
|
expect(toggle!.getAttribute("aria-pressed")).toBe("true");
|
|
expect(toggle!.querySelector(".lucide-panel-right-close")).not.toBeNull();
|
|
expect(container.querySelector('[aria-label="Close side panel"]')).toBeNull();
|
|
});
|
|
|
|
it("restores a remembered width from localStorage (clamped to the minimum)", async () => {
|
|
window.localStorage.setItem("taskChatRedesign.propertiesPaneWidth", "300");
|
|
await renderPanel();
|
|
expect(container.querySelector("aside")!.style.width).toBe("300px");
|
|
});
|
|
|
|
it("clamps a stored width below the 260px minimum", async () => {
|
|
window.localStorage.setItem("taskChatRedesign.propertiesPaneWidth", "100");
|
|
await renderPanel();
|
|
expect(container.querySelector("aside")!.style.width).toBe("260px");
|
|
});
|
|
|
|
it("falls back to the default width when the stored value is garbage", async () => {
|
|
window.localStorage.setItem("taskChatRedesign.propertiesPaneWidth", "not-a-number");
|
|
await renderPanel();
|
|
expect(container.querySelector("aside")!.style.width).toBe("322px");
|
|
});
|
|
|
|
it("keeps the collapse-to-0 behavior when the panel is hidden", async () => {
|
|
await renderPanel({ panelVisible: false });
|
|
const aside = container.querySelector("aside");
|
|
expect(aside!.style.width).toBe("0px");
|
|
expect(aside!.style.opacity).toBe("0");
|
|
// No grip while hidden.
|
|
expect(aside!.querySelector('[role="separator"]')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Streamlined UI off", () => {
|
|
beforeEach(() => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableStreamlinedUi: false,
|
|
enableClassicTaskInterface: false,
|
|
});
|
|
});
|
|
|
|
it("restores master's default resizable panel rather than the classic panel", async () => {
|
|
await renderPanel({ taskDetailLayout: true });
|
|
const aside = container.querySelector("aside");
|
|
expect(aside).not.toBeNull();
|
|
expect(aside!.style.width).toBe("322px");
|
|
expect(aside!.querySelector('[role="separator"][aria-label="Resize panel"]')).not.toBeNull();
|
|
expect(container.querySelector('[aria-label="Maximize side panel"]')).not.toBeNull();
|
|
});
|
|
|
|
it("still honors the independent Classic Task Interface preference", async () => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableStreamlinedUi: false,
|
|
enableClassicTaskInterface: true,
|
|
});
|
|
await renderPanel({ taskDetailLayout: true });
|
|
const aside = container.querySelector("aside");
|
|
expect(aside!.style.width).toBe("320px");
|
|
expect(aside!.querySelector('[role="separator"]')).toBeNull();
|
|
});
|
|
});
|
|
});
|