mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 23:36:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - This change touches several already-shipped subsystems — document-comment annotations, the selected-agent Conference Room chat surface, routine description annotations, workspace-operation tracking, and the board polling/inbox UI > - A batch of incremental fixes and two small backend additions had accumulated on a local mainline and were deployed to a live instance, but never landed upstream — so each `origin/master` sync kept re-diverging > - Leaving them un-upstreamed means the same delta has to be re-merged on every sync and risks being lost or silently reverted > - This pull request rebases that delta cleanly on top of current `origin/master` (preserving recent upstream work such as reusable sandbox leases and the relation-list collapse controls) and brings it up for review > - The benefit is that mainline and the deployed instance converge, and these fixes/additions get normal review + CI + Greptile coverage ## Linked Issues or Issue Description No single GitHub issue tracks this; it is a bundle of bug fixes and two small feature additions. Following the issue-template fields: **Bug fixes (what was wrong → what this does):** - Document comments rendered out of document order, didn't live-update across clients, swallowed save failures, and lost the markdown text selection on re-render. Now: doc-order sort, live updates, surfaced save-failure state, stable selection across re-renders. - The board polling hot path returned oversized payloads on every poll. Now: an opt-in `summary` projection trims the heartbeat-run list payload. - Assorted UI fixes: sidebar nav peek/streamlining edge cases, markdown file-viewer re-mount/line-height issues on iOS Safari, inbox badge/skill deep-link tab selection, and `⌘.` work-mode cycling on iOS. **Feature additions:** - `workspace_operations.issue_id` — associate a workspace operation with the issue that triggered it (new migration `0106`, schema, service, shared type). - Routine **description annotations** — comment threads on a routine's description document, mirroring issue document annotations (new migration `0107`, `routine_documents` schema, routes/service, editable-sections UI). - Selected-agent **Conference Room chat** surface wiring and live issue-thread updates. **Related PR:** #8229 (`feat(control-plane): add annotation and workspace controls`, draft) covers overlapping annotation/workspace-control territory — flagging it so a reviewer can reconcile the two rather than double-merging. ## What Changed - `feat(workspace)`: `workspace_operations.issue_id` migration + schema/service/type; issue workspace property controls reconciled with upstream's evolved "Service" row. - `feat(routines)`: routine description annotations — `routine_documents` schema, migration `0107`, routes/service, `editable-sections` UI. - `fix(document-comments)`: doc-order sort, live updates, save-failure surfacing, stable markdown selection (+ storybook story, rerender test). - `feat(chat)`: selected-agent Conference Room chat surface and live issue-thread updates (`LiveUpdatesProvider`, `issue-chat-messages`, interactions service). - `perf(board)`: opt-in `summary` projection for the board polling/heartbeat-run list payload, plus assorted sidebar / file-viewer / inbox / IssueProperties UI fixes. Organized into 5 logical commits. Migrations are numbered incrementally after upstream's latest (`0105`) — `0106` then `0107`, no journal collision. ## Verification - Built by 3-way merging the deployed delta onto current `origin/master`; the only merge conflict (`IssueProperties.test.tsx`, two adjacent test blocks) was resolved in favor of upstream's evolved "green service link above the workspace row" layout, which matches the merged component's rendered output. - Confirmed recent upstream work is preserved post-merge: reusable sandbox lease teardown (`#8513`), the IssueProperties relation-list collapse controls, and the sidebar streamlined-nav default. - Confirmed the net diff vs `origin/master` is exactly the intended feature delta (65 files) and that overlapping server files (`issues.ts`, `agents.ts`, `heartbeat.ts`) only add feature code without disturbing upstream logic. - This delta is already running on a live deployed instance. - Full typecheck/test suite + Greptile to run in CI (see checklist). ## Risks - Two new migrations (`0106`, `0107`). Both are additive (new table / new nullable column) and ordered after upstream's `0105`; no data backfill, low risk. If another migration-bearing PR merges first, renumber before merge. - Largest blast radius is in the merged overlapping UI/service files; covered by the existing test suites for those files plus CI. - Overlaps thematically with draft PR #8229 — reviewers should reconcile rather than merge both blindly. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, with tool use (git, shell). Agentic coding workflow. ## 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) - [ ] 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 - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
490 lines
15 KiB
TypeScript
490 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import type { WorkspaceRuntimeService } from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
buildWorkspaceRuntimeControlItems,
|
|
buildWorkspaceRuntimeControlSections,
|
|
WorkspaceRuntimeQuickControls,
|
|
WorkspaceRuntimeControls,
|
|
} from "./WorkspaceRuntimeControls";
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function act(callback: () => void) {
|
|
flushSync(callback);
|
|
}
|
|
|
|
function createRuntimeService(overrides: Partial<WorkspaceRuntimeService> = {}): WorkspaceRuntimeService {
|
|
return {
|
|
id: overrides.id ?? "service-1",
|
|
companyId: overrides.companyId ?? "company-1",
|
|
projectId: overrides.projectId ?? "project-1",
|
|
projectWorkspaceId: overrides.projectWorkspaceId ?? "workspace-1",
|
|
executionWorkspaceId: overrides.executionWorkspaceId ?? null,
|
|
issueId: overrides.issueId ?? null,
|
|
scopeType: overrides.scopeType ?? "project_workspace",
|
|
scopeId: overrides.scopeId ?? "workspace-1",
|
|
serviceName: overrides.serviceName ?? "web",
|
|
status: overrides.status ?? "stopped",
|
|
lifecycle: overrides.lifecycle ?? "shared",
|
|
reuseKey: overrides.reuseKey ?? null,
|
|
command: overrides.command ?? "pnpm dev",
|
|
cwd: overrides.cwd ?? "/repo",
|
|
port: overrides.port ?? null,
|
|
url: overrides.url ?? null,
|
|
provider: overrides.provider ?? "local_process",
|
|
providerRef: overrides.providerRef ?? null,
|
|
ownerAgentId: overrides.ownerAgentId ?? null,
|
|
startedByRunId: overrides.startedByRunId ?? null,
|
|
lastUsedAt: overrides.lastUsedAt ?? new Date("2026-04-12T00:00:00.000Z"),
|
|
startedAt: overrides.startedAt ?? new Date("2026-04-12T00:00:00.000Z"),
|
|
stoppedAt: overrides.stoppedAt ?? null,
|
|
stopPolicy: overrides.stopPolicy ?? null,
|
|
healthStatus: overrides.healthStatus ?? "unknown",
|
|
configIndex: overrides.configIndex ?? null,
|
|
createdAt: overrides.createdAt ?? new Date("2026-04-12T00:00:00.000Z"),
|
|
updatedAt: overrides.updatedAt ?? new Date("2026-04-12T00:00:00.000Z"),
|
|
};
|
|
}
|
|
|
|
describe("buildWorkspaceRuntimeControlSections", () => {
|
|
it("separates service and job commands while matching running services", () => {
|
|
const sections = buildWorkspaceRuntimeControlSections({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
|
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
|
|
],
|
|
},
|
|
runtimeServices: [
|
|
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
|
],
|
|
canStartServices: true,
|
|
canRunJobs: true,
|
|
});
|
|
|
|
expect(sections.services).toHaveLength(1);
|
|
expect(sections.jobs).toHaveLength(1);
|
|
expect(sections.services[0]).toMatchObject({
|
|
title: "web",
|
|
statusLabel: "running",
|
|
workspaceCommandId: "web",
|
|
runtimeServiceId: "service-web",
|
|
});
|
|
expect(sections.jobs[0]).toMatchObject({
|
|
title: "db:migrate",
|
|
statusLabel: "run once",
|
|
workspaceCommandId: "db-migrate",
|
|
});
|
|
});
|
|
|
|
it("keeps stopped stale runtime services from masking updated inherited commands", () => {
|
|
const sections = buildWorkspaceRuntimeControlSections({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth" },
|
|
],
|
|
},
|
|
runtimeServices: [
|
|
createRuntimeService({
|
|
id: "service-web",
|
|
serviceName: "web",
|
|
status: "stopped",
|
|
command: "pnpm dev",
|
|
}),
|
|
],
|
|
canStartServices: true,
|
|
canRunJobs: true,
|
|
});
|
|
|
|
expect(sections.services).toEqual([
|
|
expect.objectContaining({
|
|
title: "web",
|
|
statusLabel: "stopped",
|
|
command: "pnpm dev:once --tailscale-auth",
|
|
runtimeServiceId: null,
|
|
}),
|
|
]);
|
|
expect(sections.otherServices).toEqual([]);
|
|
});
|
|
|
|
it("surfaces running stale runtime services separately from updated commands", () => {
|
|
const sections = buildWorkspaceRuntimeControlSections({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth" },
|
|
],
|
|
},
|
|
runtimeServices: [
|
|
createRuntimeService({
|
|
id: "service-web",
|
|
serviceName: "web",
|
|
status: "running",
|
|
command: "pnpm dev",
|
|
}),
|
|
],
|
|
canStartServices: true,
|
|
canRunJobs: true,
|
|
});
|
|
|
|
expect(sections.services).toEqual([
|
|
expect.objectContaining({
|
|
title: "web",
|
|
statusLabel: "stopped",
|
|
command: "pnpm dev:once --tailscale-auth",
|
|
runtimeServiceId: null,
|
|
}),
|
|
]);
|
|
expect(sections.otherServices).toEqual([
|
|
expect.objectContaining({
|
|
title: "web",
|
|
statusLabel: "running",
|
|
command: "pnpm dev",
|
|
runtimeServiceId: "service-web",
|
|
disabledReason: "This runtime service no longer matches a configured workspace command.",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("surfaces running stale runtime services separately from updated commands", () => {
|
|
const sections = buildWorkspaceRuntimeControlSections({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth" },
|
|
],
|
|
},
|
|
runtimeServices: [
|
|
createRuntimeService({
|
|
id: "service-web",
|
|
serviceName: "web",
|
|
status: "running",
|
|
command: "pnpm dev",
|
|
}),
|
|
],
|
|
canStartServices: true,
|
|
canRunJobs: true,
|
|
});
|
|
|
|
expect(sections.services).toEqual([
|
|
expect.objectContaining({
|
|
title: "web",
|
|
statusLabel: "stopped",
|
|
command: "pnpm dev:once --tailscale-auth",
|
|
runtimeServiceId: null,
|
|
}),
|
|
]);
|
|
expect(sections.otherServices).toEqual([
|
|
expect.objectContaining({
|
|
title: "web",
|
|
statusLabel: "running",
|
|
command: "pnpm dev",
|
|
runtimeServiceId: "service-web",
|
|
disabledReason: "This runtime service no longer matches a configured workspace command.",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("surfaces running stale runtime services separately from updated commands", () => {
|
|
const sections = buildWorkspaceRuntimeControlSections({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth" },
|
|
],
|
|
},
|
|
runtimeServices: [
|
|
createRuntimeService({
|
|
id: "service-web",
|
|
serviceName: "web",
|
|
status: "running",
|
|
command: "pnpm dev",
|
|
}),
|
|
],
|
|
canStartServices: true,
|
|
canRunJobs: true,
|
|
});
|
|
|
|
expect(sections.services).toEqual([
|
|
expect.objectContaining({
|
|
title: "web",
|
|
statusLabel: "stopped",
|
|
command: "pnpm dev:once --tailscale-auth",
|
|
runtimeServiceId: null,
|
|
}),
|
|
]);
|
|
expect(sections.otherServices).toEqual([
|
|
expect.objectContaining({
|
|
title: "web",
|
|
statusLabel: "running",
|
|
command: "pnpm dev",
|
|
runtimeServiceId: "service-web",
|
|
disabledReason: "This runtime service no longer matches a configured workspace command.",
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("buildWorkspaceRuntimeControlItems", () => {
|
|
it("keeps the legacy flat export shape for stale importers", () => {
|
|
const items = buildWorkspaceRuntimeControlItems({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
|
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
|
|
],
|
|
},
|
|
runtimeServices: [
|
|
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
|
],
|
|
canStartServices: true,
|
|
canRunJobs: true,
|
|
});
|
|
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0]).toMatchObject({
|
|
title: "web",
|
|
status: "running",
|
|
statusLabel: "running",
|
|
runtimeServiceId: "service-web",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("WorkspaceRuntimeControls", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("renders service and job actions distinctly", () => {
|
|
const sections = buildWorkspaceRuntimeControlSections({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
|
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
|
|
],
|
|
},
|
|
runtimeServices: [
|
|
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
|
],
|
|
canStartServices: true,
|
|
canRunJobs: true,
|
|
});
|
|
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<WorkspaceRuntimeControls
|
|
sections={sections}
|
|
onAction={vi.fn()}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
const buttons = Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim());
|
|
expect(buttons).toEqual(["Stop", "Restart", "Run"]);
|
|
expect(container.textContent).toContain("Services");
|
|
expect(container.textContent).toContain("Jobs");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("lets quick action buttons inherit the shared button shape tokens", () => {
|
|
const sections = buildWorkspaceRuntimeControlSections({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
|
],
|
|
},
|
|
runtimeServices: [
|
|
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
|
],
|
|
canStartServices: true,
|
|
});
|
|
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<WorkspaceRuntimeQuickControls
|
|
sections={sections}
|
|
onAction={vi.fn()}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
const buttons = Array.from(container.querySelectorAll("button"));
|
|
expect(buttons).toHaveLength(2);
|
|
for (const button of buttons) {
|
|
expect(button.className).toContain("rounded-md");
|
|
expect(button.className).not.toContain("rounded-none");
|
|
expect(button.className).not.toContain("rounded-xl");
|
|
expect(button.className).not.toContain("shadow-none");
|
|
}
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("shows disabled actions when local command prerequisites are missing", () => {
|
|
const sections = buildWorkspaceRuntimeControlSections({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
|
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
|
|
],
|
|
},
|
|
runtimeServices: [],
|
|
canStartServices: false,
|
|
canRunJobs: false,
|
|
});
|
|
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<WorkspaceRuntimeControls
|
|
sections={sections}
|
|
disabledHint="Add a workspace path first."
|
|
onAction={vi.fn()}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
const buttons = Array.from(container.querySelectorAll("button"));
|
|
expect(buttons.every((button) => button.hasAttribute("disabled"))).toBe(true);
|
|
expect(container.textContent).toContain("Add a workspace path first.");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("hides the disabled hint once services can already run", () => {
|
|
const sections = buildWorkspaceRuntimeControlSections({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
|
],
|
|
},
|
|
runtimeServices: [
|
|
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
|
],
|
|
canStartServices: true,
|
|
});
|
|
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<WorkspaceRuntimeControls
|
|
sections={sections}
|
|
disabledHint="Add runtime settings first."
|
|
onAction={vi.fn()}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).not.toContain("Add runtime settings first.");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("hides the health badge for stopped services", () => {
|
|
const sections = buildWorkspaceRuntimeControlSections({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
|
],
|
|
},
|
|
runtimeServices: [
|
|
createRuntimeService({ id: "service-web", serviceName: "web", status: "stopped", healthStatus: "unknown" }),
|
|
],
|
|
canStartServices: true,
|
|
});
|
|
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<WorkspaceRuntimeControls
|
|
sections={sections}
|
|
onAction={vi.fn()}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).not.toContain("unknown");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("can render square plain surfaces for embedded configuration pages", () => {
|
|
const sections = buildWorkspaceRuntimeControlSections({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
|
],
|
|
},
|
|
runtimeServices: [],
|
|
canStartServices: true,
|
|
});
|
|
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<WorkspaceRuntimeControls
|
|
sections={sections}
|
|
square
|
|
onAction={vi.fn()}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
const summaryPanel = container.querySelector(".border.border-border\\/70");
|
|
const servicePanel = Array.from(container.querySelectorAll(".border.border-border\\/80"))
|
|
.find((element) => element.textContent?.includes("web"));
|
|
const startButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.trim() === "Start");
|
|
|
|
expect(summaryPanel?.className).toContain("rounded-none");
|
|
expect(summaryPanel?.className).not.toContain("bg-background/60");
|
|
expect(servicePanel?.className).toContain("rounded-none");
|
|
expect(startButton?.className).toContain("rounded-none");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("accepts the legacy items prop without crashing", () => {
|
|
const items = buildWorkspaceRuntimeControlItems({
|
|
runtimeConfig: {
|
|
commands: [
|
|
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
|
],
|
|
},
|
|
runtimeServices: [],
|
|
canStartServices: false,
|
|
});
|
|
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<WorkspaceRuntimeControls
|
|
items={items}
|
|
emptyMessage="No runtime services have been started yet."
|
|
disabledHint="Add runtime settings first."
|
|
onAction={vi.fn()}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain("Services");
|
|
expect(container.textContent).toContain("Add runtime settings first.");
|
|
expect(Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim())).toEqual(["Start"]);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
});
|