mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 04:00:38 +09:00
[codex] Polish issue board workflows (#4224)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Human operators supervise that work through issue lists, issue detail, comments, inbox groups, markdown references, and profile/activity surfaces > - The branch had many small UI fixes that improve the operator loop but do not need to ship with backend runtime migrations > - These changes belong together as board workflow polish because they affect scanning, navigation, issue context, comment state, and markdown clarity > - This pull request groups the UI-only slice so it can merge independently from runtime/backend changes > - The benefit is a clearer board experience with better issue context, steadier optimistic updates, and more predictable keyboard navigation ## What Changed - Improves issue properties, sub-issue actions, blocker chips, and issue list/detail refresh behavior. - Adds blocker context above the issue composer and stabilizes queued/interrupted comment UI state. - Improves markdown issue/GitHub link rendering and opens external markdown links in a new tab. - Adds inbox group keyboard navigation and fold/unfold support. - Polishes activity/avatar/profile/settings/workspace presentation details. ## Verification - `pnpm exec vitest run ui/src/components/IssueProperties.test.tsx ui/src/components/IssueChatThread.test.tsx ui/src/components/MarkdownBody.test.tsx ui/src/lib/inbox.test.ts ui/src/lib/optimistic-issue-comments.test.ts` ## Risks - Low to medium risk: changes are UI-focused but cover high-traffic issue and inbox surfaces. - This branch intentionally does not include the backend runtime changes from the companion PR; where UI calls newer API filters, unsupported servers should continue to fail visibly through existing API error handling. - Visual screenshots were not captured in this heartbeat; targeted component/helper tests cover the changed behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent runtime, shell/git tool use enabled. Exact hosted model build and context window are not exposed in this Paperclip heartbeat environment. ## 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 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
This commit is contained in:
parent
09d0678840
commit
a26e1288b6
40 changed files with 1218 additions and 132 deletions
|
|
@ -7,6 +7,7 @@ import type {
|
|||
ExecutionWorkspace,
|
||||
IssueExecutionPolicy,
|
||||
IssueExecutionState,
|
||||
IssueLabel,
|
||||
Project,
|
||||
WorkspaceRuntimeService,
|
||||
} from "@paperclipai/shared";
|
||||
|
|
@ -26,12 +27,17 @@ const mockProjectsApi = vi.hoisted(() => ({
|
|||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listLabels: vi.fn(),
|
||||
createLabel: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
|
|
@ -54,6 +60,10 @@ vi.mock("../api/auth", () => ({
|
|||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useProjectOrder", () => ({
|
||||
useProjectOrder: ({ projects }: { projects: unknown[] }) => ({
|
||||
orderedProjects: projects,
|
||||
|
|
@ -153,6 +163,18 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
|||
};
|
||||
}
|
||||
|
||||
function createLabel(overrides: Partial<IssueLabel> = {}): IssueLabel {
|
||||
return {
|
||||
id: "label-1",
|
||||
companyId: "company-1",
|
||||
name: "Bug",
|
||||
color: "#ef4444",
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeService(overrides: Partial<WorkspaceRuntimeService> = {}): WorkspaceRuntimeService {
|
||||
return {
|
||||
id: "service-1",
|
||||
|
|
@ -330,7 +352,13 @@ describe("IssueProperties", () => {
|
|||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
mockIssuesApi.createLabel.mockResolvedValue(createLabel({
|
||||
id: "label-new",
|
||||
name: "New label",
|
||||
color: "#6366f1",
|
||||
}));
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -363,6 +391,63 @@ describe("IssueProperties", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders blocked-by issues as direct chips and edits them from an add action", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
mockIssuesApi.list.mockResolvedValue([
|
||||
createIssue({ id: "issue-3", identifier: "PAP-3", title: "New blocker", status: "todo" }),
|
||||
]);
|
||||
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
blockedBy: [
|
||||
{
|
||||
id: "issue-2",
|
||||
identifier: "PAP-2",
|
||||
title: "Existing blocker",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
const blockerLink = container.querySelector('a[href="/issues/PAP-2"]');
|
||||
expect(blockerLink).not.toBeNull();
|
||||
expect(blockerLink?.textContent).toContain("PAP-2");
|
||||
expect(blockerLink?.closest("button")).toBeNull();
|
||||
expect(container.textContent).toContain("Add blocker");
|
||||
expect(container.querySelector('input[placeholder="Search issues..."]')).toBeNull();
|
||||
|
||||
const addButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Add blocker"));
|
||||
expect(addButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector('input[placeholder="Search issues..."]')).not.toBeNull();
|
||||
|
||||
const candidateButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("PAP-3 New blocker"));
|
||||
expect(candidateButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-2", "issue-3"] });
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
|
||||
mockProjectsApi.list.mockResolvedValue([createProject()]);
|
||||
const serviceUrl = "http://127.0.0.1:62475";
|
||||
|
|
@ -392,6 +477,38 @@ describe("IssueProperties", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows a workspace tasks link for non-default workspaces when isolated workspaces are enabled", async () => {
|
||||
mockProjectsApi.list.mockResolvedValue([createProject()]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-main",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
currentExecutionWorkspace: createExecutionWorkspace({
|
||||
mode: "isolated_workspace",
|
||||
}),
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const tasksLink = Array.from(container.querySelectorAll("a")).find(
|
||||
(link) => link.textContent?.includes("View workspace tasks"),
|
||||
);
|
||||
const workspaceLink = Array.from(container.querySelectorAll("a")).find(
|
||||
(link) => link.textContent?.trim() === "View workspace",
|
||||
);
|
||||
expect(tasksLink).not.toBeUndefined();
|
||||
expect(tasksLink?.getAttribute("href")).toBe("/issues?workspace=workspace-1");
|
||||
expect(workspaceLink).not.toBeUndefined();
|
||||
expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("does not show a service link for the main shared workspace", async () => {
|
||||
mockProjectsApi.list.mockResolvedValue([createProject()]);
|
||||
const serviceUrl = "http://127.0.0.1:62475";
|
||||
|
|
@ -412,6 +529,10 @@ describe("IssueProperties", () => {
|
|||
await flush();
|
||||
|
||||
expect(container.querySelector(`a[href="${serviceUrl}"]`)).toBeNull();
|
||||
expect(container.textContent).not.toContain("View workspace tasks");
|
||||
expect(Array.from(container.querySelectorAll("a")).some(
|
||||
(link) => link.textContent?.trim() === "View workspace",
|
||||
)).toBe(false);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
|
@ -563,6 +684,61 @@ describe("IssueProperties", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows selected labels from labelIds even before the issue labels relation refreshes", async () => {
|
||||
mockIssuesApi.listLabels.mockResolvedValue([createLabel()]);
|
||||
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
labels: [],
|
||||
labelIds: ["label-1"],
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Bug");
|
||||
expect(container.textContent).not.toContain("No labels");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows a checkmark on selected labels in the picker", async () => {
|
||||
mockIssuesApi.listLabels.mockResolvedValue([
|
||||
createLabel(),
|
||||
createLabel({ id: "label-2", name: "Feature", color: "#22c55e" }),
|
||||
]);
|
||||
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
labels: [createLabel()],
|
||||
labelIds: ["label-1"],
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
const addLabelButton = container.querySelector('button[aria-label="Add label"]');
|
||||
expect(addLabelButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const labelButtons = Array.from(container.querySelectorAll("button"))
|
||||
.filter((button) => button.textContent?.includes("Bug") || button.textContent?.includes("Feature"));
|
||||
const bugButton = labelButtons.find((button) => button.textContent?.includes("Bug") && button.querySelector("svg"));
|
||||
const featureButton = labelButtons.find((button) => button.textContent?.includes("Feature"));
|
||||
expect(bugButton).not.toBeUndefined();
|
||||
expect(featureButton?.querySelector("svg")).toBeNull();
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("allows setting and clearing a parent issue from the properties pane", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
mockIssuesApi.list.mockResolvedValue([
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue