mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 10:06:42 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The web UI has a project detail page. A properties panel on that page lets a user edit the project name, description, and other fields. > - Each field shows a "Saving...", "Saved", or "Failed" indicator while an edit is in progress. > - The indicator was rendered next to the label text in a fixed 80px label column. The "Description" label almost fills that column, so the indicator spilled into the value column and covered the description text. > - A user cannot read the description while the indicator is visible. This looks broken and it hides content. > - This pull request stacks the indicator directly below the label text, so it stays inside the label column. > - The benefit is that the indicator never covers the field value, for the description and for every other labelled field. ## Linked Issues or Issue Description No public GitHub issue exists for this bug. The issue is described here. **What happened?** When a user edits a project description in the project properties panel, the "Saving..." and "Saved" indicator appears next to the "Description" label. The label column is 80px wide. The indicator does not fit, so it overflows into the value column and overlaps the description text. **Expected behavior** The "Saving..." / "Saved" / "Failed" indicator must appear directly below the "Description" label. It must not overlap the description text or any other field value. **Steps to reproduce** 1. Open a project in the Paperclip web UI. 2. Click the Description field in the properties panel and change the text. 3. Click outside the field to save. 4. Look at the "Description" label while the "Saving..." and then "Saved" indicator is visible. The indicator overlaps the description text. **Paperclip version or commit** master at `5edf55d7350c7f08c9dd132c7e0f1421fa0bf2fb`. **Deployment mode** Local development (`pnpm dev`). The bug is in the UI layout, so it applies to every deployment mode. ## What Changed - `ui/src/components/ProjectProperties.tsx`: `FieldLabel` now renders the label text and the `SaveIndicator` in a vertical flex column (`flex-col`) instead of a horizontal row. The indicator sits directly below the label and stays inside the 80px label column. This applies to every labelled property row (Name, Description, Env, and so on), so no label can overflow. - `ui/src/components/ProjectProperties.save-indicator.test.tsx`: new regression test. It asserts that the indicator is a stacked sibling under the Description label for the `saving` and `saved` states, and that no indicator renders for the `idle` state. ## Verification - Run `pnpm --filter @paperclipai/ui exec vitest run src/components/ProjectProperties` from the repo root. All ProjectProperties tests pass, including the new save-indicator test. - The new test fails against the previous inline layout (2 of 3 cases fail) and passes with this change (3 of 3 cases pass). - The existing `ProjectProperties.concurrency`, `ProjectProperties.managed-sandbox`, and `ProjectDetail` tests pass (18 tests). - `tsc -b` in `ui/` reports no errors in the changed files. - Manual check: open a project, edit the description, and save. The "Saving..." and "Saved" indicator now appears below the "Description" label and does not cover the description text. ## Risks - Low risk. The change is a single flex-direction swap on the label wrapper in one component. - Every labelled row in the project properties panel gets a slightly taller label cell while an indicator is visible. This is intentional and it matches the requested layout. - No data, API, or migration changes. ## Model Used - Claude Fable 5.1 (Anthropic), model id `claude-fable-5-1`, with extended thinking and tool use, run through Claude Code inside a Paperclip agent session. A human reviewed the change and the pull request text. ## 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 - [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 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Bender (Fable) <bender@paperclip.local> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
134 lines
5.1 KiB
TypeScript
134 lines
5.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { Project } from "@paperclipai/shared";
|
|
import type { ReactNode } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ProjectProperties, type ProjectFieldSaveState } from "./ProjectProperties";
|
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
|
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function act(callback: () => void) {
|
|
flushSync(() => {
|
|
callback();
|
|
});
|
|
}
|
|
|
|
const noop = vi.hoisted(() => () => undefined);
|
|
|
|
vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn() } }));
|
|
vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } }));
|
|
vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn().mockResolvedValue([]), listUserSecretDefinitions: vi.fn().mockResolvedValue([]), create: vi.fn() } }));
|
|
vi.mock("../api/environments", () => ({ environmentsApi: { list: vi.fn().mockResolvedValue([]) } }));
|
|
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn().mockResolvedValue({ enableIsolatedWorkspaces: true }) } }));
|
|
|
|
vi.mock("../context/CompanyContext", () => ({
|
|
useCompany: () => ({ companies: [{ id: "company-1", issuePrefix: "PAP" }], selectedCompanyId: "company-1", setSelectedCompanyId: vi.fn() }),
|
|
}));
|
|
|
|
// Heavy children that are unrelated to the save indicator.
|
|
vi.mock("./environment-variables-editor", () => ({ EnvironmentVariablesEditor: () => null }));
|
|
vi.mock("./InlineEditor", () => ({ InlineEditor: ({ value }: { value?: ReactNode }) => <div>{value}</div> }));
|
|
vi.mock("./PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
|
|
|
|
function makeProject(overrides: Partial<Project> = {}): Project {
|
|
return {
|
|
id: "project-1",
|
|
urlKey: "project-1",
|
|
name: "Test project",
|
|
description: "A long description that fills the value column",
|
|
status: "in_progress",
|
|
goalIds: [],
|
|
goals: [],
|
|
env: null,
|
|
codebase: { workspaceId: null, repoUrl: null, repoRef: null, defaultRef: null, repoName: null },
|
|
primaryWorkspace: null,
|
|
workspaces: [],
|
|
executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", allowIssueOverride: true },
|
|
...overrides,
|
|
} as unknown as Project;
|
|
}
|
|
|
|
function primedClient() {
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
client.setQueryData(queryKeys.health, { hiddenSettings: [] });
|
|
client.setQueryData(queryKeys.instance.experimentalSettings, { enableIsolatedWorkspaces: true });
|
|
return client;
|
|
}
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function render(getFieldSaveState: (field: string) => ProjectFieldSaveState) {
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={primedClient()}>
|
|
<TooltipProvider>
|
|
<ProjectProperties
|
|
project={makeProject()}
|
|
onFieldUpdate={noop}
|
|
getFieldSaveState={getFieldSaveState}
|
|
onArchive={noop}
|
|
/>
|
|
</TooltipProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
}
|
|
|
|
function labelSpan(text: string): HTMLSpanElement {
|
|
const span = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === text);
|
|
if (!span) throw new Error(`Label "${text}" not rendered`);
|
|
return span;
|
|
}
|
|
|
|
describe("ProjectProperties save indicator placement", () => {
|
|
it("stacks the Saving indicator below the Description label instead of beside it", () => {
|
|
render((field) => (field === "description" ? "saving" : "idle"));
|
|
|
|
const label = labelSpan("Description");
|
|
const wrapper = label.parentElement;
|
|
expect(wrapper).not.toBeNull();
|
|
// The label column is a fixed 80px wide; laying the indicator out inline
|
|
// overflows into the description text. It must be a stacked column.
|
|
expect(wrapper!.className).toContain("flex-col");
|
|
expect(wrapper!.className).not.toContain("items-center");
|
|
|
|
const indicator = label.nextElementSibling;
|
|
expect(indicator).not.toBeNull();
|
|
expect(indicator!.textContent).toContain("Saving");
|
|
expect(wrapper!.contains(indicator!)).toBe(true);
|
|
});
|
|
|
|
it("renders the Saved indicator in the same stacked position once the save completes", () => {
|
|
render((field) => (field === "description" ? "saved" : "idle"));
|
|
|
|
const label = labelSpan("Description");
|
|
expect(label.parentElement!.className).toContain("flex-col");
|
|
expect(label.nextElementSibling?.textContent).toContain("Saved");
|
|
});
|
|
|
|
it("renders no indicator when the field is idle", () => {
|
|
render(() => "idle");
|
|
|
|
const label = labelSpan("Description");
|
|
expect(label.nextElementSibling).toBeNull();
|
|
});
|
|
});
|