mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - That operator experience depends not just on issue chat, but also on how workspaces, inbox groups, and navigation state behave over long-running sessions > - The current branch included a separate cluster of workspace-runtime controls, inbox grouping, sidebar ordering, and worktree lifecycle fixes > - Those changes cross server, shared contracts, database state, and UI navigation, but they still form one coherent operator workflow area > - This pull request isolates the workspace/runtime and navigation ergonomics work into one standalone branch > - The benefit is better workspace recovery and navigation persistence without forcing reviewers through the unrelated issue-detail/chat work ## What Changed - Improved execution workspace and project workspace controls, request wiring, layout, and JSON editor ergonomics - Hardened linked worktree reuse/startup behavior and documented the `worktree repair` flow for recovering linked worktrees safely - Added inbox workspace grouping, mobile collapse, archive undo, keyboard navigation, shared group-header styling, and persisted collapsed-group behavior - Added persistent sidebar order preferences with the supporting DB migration, shared/server contracts, routes, services, hooks, and UI integration - Scoped issue-list preferences by context and added targeted UI/server tests for workspace controls, inbox behavior, sidebar preferences, and worktree validation ## Verification - `pnpm vitest run server/src/__tests__/sidebar-preferences-routes.test.ts ui/src/pages/Inbox.test.tsx ui/src/components/ProjectWorkspaceSummaryCard.test.tsx ui/src/components/WorkspaceRuntimeControls.test.tsx ui/src/api/workspace-runtime-control.test.ts` - `server/src/__tests__/workspace-runtime.test.ts` was attempted, but the embedded Postgres suite self-skipped/hung on this host after reporting an init-script issue, so it is not counted as a local pass here ## Risks - Medium: this branch includes migration-backed preference storage plus worktree/runtime behavior, so merge review should pay attention to state persistence and worktree recovery semantics - The sidebar preference migration is standalone, but it should still be watched for conflicts if another migration lands first ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## 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) - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
296 lines
8 KiB
TypeScript
296 lines
8 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "react";
|
|
import type { ComponentProps } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import type { Issue } from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { FailedRunInboxRow, InboxGroupHeader, InboxIssueMetaLeading, InboxIssueTrailingColumns } from "./Inbox";
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
|
|
<a className={className} {...props}>{children}</a>
|
|
),
|
|
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
|
|
useNavigate: () => () => {},
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
|
return {
|
|
id: "issue-1",
|
|
identifier: "PAP-904",
|
|
companyId: "company-1",
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
goalId: null,
|
|
parentId: null,
|
|
title: "Inbox item",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: null,
|
|
assigneeUserId: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
issueNumber: 904,
|
|
requestDepth: 0,
|
|
billingCode: null,
|
|
assigneeAdapterOverrides: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
executionWorkspaceSettings: null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
startedAt: null,
|
|
completedAt: null,
|
|
cancelledAt: null,
|
|
hiddenAt: null,
|
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
labels: [],
|
|
labelIds: [],
|
|
myLastTouchAt: null,
|
|
lastExternalCommentAt: null,
|
|
lastActivityAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
isUnreadForMe: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("FailedRunInboxRow", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
});
|
|
|
|
it("suppresses accent hover styling when selected", () => {
|
|
const root = createRoot(container);
|
|
const run = {
|
|
id: "run-1",
|
|
companyId: "company-1",
|
|
agentId: "agent-1",
|
|
invocationSource: "assignment",
|
|
triggerDetail: null,
|
|
status: "failed",
|
|
error: "boom",
|
|
wakeupRequestId: null,
|
|
exitCode: null,
|
|
signal: null,
|
|
usageJson: null,
|
|
resultJson: null,
|
|
sessionIdBefore: null,
|
|
sessionIdAfter: null,
|
|
logStore: null,
|
|
logRef: null,
|
|
logBytes: null,
|
|
logSha256: null,
|
|
logCompressed: false,
|
|
errorCode: null,
|
|
externalRunId: null,
|
|
processPid: null,
|
|
processGroupId: null,
|
|
processStartedAt: null,
|
|
retryOfRunId: null,
|
|
processLossRetryCount: 0,
|
|
stdoutExcerpt: null,
|
|
stderrExcerpt: null,
|
|
contextSnapshot: null,
|
|
startedAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
finishedAt: null,
|
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
} as const;
|
|
|
|
act(() => {
|
|
root.render(
|
|
<FailedRunInboxRow
|
|
run={run}
|
|
issueById={new Map()}
|
|
agentName="Agent"
|
|
issueLinkState={null}
|
|
onDismiss={() => {}}
|
|
onRetry={() => {}}
|
|
isRetrying={false}
|
|
selected
|
|
/>,
|
|
);
|
|
});
|
|
|
|
const link = container.querySelector("a");
|
|
expect(link).not.toBeNull();
|
|
expect(link?.className).toContain("hover:bg-transparent");
|
|
expect(link?.className).not.toContain("hover:bg-accent/50");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("InboxIssueMetaLeading", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
});
|
|
|
|
it("keeps status and live accents visible", () => {
|
|
const root = createRoot(container);
|
|
|
|
act(() => {
|
|
root.render(<InboxIssueMetaLeading issue={createIssue()} isLive />);
|
|
});
|
|
|
|
const statusIcon = container.querySelector('span[class*="border-blue-600"]');
|
|
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-blue-500/10"]');
|
|
const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find(
|
|
(node) => node.textContent === "Live" && node.className.includes("text-"),
|
|
);
|
|
const liveDot = container.querySelector('span[class*="bg-blue-500"]');
|
|
const pulseRing = container.querySelector('span[class*="animate-pulse"]');
|
|
|
|
expect(statusIcon).not.toBeNull();
|
|
expect(statusIcon?.className).not.toContain("!border-muted-foreground");
|
|
expect(statusIcon?.className).not.toContain("!text-muted-foreground");
|
|
expect(liveBadge).not.toBeNull();
|
|
expect(liveBadge?.className).toContain("bg-blue-500/10");
|
|
expect(liveBadgeLabel).not.toBeNull();
|
|
expect(liveBadgeLabel?.className).toContain("text-blue-600");
|
|
expect(liveDot).not.toBeNull();
|
|
expect(pulseRing).not.toBeNull();
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("InboxIssueTrailingColumns", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
});
|
|
|
|
it("renders an empty tags cell when an issue has no labels", () => {
|
|
const root = createRoot(container);
|
|
|
|
act(() => {
|
|
root.render(
|
|
<InboxIssueTrailingColumns
|
|
issue={createIssue({ labels: [], labelIds: [] })}
|
|
columns={["labels"]}
|
|
projectName={null}
|
|
projectColor={null}
|
|
workspaceName={null}
|
|
assigneeName={null}
|
|
currentUserId={null}
|
|
parentIdentifier={null}
|
|
parentTitle={null}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toBe("");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("leaves the workspace cell blank when no explicit workspace label should be shown", () => {
|
|
const root = createRoot(container);
|
|
|
|
act(() => {
|
|
root.render(
|
|
<InboxIssueTrailingColumns
|
|
issue={createIssue()}
|
|
columns={["workspace"]}
|
|
projectName={null}
|
|
projectColor={null}
|
|
workspaceName={null}
|
|
assigneeName={null}
|
|
currentUserId={null}
|
|
parentIdentifier={null}
|
|
parentTitle={null}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toBe("");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("InboxGroupHeader", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
});
|
|
|
|
it("shows a left caret and expanded state for collapsible mobile headers", () => {
|
|
const root = createRoot(container);
|
|
|
|
act(() => {
|
|
root.render(<InboxGroupHeader label="Primary workspace (default)" collapsible collapsed={false} />);
|
|
});
|
|
|
|
const button = container.querySelector("button");
|
|
expect(button).not.toBeNull();
|
|
expect(button?.getAttribute("aria-expanded")).toBe("true");
|
|
expect(button?.textContent).toContain("Primary workspace (default)");
|
|
const caret = container.querySelector("svg");
|
|
expect(caret?.className.baseVal).toContain("rotate-90");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps the caret collapsed when the mobile group is hidden", () => {
|
|
const root = createRoot(container);
|
|
|
|
act(() => {
|
|
root.render(<InboxGroupHeader label="Feature Branch" collapsible collapsed />);
|
|
});
|
|
|
|
const button = container.querySelector("button");
|
|
expect(button?.getAttribute("aria-expanded")).toBe("false");
|
|
const caret = container.querySelector("svg");
|
|
expect(caret?.className.baseVal).not.toContain("rotate-90");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|