mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage agents at
work.
> - The task thread shows human messages and agent replies.
> - Users can copy agent replies with an existing control.
> - Users need the same control for their own messages.
> - This pull request adds that control beside the message time.
> - Users can copy the original Markdown and attachment links.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Copy a message from the task thread.
**Subsystem affected**
ui/ — React board UI.
**Current behavior**
Your own messages have no copy control beside the time.
**Proposed behavior**
Move the pointer over the time to show a copy icon. Select it to copy
the original Markdown. Keyboard focus also shows the icon. Touch screens
show it at all times.
**Reason and benefit**
Users can reuse their message text without losing Markdown or attachment
links.
**Breaking changes**
None.
## What Changed
- Reuse TaskChatBubbleActions in the footer of your sent messages.
- Keep the timestamp and iMessage source label.
- Show the control on hover or keyboard focus. Keep it visible on touch
screens and small screens.
- Respect the reduced motion setting.
- Test original Markdown, attachment links, copy feedback, other
authors, and pending messages.
- Add the required membership role to the existing routine-user test
fixture so UI typecheck can pass.
- Keep this behavior in TaskChatBubble. No shared contract change is
needed.
## Verification
- 45 focused component, artifact, and clipboard tests pass after rebase
onto master.
- The routine-user fixture tests pass: 9 tests.
- UI typecheck, UI production build, design-token gates, and browser
checks pass.
- Five screenshots cover idle, hover, copied feedback, keyboard focus,
and touch states. The existing Storybook own-message story uses the
production component with sample content.
- Browser checks verify clipboard contents and reduced motion.
- GitHub PR CI passed for d46d2890c4:
build, typecheck, selected general tests, all eight e2e shards, policy,
security, and aggregate verification. Storybook visual and out-of-scope
suites were skipped by CI.
- Greptile and Paperclip reviews both scored the current commit 5/5 with
no actionable findings. No open review threads.
- Full repository typecheck and build stop because Cargo is missing in
the local environment. Earlier full tests reached resource limits. No
full-suite pass is claimed.
- The first typecheck after rebase found stale local dependencies and a
missing membershipRole in a routine-user test fixture. A frozen
dependency install resolved the package errors. This PR adds the
required fixture field and the later UI typecheck passes.
- Browser states to verify: idle, hover, keyboard focus, copied
feedback, touch, and reduced motion.
## Risks
- Low risk. The change adds a footer control for sent messages owned by
the viewer.
- The control uses the existing clipboard fallback and error feedback.
## Model Used
- OpenAI GPT-6 Codex. Exact deployed model ID and context window are not
exposed in this runtime. The agent used reasoning, tools, and code
execution.
## 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
- [x] My branch name describes the change and contains no internal
ticket id or instance details
- [x] I have run focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (the
behavior is described here)
- [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>
148 lines
7.7 KiB
TypeScript
148 lines
7.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
|
import type { RoutineDetail } from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { accessApi } from "@/api/access";
|
|
import { ApiError } from "@/api/client";
|
|
import { routinesApi } from "@/api/routines";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { RoutineResponsibleUser } from "./RoutineResponsibleUser";
|
|
|
|
const routine = {
|
|
id: "routine-1", companyId: "company-1", responsibleUserId: "user-alex",
|
|
latestRevisionId: "revision-1", canChangeResponsibleUser: true,
|
|
} as RoutineDetail;
|
|
const users = ["Alex", "Sam"].map((name) => ({
|
|
principalId: `user-${name.toLowerCase()}`, status: "active" as const, membershipRole: "member" as const,
|
|
user: { id: `user-${name.toLowerCase()}`, name, email: `${name.toLowerCase()}@example.test`, image: null },
|
|
}));
|
|
|
|
describe("RoutineResponsibleUser", () => {
|
|
let root: Root;
|
|
let container: HTMLDivElement;
|
|
let client: QueryClient;
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} });
|
|
HTMLElement.prototype.scrollIntoView = vi.fn();
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
|
vi.spyOn(accessApi, "listUserDirectory").mockResolvedValue({ users });
|
|
vi.spyOn(routinesApi, "update").mockResolvedValue({ ...routine, responsibleUserId: "user-sam" });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount());
|
|
container.remove();
|
|
client.clear();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
async function render(value = routine) {
|
|
await act(async () => root.render(<QueryClientProvider client={client}><RoutineResponsibleUser routine={value} /></QueryClientProvider>));
|
|
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
|
|
}
|
|
|
|
async function selectSam() {
|
|
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
|
|
const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find((item) => item.textContent?.includes("Sam"));
|
|
expect(option).toBeDefined();
|
|
await act(async () => option!.click());
|
|
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
|
|
}
|
|
|
|
it("shows the person and inheritance rules to read-only viewers", async () => {
|
|
await render({ ...routine, canChangeResponsibleUser: false });
|
|
expect(container.textContent).toContain("Alex");
|
|
expect(container.textContent).toContain("Automatic tasks use this person. Manual runs use the person who starts them.");
|
|
expect(container.querySelector("button")).toBeNull();
|
|
});
|
|
|
|
it("saves only responsibility and its base revision, then refreshes routine data", async () => {
|
|
const invalidate = vi.spyOn(client, "invalidateQueries");
|
|
await render();
|
|
await selectSam();
|
|
expect(routinesApi.update).toHaveBeenCalledWith("routine-1", { responsibleUserId: "user-sam", baseRevisionId: "revision-1" });
|
|
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.routines.detail("routine-1") });
|
|
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.routines.revisions("routine-1") });
|
|
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.routines.activity("company-1", "routine-1") });
|
|
expect(document.querySelector('[role="option"]')).toBeNull();
|
|
});
|
|
|
|
it("keeps the picker disabled until the saved detail is refreshed, then uses the new revision", async () => {
|
|
let resolveRefresh!: (value: RoutineDetail) => void;
|
|
const refresh = new Promise<RoutineDetail>((resolve) => { resolveRefresh = resolve; });
|
|
const saved = { ...routine, responsibleUserId: "user-sam", latestRevisionId: "revision-2" };
|
|
vi.mocked(routinesApi.update).mockResolvedValue(saved);
|
|
function Detail() {
|
|
const detail = useQuery({
|
|
queryKey: queryKeys.routines.detail(routine.id),
|
|
queryFn: () => refresh,
|
|
initialData: routine,
|
|
staleTime: Infinity,
|
|
});
|
|
return <RoutineResponsibleUser routine={detail.data} />;
|
|
}
|
|
await act(async () => root.render(<QueryClientProvider client={client}><Detail /></QueryClientProvider>));
|
|
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
|
|
await selectSam();
|
|
expect(container.querySelector<HTMLButtonElement>("button")?.disabled).toBe(true);
|
|
expect(container.textContent).toContain("Saving…");
|
|
await act(async () => resolveRefresh(saved));
|
|
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
|
|
expect(container.querySelector<HTMLButtonElement>("button")?.disabled).toBe(false);
|
|
expect(container.textContent).toContain("Sam");
|
|
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
|
|
const alex = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find((item) => item.textContent?.includes("Alex"));
|
|
await act(async () => alex!.click());
|
|
expect(routinesApi.update).toHaveBeenLastCalledWith(routine.id, { responsibleUserId: "user-alex", baseRevisionId: "revision-2" });
|
|
});
|
|
|
|
it("does not save when the current person is selected", async () => {
|
|
await render();
|
|
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
|
|
const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find((item) => item.textContent?.includes("Alex"));
|
|
await act(async () => option!.click());
|
|
expect(routinesApi.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([403, 409])("keeps the saved person and shows the %s failure", async (status) => {
|
|
vi.mocked(routinesApi.update).mockRejectedValue(new ApiError("Missing permission: tasks:assign", status, null));
|
|
await render();
|
|
await selectSam();
|
|
expect(container.textContent).toContain("Alex");
|
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain(status === 409 ? "Reload before changing responsibility" : "Missing permission");
|
|
});
|
|
|
|
it("shows an unavailable person without inventing a name and permits repair", async () => {
|
|
await render({ ...routine, responsibleUserId: "former-user" });
|
|
expect(container.textContent).toContain("Unavailable user");
|
|
await selectSam();
|
|
expect(routinesApi.update).toHaveBeenCalledWith(routine.id, expect.objectContaining({ responsibleUserId: "user-sam" }));
|
|
});
|
|
|
|
it("disables the picker and explains a directory failure", async () => {
|
|
vi.mocked(accessApi.listUserDirectory).mockRejectedValue(new Error("Unavailable"));
|
|
await render();
|
|
expect(container.querySelector<HTMLButtonElement>("button")?.disabled).toBe(true);
|
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Unavailable");
|
|
expect(container.textContent).toContain("Retry");
|
|
});
|
|
|
|
it("keeps a cached person visible without a local alert during a transient directory failure", async () => {
|
|
await render();
|
|
vi.mocked(accessApi.listUserDirectory).mockRejectedValue(new ApiError("tenant_app_unavailable", 503, { error: "tenant_app_unavailable" }));
|
|
await act(async () => client.invalidateQueries({ queryKey: queryKeys.access.companyUserDirectory(routine.companyId) }));
|
|
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
|
|
expect(container.textContent).toContain("Alex");
|
|
expect(container.querySelector<HTMLButtonElement>("button")?.disabled).toBe(false);
|
|
expect(container.querySelector('[role="alert"]')).toBeNull();
|
|
});
|
|
});
|