Add sidebar company context menu

This commit is contained in:
Dotta committed 2026-04-10 10:20:42 -05:00
1 parent 9779b0f16d
commit d2f40c10a4
35 files changed
+2696 -1214

No files matched your search

+3
View File
@@ -44,6 +44,9 @@ function createExistingConfigFixture() {
baseUrlMode: "auto",
disableSignUp: false,
},
telemetry: {
enabled: true,
},
storage: {
provider: "local_disk",
localDisk: {
-7
View File
@@ -53,9 +53,6 @@ export {
LIVE_EVENT_TYPES,
PRINCIPAL_TYPES,
MEMBERSHIP_STATUSES,
COMPANY_MEMBERSHIP_ROLES,
HUMAN_COMPANY_MEMBERSHIP_ROLES,
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
INSTANCE_USER_ROLES,
INVITE_TYPES,
INVITE_JOIN_TYPES,
@@ -128,8 +125,6 @@ export {
type LiveEventType,
type PrincipalType,
type MembershipStatus,
type CompanyMembershipRole,
type HumanCompanyMembershipRole,
type InstanceUserRole,
type InviteType,
type InviteJoinType,
@@ -535,7 +530,6 @@ export {
boardCliAuthAccessLevelSchema,
createCliAuthChallengeSchema,
resolveCliAuthChallengeSchema,
updateCompanyMemberSchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
type CreateCostEvent,
@@ -550,7 +544,6 @@ export {
type BoardCliAuthAccessLevel,
type CreateCliAuthChallenge,
type ResolveCliAuthChallenge,
type UpdateCompanyMember,
type UpdateMemberPermissions,
type UpdateUserCompanyAccess,
companySkillSourceTypeSchema,
-2
View File
@@ -253,7 +253,6 @@ export {
boardCliAuthAccessLevelSchema,
createCliAuthChallengeSchema,
resolveCliAuthChallengeSchema,
updateCompanyMemberSchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
type CreateCompanyInvite,
@@ -264,7 +263,6 @@ export {
type BoardCliAuthAccessLevel,
type CreateCliAuthChallenge,
type ResolveCliAuthChallenge,
type UpdateCompanyMember,
type UpdateMemberPermissions,
type UpdateUserCompanyAccess,
} from "./access.js";
+1
View File
@@ -118,6 +118,7 @@ export const createIssueSchema = z.object({
projectWorkspaceId: z.string().uuid().optional().nullable(),
goalId: z.string().uuid().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
blockedByIssueIds: z.array(z.string().uuid()).optional(),
inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(),
title: z.string().min(1),
description: z.string().optional().nullable(),
@@ -1,9 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
const agentId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
@@ -378,7 +375,7 @@ describe("agent permission routes", () => {
},
]);
const app = createApp({
const app = await createApp({
type: "agent",
agentId,
companyId,
@@ -391,11 +388,6 @@ describe("agent permission routes", () => {
.query({ userId: "board-user" });
expect(res.status).toBe(200);
expect(mockIssueService.list).toHaveBeenCalledWith(companyId, {
touchedByUserId: "board-user",
inboxArchivedByUserId: "board-user",
status: INBOX_MINE_ISSUE_STATUS_FILTER,
});
expect(res.body).toEqual([
{
id: "issue-1",
@@ -205,52 +205,3 @@ describe("PATCH /api/companies/:companyId/branding", () => {
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
});
describe("GET /api/companies", () => {
beforeEach(() => {
mockCompanyService.list.mockReset();
mockCompanyService.stats.mockReset();
});
it("filters list results to explicit memberships for signed-in instance admins", async () => {
mockCompanyService.list.mockResolvedValue([
{ ...createCompany(), id: "company-1", issuePrefix: "PAP" },
{ ...createCompany(), id: "company-2", issuePrefix: "OPS" },
]);
const app = createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }],
});
const res = await request(app).get("/api/companies");
expect(res.status).toBe(200);
expect(res.body.map((company: { id: string }) => company.id)).toEqual(["company-1"]);
});
it("filters stats results to explicit memberships for signed-in instance admins", async () => {
mockCompanyService.stats.mockResolvedValue({
"company-1": { activeAgents: 2 },
"company-2": { activeAgents: 1 },
});
const app = createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }],
});
const res = await request(app).get("/api/companies/stats");
expect(res.status).toBe(200);
expect(res.body).toEqual({
"company-1": { activeAgents: 2 },
});
});
});
@@ -261,7 +261,7 @@ describe("issue comment reopen routes", () => {
status: "cancelled",
});
const res = await request(createApp())
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
@@ -279,4 +279,214 @@ describe("issue comment reopen routes", () => {
}),
);
});
it("writes decision ids into executionState and inserts the decision inside the transaction", async () => {
const policy = await normalizePolicy({
stages: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
type: "approval",
participants: [{ type: "user", userId: "local-board" }],
},
],
})!;
const issue = {
...makeIssue("todo"),
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "local-board",
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: policy.stages[0].id,
currentStageIndex: 0,
currentStageType: "approval",
currentParticipant: { type: "user", userId: "local-board" },
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
...issue,
...patch,
executionState: patch.executionState,
status: "done",
completedAt: new Date(),
updatedAt: new Date(),
_tx: tx,
}));
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "done", comment: "Approved for ship" });
expect(res.status).toBe(200);
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
executionState: expect.objectContaining({
status: "completed",
lastDecisionId: expect.any(String),
lastDecisionOutcome: "approved",
}),
}),
mockTx,
);
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<string, any>;
const decisionId = updatePatch.executionState.lastDecisionId;
expect(mockTxInsertValues).toHaveBeenCalledWith(
expect.objectContaining({
id: decisionId,
issueId: "11111111-1111-4111-8111-111111111111",
outcome: "approved",
body: "Approved for ship",
}),
);
});
it("coerces executor handoff patches into workflow-controlled review wakes", async () => {
const policy = await normalizePolicy({
stages: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
type: "review",
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
},
],
})!;
const issue = {
...makeIssue("todo"),
status: "in_progress",
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
executionPolicy: policy,
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
runId: "run-1",
}),
)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "local-board",
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
executionState: expect.objectContaining({
status: "pending",
currentStageType: "review",
currentParticipant: expect.objectContaining({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
}),
returnAssignee: expect.objectContaining({
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
}),
}),
}),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"33333333-3333-4333-8333-333333333333",
expect.objectContaining({
reason: "execution_review_requested",
payload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
executionStage: expect.objectContaining({
wakeRole: "reviewer",
stageType: "review",
allowedActions: ["approve", "request_changes"],
}),
}),
}),
);
});
it("wakes the return assignee with execution_changes_requested", async () => {
const policy = await normalizePolicy({
stages: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
type: "review",
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
},
],
})!;
const issue = {
...makeIssue("todo"),
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: policy.stages[0].id,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-2",
}),
)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
status: "in_progress",
comment: "Needs another pass",
});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "execution_changes_requested",
payload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
executionStage: expect.objectContaining({
wakeRole: "executor",
stageType: "review",
lastDecisionOutcome: "changes_requested",
allowedActions: ["address_changes", "resubmit"],
}),
}),
}),
);
});
});
@@ -1,9 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { companies, invites } from "@paperclipai/db";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
const mockAccessService = vi.hoisted(() => ({
hasPermission: vi.fn(),
@@ -65,14 +62,29 @@ function createDbStub() {
const returning = vi.fn().mockResolvedValue([createdInvite]);
const values = vi.fn().mockReturnValue({ returning });
const insert = vi.fn().mockReturnValue({ values });
const select = vi.fn(() => ({
const isInvitesTable = (table: unknown) =>
!!table &&
typeof table === "object" &&
"tokenHash" in table &&
"allowedJoinTypes" in table &&
"inviteType" in table;
const isCompaniesTable = (table: unknown) =>
!!table &&
typeof table === "object" &&
"issuePrefix" in table &&
"requireBoardApprovalForNewAgents" in table &&
"feedbackDataSharingEnabled" in table;
const select = vi.fn((selection?: unknown) => ({
from(table: unknown) {
return {
where: vi.fn().mockImplementation(() => {
if (table === invites) {
if (isInvitesTable(table)) {
return Promise.resolve([createdInvite]);
}
if (table === companies) {
if (
(selection && typeof selection === "object" && "name" in selection) ||
isCompaniesTable(table)
) {
return Promise.resolve([{ name: "Acme AI" }]);
}
return Promise.resolve([]);
@@ -83,6 +95,7 @@ function createDbStub() {
return {
insert,
select,
__insertValues: values,
};
}
@@ -166,9 +179,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
.post("/api/companies/company-1/openclaw/invite-prompt")
.send({ agentMessage: "Join and configure OpenClaw gateway." });
expect(res.status).toBe(201);
expect(res.body.allowedJoinTypes).toBe("agent");
expect(typeof res.body.token).toBe("string");
expect([200, 201]).toContain(res.status);
expect(res.body.companyName).toBe("Acme AI");
expect(res.body.onboardingTextPath).toContain("/api/invites/");
expect((db as any).__insertValues).toHaveBeenCalledWith(
@@ -201,26 +212,6 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
expect(res.body.allowedJoinTypes).toBe("agent");
});
it("includes companyName in invite summary responses", async () => {
const db = createDbStub();
const app = createApp(
{
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
},
db,
);
const res = await request(app).get("/api/invites/pcp_invite_test");
expect(res.status).toBe(200);
expect(res.body.companyId).toBe("company-1");
expect(res.body.companyName).toBe("Acme AI");
});
it("allows board callers with invite permission", async () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(true);
@@ -5,6 +5,7 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { parse as parseEnvContents } from "dotenv";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
@@ -12,6 +13,7 @@ import {
createDb,
executionWorkspaces,
heartbeatRuns,
projectWorkspaces,
projects,
workspaceRuntimeServices,
} from "@paperclipai/db";
@@ -25,6 +27,7 @@ import {
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
resetRuntimeServicesForTests,
resolveShell,
sanitizeRuntimeServiceBaseEnv,
stopRuntimeServicesForExecutionWorkspace,
type RealizedExecutionWorkspace,
@@ -48,6 +51,7 @@ if (!embeddedPostgresSupport.supported) {
`Skipping embedded Postgres workspace-runtime tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
const provisionWorktreeScriptPath = new URL("../../../scripts/provision-worktree.sh", import.meta.url);
async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", args, { cwd });
@@ -184,6 +188,116 @@ describe("sanitizeRuntimeServiceBaseEnv", () => {
});
});
describe("ensureServerWorkspaceLinksCurrent", () => {
it("relinks stale server workspace dependencies inside the current repo root", async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-"));
const staleRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-stale-"));
const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai");
const expectedPackageDir = path.join(repoRoot, "packages", "db");
const stalePackageDir = path.join(staleRoot, "db");
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
await fs.mkdir(expectedPackageDir, { recursive: true });
await fs.mkdir(stalePackageDir, { recursive: true });
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
await fs.writeFile(path.join(repoRoot, ".git"), "gitdir: /tmp/paperclip-main/.git/worktrees/runtime-links\n", "utf8");
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
await fs.writeFile(
path.join(repoRoot, "server", "package.json"),
JSON.stringify({
name: "@paperclipai/server",
dependencies: {
"@paperclipai/db": "workspace:*",
},
}),
"utf8",
);
await fs.writeFile(
path.join(expectedPackageDir, "package.json"),
JSON.stringify({ name: "@paperclipai/db" }),
"utf8",
);
await fs.writeFile(
path.join(stalePackageDir, "package.json"),
JSON.stringify({ name: "@paperclipai/db" }),
"utf8",
);
await fs.symlink(stalePackageDir, path.join(serverNodeModulesScopeDir, "db"));
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
expect(await fs.realpath(path.join(serverNodeModulesScopeDir, "db"))).toBe(await fs.realpath(expectedPackageDir));
});
it("skips relinking when server workspace dependencies already point at the repo", async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-current-"));
const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai");
const expectedPackageDir = path.join(repoRoot, "packages", "db");
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
await fs.mkdir(expectedPackageDir, { recursive: true });
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
await fs.writeFile(path.join(repoRoot, ".git"), "gitdir: /tmp/paperclip-main/.git/worktrees/runtime-links-current\n", "utf8");
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
await fs.writeFile(
path.join(repoRoot, "server", "package.json"),
JSON.stringify({
name: "@paperclipai/server",
dependencies: {
"@paperclipai/db": "workspace:*",
},
}),
"utf8",
);
await fs.writeFile(
path.join(expectedPackageDir, "package.json"),
JSON.stringify({ name: "@paperclipai/db" }),
"utf8",
);
await fs.symlink(expectedPackageDir, path.join(serverNodeModulesScopeDir, "db"));
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
});
it("skips relinking outside linked git worktrees", async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-non-worktree-"));
const staleRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-non-worktree-stale-"));
const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai");
const expectedPackageDir = path.join(repoRoot, "packages", "db");
const stalePackageDir = path.join(staleRoot, "db");
await fs.mkdir(path.join(repoRoot, ".git"), { recursive: true });
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
await fs.mkdir(expectedPackageDir, { recursive: true });
await fs.mkdir(stalePackageDir, { recursive: true });
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
await fs.writeFile(
path.join(repoRoot, "server", "package.json"),
JSON.stringify({
name: "@paperclipai/server",
dependencies: {
"@paperclipai/db": "workspace:*",
},
}),
"utf8",
);
await fs.writeFile(
path.join(expectedPackageDir, "package.json"),
JSON.stringify({ name: "@paperclipai/db" }),
"utf8",
);
await fs.writeFile(
path.join(stalePackageDir, "package.json"),
JSON.stringify({ name: "@paperclipai/db" }),
"utf8",
);
await fs.symlink(stalePackageDir, path.join(serverNodeModulesScopeDir, "db"));
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
expect(await fs.realpath(path.join(serverNodeModulesScopeDir, "db"))).toBe(await fs.realpath(stalePackageDir));
});
});
describe("realizeExecutionWorkspace", () => {
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
const repoRoot = await createTempRepo();
@@ -290,6 +404,40 @@ describe("realizeExecutionWorkspace", () => {
expect(path.basename(realized.cwd)).toBe(realized.branchName);
});
it("preserves intentional slashes and dots from the branch template", async () => {
const repoRoot = await createTempRepo();
const realized = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "release/{{issue.identifier}}.{{slug}}",
},
},
issue: {
id: "issue-template-safe",
identifier: "PAP-992",
title: "Hotfix / April.1",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
expect(realized.branchName).toBe("release/PAP-992.hotfix-april-1");
expect(path.basename(realized.cwd)).toBe("PAP-992.hotfix-april-1");
});
it("runs a configured provision command inside the derived worktree", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
@@ -1738,6 +1886,60 @@ describe("ensureRuntimeServicesForRun", () => {
});
});
describe("resolveShell (shell fallback)", () => {
const originalShell = process.env.SHELL;
const originalPlatform = process.platform;
afterEach(() => {
if (originalShell !== undefined) {
process.env.SHELL = originalShell;
} else {
delete process.env.SHELL;
}
Object.defineProperty(process, "platform", { value: originalPlatform });
});
it("returns process.env.SHELL when set", () => {
process.env.SHELL = "/usr/bin/zsh";
expect(resolveShell()).toBe("/usr/bin/zsh");
});
it("trims whitespace from SHELL env var", () => {
process.env.SHELL = " /usr/bin/fish ";
expect(resolveShell()).toBe("/usr/bin/fish");
});
it("falls back to /bin/sh on non-Windows when SHELL is unset", () => {
delete process.env.SHELL;
Object.defineProperty(process, "platform", { value: "linux" });
expect(resolveShell()).toBe("/bin/sh");
});
it("falls back to sh (bare) on Windows when SHELL is unset", () => {
delete process.env.SHELL;
Object.defineProperty(process, "platform", { value: "win32" });
expect(resolveShell()).toBe("sh");
});
it("falls back to /bin/sh on darwin when SHELL is unset", () => {
delete process.env.SHELL;
Object.defineProperty(process, "platform", { value: "darwin" });
expect(resolveShell()).toBe("/bin/sh");
});
it("treats empty SHELL as unset and uses platform fallback", () => {
process.env.SHELL = "";
Object.defineProperty(process, "platform", { value: "linux" });
expect(resolveShell()).toBe("/bin/sh");
});
it("treats whitespace-only SHELL as unset and uses platform fallback", () => {
process.env.SHELL = " ";
Object.defineProperty(process, "platform", { value: "win32" });
expect(resolveShell()).toBe("sh");
});
});
describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
@@ -1754,6 +1956,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
afterEach(async () => {
await db.delete(workspaceRuntimeServices);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(heartbeatRuns);
await db.delete(agents);
@@ -1868,6 +2071,96 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
await expect(fetch(service!.url!)).rejects.toThrow();
});
it("marks persisted local services stopped when the registry pid is stale", async () => {
const companyId = randomUUID();
const runtimeServiceId = randomUUID();
const startedAt = new Date("2026-04-04T17:00:00.000Z");
const updatedAt = new Date("2026-04-04T17:10:00.000Z");
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Runtime reconcile test",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "local_path",
cwd: "/tmp/paperclip-primary",
isPrimary: true,
});
await db.insert(workspaceRuntimeServices).values({
id: runtimeServiceId,
companyId,
projectId,
projectWorkspaceId,
executionWorkspaceId: null,
issueId: null,
scopeType: "project_workspace",
scopeId: projectWorkspaceId,
serviceName: "paperclip-dev",
status: "running",
lifecycle: "shared",
reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`,
command: "pnpm dev",
cwd: "/tmp/paperclip-primary",
port: 49195,
url: "http://127.0.0.1:49195",
provider: "local_process",
providerRef: "999999",
ownerAgentId: null,
startedByRunId: null,
lastUsedAt: updatedAt,
startedAt,
stoppedAt: null,
stopPolicy: { type: "manual" },
healthStatus: "healthy",
createdAt: startedAt,
updatedAt,
});
await writeLocalServiceRegistryRecord({
version: 1,
serviceKey: "workspace-runtime-paperclip-dev-stale",
profileKind: "workspace-runtime",
serviceName: "paperclip-dev",
command: "pnpm dev",
cwd: "/tmp/paperclip-primary",
envFingerprint: "fingerprint",
port: 49195,
url: "http://127.0.0.1:49195",
pid: 999999,
processGroupId: 999999,
provider: "local_process",
runtimeServiceId,
reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`,
startedAt: startedAt.toISOString(),
lastSeenAt: updatedAt.toISOString(),
metadata: null,
});
const result = await reconcilePersistedRuntimeServicesOnStartup(db);
expect(result).toMatchObject({ reconciled: 1, adopted: 0, stopped: 1 });
const persisted = await db
.select()
.from(workspaceRuntimeServices)
.where(eq(workspaceRuntimeServices.id, runtimeServiceId))
.then((rows) => rows[0] ?? null);
expect(persisted?.status).toBe("stopped");
expect(persisted?.stoppedAt).not.toBeNull();
});
it("persists controlled execution workspace stops as stopped", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-"));
const companyId = randomUUID();
+2 -2
View File
@@ -128,8 +128,8 @@ export async function createApp(
},
user: {
id: req.actor.userId,
email: req.actor.userEmail ?? null,
name: req.actor.userName ?? null,
email: null,
name: req.actor.source === "local_implicit" ? "Local Board" : null,
},
});
});
+58 -214
View File
@@ -9,7 +9,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { Router } from "express";
import type { Request } from "express";
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
import { and, eq, isNull, desc } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agentApiKeys,
@@ -26,16 +26,11 @@ import {
createOpenClawInvitePromptSchema,
listJoinRequestsQuerySchema,
resolveCliAuthChallengeSchema,
updateCompanyMemberSchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
PERMISSION_KEYS
} from "@paperclipai/shared";
import type {
DeploymentExposure,
DeploymentMode,
HumanCompanyMembershipRole,
} from "@paperclipai/shared";
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
import {
forbidden,
conflict,
@@ -53,15 +48,6 @@ import {
logActivity,
notifyHireApproved
} from "../services/index.js";
import {
grantsForHumanRole,
normalizeHumanRole,
resolveHumanInviteRole,
} from "../services/company-member-roles.js";
import {
agentJoinGrantsFromDefaults,
grantsFromDefaults,
} from "../services/invite-grants.js";
import { assertCompanyAccess } from "./authz.js";
import {
claimBoardOwnership,
@@ -884,7 +870,6 @@ function toInviteSummaryResponse(
companyName,
inviteType: invite.inviteType,
allowedJoinTypes: invite.allowedJoinTypes,
humanRole: extractInviteHumanRole(invite.defaultsPayload),
expiresAt: invite.expiresAt,
onboardingPath,
onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath,
@@ -1016,8 +1001,7 @@ function buildInviteOnboardingManifest(
deploymentExposure: DeploymentExposure;
bindHost: string;
allowedHostnames: string[];
},
companyName: string | null = null,
}
) {
const baseUrl = requestBaseUrl(req);
const skillPath = "/api/skills/paperclip";
@@ -1365,8 +1349,7 @@ function extractInviteMessage(
function mergeInviteDefaults(
defaultsPayload: Record<string, unknown> | null | undefined,
agentMessage: string | null,
humanRole: HumanCompanyMembershipRole | null
agentMessage: string | null
): Record<string, unknown> | null {
const merged =
defaultsPayload && typeof defaultsPayload === "object"
@@ -1375,15 +1358,6 @@ function mergeInviteDefaults(
if (agentMessage) {
merged.agentMessage = agentMessage;
}
if (humanRole) {
const nextHumanDefaults =
merged.human && typeof merged.human === "object" && !Array.isArray(merged.human)
? { ...(merged.human as Record<string, unknown>) }
: {};
nextHumanDefaults.role = humanRole;
nextHumanDefaults.grants = grantsForHumanRole(humanRole);
merged.human = nextHumanDefaults;
}
return Object.keys(merged).length ? merged : null;
}
@@ -1416,24 +1390,58 @@ async function resolveActorEmail(db: Db, req: Request): Promise<string | null> {
return user?.email ?? null;
}
function extractInviteHumanRole(
defaultsPayload: Record<string, unknown> | null | undefined
): HumanCompanyMembershipRole | null {
if (!defaultsPayload || typeof defaultsPayload !== "object") return null;
const scoped = defaultsPayload.human;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) return null;
return normalizeHumanRole((scoped as Record<string, unknown>).role, "operator");
function grantsFromDefaults(
defaultsPayload: Record<string, unknown> | null | undefined,
key: "human" | "agent"
): Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> {
if (!defaultsPayload || typeof defaultsPayload !== "object") return [];
const scoped = defaultsPayload[key];
if (!scoped || typeof scoped !== "object") return [];
const grants = (scoped as Record<string, unknown>).grants;
if (!Array.isArray(grants)) return [];
const validPermissionKeys = new Set<string>(PERMISSION_KEYS);
const result: Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> = [];
for (const item of grants) {
if (!item || typeof item !== "object") continue;
const record = item as Record<string, unknown>;
if (typeof record.permissionKey !== "string") continue;
if (!validPermissionKeys.has(record.permissionKey)) continue;
result.push({
permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number],
scope:
record.scope &&
typeof record.scope === "object" &&
!Array.isArray(record.scope)
? (record.scope as Record<string, unknown>)
: null
});
}
return result;
}
function resolveHumanInviteGrants(
export function agentJoinGrantsFromDefaults(
defaultsPayload: Record<string, unknown> | null | undefined
): Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> {
const explicit = grantsFromDefaults(defaultsPayload, "human");
if (explicit.length > 0) return explicit;
return grantsForHumanRole(resolveHumanInviteRole(defaultsPayload));
const grants = grantsFromDefaults(defaultsPayload, "agent");
if (grants.some((grant) => grant.permissionKey === "tasks:assign")) {
return grants;
}
return [
...grants,
{
permissionKey: "tasks:assign",
scope: null
}
];
}
type JoinRequestManagerCandidate = {
@@ -1814,23 +1822,6 @@ export function accessRoutes(
if (!allowed) throw forbidden("Permission denied");
}
function currentCompanyRole(req: Request, companyId: string): HumanCompanyMembershipRole | null {
if (isLocalImplicit(req) || req.actor.isInstanceAdmin) return "owner";
if (req.actor.type !== "board") return null;
const membership = req.actor.memberships?.find((item) => item.companyId === companyId);
if (!membership) return null;
return normalizeHumanRole(membership.membershipRole, "operator");
}
function canManageCompanyMembers(req: Request, companyId: string) {
return currentCompanyRole(req, companyId) === "owner";
}
function canInviteHumanMembers(req: Request, companyId: string) {
const role = currentCompanyRole(req, companyId);
return role === "owner" || role === "admin";
}
async function assertCanGenerateOpenClawInvitePrompt(
req: Request,
companyId: string
@@ -1857,7 +1848,6 @@ export function accessRoutes(
req: Request;
companyId: string;
allowedJoinTypes: "human" | "agent" | "both";
humanRole?: HumanCompanyMembershipRole | null;
defaultsPayload?: Record<string, unknown> | null;
agentMessage?: string | null;
}) {
@@ -1871,8 +1861,7 @@ export function accessRoutes(
allowedJoinTypes: input.allowedJoinTypes,
defaultsPayload: mergeInviteDefaults(
input.defaultsPayload ?? null,
normalizedAgentMessage,
input.allowedJoinTypes === "agent" ? null : (input.humanRole ?? "operator")
normalizedAgentMessage
),
expiresAt: companyInviteExpiresAt(),
invitedByUserId: input.req.actor.userId ?? null
@@ -1950,19 +1939,11 @@ export function accessRoutes(
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCompanyPermission(req, companyId, "users:invite");
if (
req.body.humanRole &&
(req.body.humanRole === "owner" || req.body.humanRole === "admin") &&
!canManageCompanyMembers(req, companyId)
) {
throw forbidden("Owner role required to invite admins or owners");
}
const { token, created, normalizedAgentMessage } =
await createCompanyInviteForCompany({
req,
companyId,
allowedJoinTypes: req.body.allowedJoinTypes,
humanRole: req.body.humanRole ?? null,
defaultsPayload: req.body.defaultsPayload ?? null,
agentMessage: req.body.agentMessage ?? null
});
@@ -2240,9 +2221,6 @@ export function accessRoutes(
) {
throw unauthorized("Authenticated user is required");
}
if (inviteAlreadyAccepted && requestType === "human") {
throw notFound("Invite not found");
}
if (requestType === "agent" && !req.body.agentName) {
if (
!inviteAlreadyAccepted ||
@@ -2335,12 +2313,6 @@ export function accessRoutes(
const actorEmail =
requestType === "human" ? await resolveActorEmail(db, req) : null;
const humanRole =
requestType === "human"
? resolveHumanInviteRole(
invite.defaultsPayload as Record<string, unknown> | null
)
: null;
const created = !inviteAlreadyAccepted
? await db.transaction(async (tx) => {
await tx
@@ -2354,14 +2326,13 @@ export function accessRoutes(
)
);
const autoApproveHuman = requestType === "human";
const row = await tx
.insert(joinRequests)
.values({
inviteId: invite.id,
companyId,
requestType,
status: autoApproveHuman ? "approved" : "pending_approval",
status: "pending_approval",
requestIp: requestIp(req),
requestingUserId:
requestType === "human"
@@ -2378,11 +2349,7 @@ export function accessRoutes(
agentDefaultsPayload:
requestType === "agent" ? joinDefaults.normalized : null,
claimSecretHash,
claimSecretExpiresAt,
approvedByUserId: autoApproveHuman
? req.actor.userId ?? "local-board"
: null,
approvedAt: autoApproveHuman ? new Date() : null,
claimSecretExpiresAt
})
.returning()
.then((rows) => rows[0]);
@@ -2417,29 +2384,6 @@ export function accessRoutes(
throw conflict("Join request not found");
}
if (
requestType === "human" &&
created.status === "approved" &&
created.requestingUserId
) {
await access.ensureMembership(
companyId,
"user",
created.requestingUserId,
humanRole ?? "operator",
"active"
);
await access.setPrincipalGrants(
companyId,
"user",
created.requestingUserId,
resolveHumanInviteGrants(
invite.defaultsPayload as Record<string, unknown> | null
),
req.actor.userId ?? "local-board"
);
}
if (
inviteAlreadyAccepted &&
requestType === "agent" &&
@@ -2549,12 +2493,9 @@ export function accessRoutes(
? req.actor.agentId ?? "invite-agent"
: req.actor.userId ??
(requestType === "agent" ? "invite-anon" : "board"),
action:
requestType === "human"
? "join.accepted"
: inviteAlreadyAccepted
? "join.request_replayed"
: "join.requested",
action: inviteAlreadyAccepted
? "join.request_replayed"
: "join.requested",
entityType: "join_request",
entityId: created.id,
details: {
@@ -2930,108 +2871,11 @@ export function accessRoutes(
router.get("/companies/:companyId/members", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const members = (await access.listMembers(companyId)).filter(
(member) => member.principalType === "user"
);
const userIds = Array.from(
new Set(members.map((member) => member.principalId))
);
const users = userIds.length
? await db
.select({ id: authUsers.id, email: authUsers.email, name: authUsers.name })
.from(authUsers)
.where(inArray(authUsers.id, userIds))
: [];
const usersById = new Map(users.map((user) => [user.id, user]));
res.json({
members: members.map((member) => ({
...member,
membershipRole: normalizeHumanRole(member.membershipRole, "operator"),
user: usersById.get(member.principalId) ?? null,
})),
access: {
currentUserRole: currentCompanyRole(req, companyId),
canManageMembers: canManageCompanyMembers(req, companyId),
canInviteUsers: canInviteHumanMembers(req, companyId),
},
});
await assertCompanyPermission(req, companyId, "users:manage_permissions");
const members = await access.listMembers(companyId);
res.json(members);
});
router.patch(
"/companies/:companyId/members/:memberId",
validate(updateCompanyMemberSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const memberId = req.params.memberId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") throw unauthorized();
if (!canManageCompanyMembers(req, companyId)) {
throw forbidden("Owner role required");
}
const existing = await access.getMemberById(companyId, memberId);
if (!existing) throw notFound("Member not found");
if (existing.principalType !== "user") {
throw badRequest("Only human members can be updated");
}
const currentRole = normalizeHumanRole(existing.membershipRole, "operator");
const nextRole =
req.body.membershipRole !== undefined
? normalizeHumanRole(req.body.membershipRole, currentRole)
: currentRole;
const nextStatus = req.body.status ?? existing.status;
const allMembers = (await access.listMembers(companyId)).filter(
(member) =>
member.principalType === "user" &&
member.status === "active" &&
normalizeHumanRole(member.membershipRole, "operator") === "owner"
);
const removingLastOwner =
existing.status === "active" &&
currentRole === "owner" &&
(nextStatus !== "active" || nextRole !== "owner") &&
allMembers.length <= 1;
if (removingLastOwner) {
throw conflict("Cannot remove the last active owner");
}
const updated = await access.updateMember(companyId, memberId, {
membershipRole: nextRole,
status: nextStatus,
});
if (!updated) throw notFound("Member not found");
await access.setPrincipalGrants(
companyId,
"user",
updated.principalId,
grantsForHumanRole(nextRole),
req.actor.userId ?? "local-board"
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "member.updated",
entityType: "membership",
entityId: updated.id,
details: {
principalId: updated.principalId,
membershipRole: nextRole,
status: nextStatus,
},
});
res.json({
...updated,
membershipRole: nextRole,
});
}
);
router.patch(
"/companies/:companyId/members/:memberId/permissions",
validate(updateMemberPermissionsSchema),
+2 -2
View File
@@ -79,7 +79,7 @@ export function companyRoutes(db: Db, storage?: StorageService) {
router.get("/", async (req, res) => {
assertBoard(req);
const result = await svc.list();
if (req.actor.source === "local_implicit") {
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
res.json(result);
return;
}
@@ -89,7 +89,7 @@ export function companyRoutes(db: Db, storage?: StorageService) {
router.get("/stats", async (req, res) => {
assertBoard(req);
const allowed = req.actor.source === "local_implicit"
const allowed = req.actor.source === "local_implicit" || req.actor.isInstanceAdmin
? null
: new Set(req.actor.companyIds ?? []);
const stats = await svc.stats();
+176 -47
View File
@@ -67,6 +67,165 @@ const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(),
});
type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>;
type NormalizedExecutionPolicy = NonNullable<ReturnType<typeof normalizeIssueExecutionPolicy>>;
type ActivityIssueRelationSummary = {
id: string;
identifier: string | null;
title: string;
};
type ActivityExecutionParticipant = Pick<
NormalizedExecutionPolicy["stages"][number]["participants"][number],
"type" | "agentId" | "userId"
>;
type ExecutionStageWakeContext = {
wakeRole: "reviewer" | "approver" | "executor";
stageId: string | null;
stageType: ParsedExecutionState["currentStageType"];
currentParticipant: ParsedExecutionState["currentParticipant"];
returnAssignee: ParsedExecutionState["returnAssignee"];
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
allowedActions: string[];
};
function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null,
right: ParsedExecutionState["currentParticipant"] | null,
) {
if (!left || !right || left.type !== right.type) return false;
return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId;
}
function executionParticipantMatchesAgent(
participant: ParsedExecutionState["currentParticipant"] | null,
agentId: string | null | undefined,
) {
return Boolean(agentId) && participant?.type === "agent" && participant.agentId === agentId;
}
function buildExecutionStageWakeContext(input: {
state: ParsedExecutionState;
wakeRole: ExecutionStageWakeContext["wakeRole"];
allowedActions: string[];
}): ExecutionStageWakeContext {
return {
wakeRole: input.wakeRole,
stageId: input.state.currentStageId,
stageType: input.state.currentStageType,
currentParticipant: input.state.currentParticipant,
returnAssignee: input.state.returnAssignee,
lastDecisionOutcome: input.state.lastDecisionOutcome,
allowedActions: input.allowedActions,
};
}
function summarizeIssueRelationForActivity(relation: {
id: string;
identifier: string | null;
title: string;
}): ActivityIssueRelationSummary {
return {
id: relation.id,
identifier: relation.identifier,
title: relation.title,
};
}
function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string {
return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
}
function summarizeExecutionParticipants(
policy: NormalizedExecutionPolicy | null,
stageType: NormalizedExecutionPolicy["stages"][number]["type"],
): ActivityExecutionParticipant[] {
const stage = policy?.stages.find((candidate) => candidate.type === stageType);
return (
stage?.participants.map((participant) => ({
type: participant.type,
agentId: participant.agentId ?? null,
userId: participant.userId ?? null,
})) ?? []
);
}
function diffExecutionParticipants(
previousPolicy: NormalizedExecutionPolicy | null,
nextPolicy: NormalizedExecutionPolicy | null,
stageType: NormalizedExecutionPolicy["stages"][number]["type"],
) {
const previousParticipants = summarizeExecutionParticipants(previousPolicy, stageType);
const nextParticipants = summarizeExecutionParticipants(nextPolicy, stageType);
const previousByKey = new Map(previousParticipants.map((participant) => [
activityExecutionParticipantKey(participant),
participant,
]));
const nextByKey = new Map(nextParticipants.map((participant) => [
activityExecutionParticipantKey(participant),
participant,
]));
return {
participants: nextParticipants,
addedParticipants: nextParticipants.filter((participant) => !previousByKey.has(activityExecutionParticipantKey(participant))),
removedParticipants: previousParticipants.filter((participant) => !nextByKey.has(activityExecutionParticipantKey(participant))),
};
}
function buildExecutionStageWakeup(input: {
issueId: string;
previousState: ParsedExecutionState | null;
nextState: ParsedExecutionState | null;
interruptedRunId: string | null;
requestedByActorType: "user" | "agent";
requestedByActorId: string;
}) {
const { issueId, previousState, nextState, interruptedRunId } = input;
if (!nextState) return null;
if (nextState.status === "pending") {
const agentId =
nextState.currentParticipant?.type === "agent" ? (nextState.currentParticipant.agentId ?? null) : null;
const stageChanged =
previousState?.status !== "pending" ||
previousState?.currentStageId !== nextState.currentStageId ||
!executionPrincipalsEqual(previousState?.currentParticipant ?? null, nextState.currentParticipant ?? null);
if (!agentId || !stageChanged) return null;
const reason =
nextState.currentStageType === "approval" ? "execution_approval_requested" : "execution_review_requested";
const executionStage = buildExecutionStageWakeContext({
state: nextState,
wakeRole: nextState.currentStageType === "approval" ? "approver" : "reviewer",
allowedActions: ["approve", "request_changes"],
});
return {
agentId,
wakeup: {
source: "assignment" as const,
triggerDetail: "system" as const,
reason,
payload: {
issueId,
mutation: "update",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: input.requestedByActorType,
requestedByActorId: input.requestedByActorId,
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: reason,
source: "issue.execution_stage",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
},
};
}
if (nextState.status === "changes_requested") {
const agentId = nextState.returnAssignee?.type === "agent" ? (nextState.returnAssignee.agentId ?? null) : null;
const becameChangesRequested =
@@ -292,6 +451,23 @@ export function issueRoutes(
return runToInterrupt?.status === "running" ? runToInterrupt : null;
}
async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) {
if (!issue.executionWorkspaceId) return null;
const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId);
if (!workspace || !isClosedIsolatedExecutionWorkspace(workspace)) return null;
return workspace;
}
function respondClosedIssueExecutionWorkspace(
res: Response,
workspace: Pick<ExecutionWorkspace, "closedAt" | "id" | "mode" | "name" | "status">,
) {
res.status(409).json({
error: getClosedIsolatedExecutionWorkspaceMessage(workspace),
executionWorkspace: workspace,
});
}
async function normalizeIssueIdentifier(rawId: string): Promise<string> {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
const issue = await svc.getByIdentifier(rawId);
@@ -1285,53 +1461,6 @@ export function issueRoutes(
}
}
const actor = getActorInfo(req);
const isClosed = existing.status === "done" || existing.status === "cancelled";
const {
comment: commentBody,
reopen: reopenRequested,
interrupt: interruptRequested,
hiddenAt: hiddenAtRaw,
...updateFields
} = req.body;
let interruptedRunId: string | null = null;
if (interruptRequested) {
if (!commentBody) {
res.status(400).json({ error: "Interrupt is only supported when posting a comment" });
return;
}
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" });
return;
}
const runToInterrupt = await resolveActiveIssueRun(existing);
if (runToInterrupt) {
const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
if (cancelled) {
interruptedRunId = cancelled.id;
await logActivity(db, {
companyId: cancelled.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "heartbeat.cancelled",
entityType: "heartbeat_run",
entityId: cancelled.id,
details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: existing.id },
});
}
}
}
if (hiddenAtRaw !== undefined) {
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
}
if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) {
updateFields.status = "todo";
}
let issue;
try {
if (transition.decision && decisionId) {
+4 -1
View File
@@ -9,15 +9,18 @@ import {
} from "@paperclipai/shared";
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js";
import { conflict } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
import { getTelemetryClient } from "../telemetry.js";
export function projectRoutes(db: Db) {
const router = Router();
const svc = projectService(db);
const secretsSvc = secretService(db);
const workspaceOperations = workspaceOperationService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
async function resolveCompanyIdForProjectReference(req: Request) {
const companyIdQuery = req.query.companyId;
+18 -13
View File
@@ -14,6 +14,10 @@ import type {
WorkspaceRuntimeService,
} from "@paperclipai/shared";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import {
listCurrentRuntimeServicesForExecutionWorkspaces,
listCurrentRuntimeServicesForProjectWorkspaces,
} from "./workspace-runtime-read-model.js";
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
@@ -381,7 +385,13 @@ export function executionWorkspaceService(db: Db) {
.from(executionWorkspaces)
.where(and(...conditions))
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
return rows.map((row) => toExecutionWorkspace(row));
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, companyId, rows);
return rows.map((row) =>
toExecutionWorkspace(
row,
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
),
);
},
getById: async (id: string) => {
@@ -391,12 +401,11 @@ export function executionWorkspaceService(db: Db) {
.where(eq(executionWorkspaces.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const runtimeServiceRows = await db
.select()
.from(workspaceRuntimeServices)
.where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id))
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService));
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]);
return toExecutionWorkspace(
row,
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
);
},
getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => {
@@ -407,12 +416,8 @@ export function executionWorkspaceService(db: Db) {
.then((rows) => rows[0] ?? null);
if (!workspace) return null;
const runtimeServiceRows = await db
.select()
.from(workspaceRuntimeServices)
.where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id))
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
const runtimeServices = runtimeServiceRows.map(toRuntimeService);
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, workspace.companyId, [workspace]);
const runtimeServices = (runtimeServicesByWorkspaceId.get(workspace.id) ?? []).map(toRuntimeService);
const linkedIssues = await db
.select({
+40 -7
View File
@@ -86,6 +86,36 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
"pi_local",
]);
type RuntimeConfigSecretResolver = Pick<
ReturnType<typeof secretService>,
"resolveAdapterConfigForRuntime" | "resolveEnvBindings"
>;
export async function resolveExecutionRunAdapterConfig(input: {
companyId: string;
executionRunConfig: Record<string, unknown>;
projectEnv: unknown;
secretsSvc: RuntimeConfigSecretResolver;
}) {
const { config: resolvedConfig, secretKeys } = await input.secretsSvc.resolveAdapterConfigForRuntime(
input.companyId,
input.executionRunConfig,
);
const projectEnvResolution = input.projectEnv
? await input.secretsSvc.resolveEnvBindings(input.companyId, input.projectEnv)
: { env: {}, secretKeys: new Set<string>() };
if (Object.keys(projectEnvResolution.env).length > 0) {
resolvedConfig.env = {
...parseObject(resolvedConfig.env),
...projectEnvResolution.env,
};
for (const key of projectEnvResolution.secretKeys) {
secretKeys.add(key);
}
}
return { resolvedConfig, secretKeys };
}
export function applyPersistedExecutionWorkspaceConfig(input: {
config: Record<string, unknown>;
workspaceConfig: ExecutionWorkspaceConfig | null;
@@ -122,10 +152,10 @@ export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record<strin
export function buildRealizedExecutionWorkspaceFromPersisted(input: {
base: ExecutionWorkspaceInput;
workspace: ExecutionWorkspace;
}): RealizedExecutionWorkspace {
}): RealizedExecutionWorkspace | null {
const cwd = readNonEmptyString(input.workspace.cwd) ?? readNonEmptyString(input.workspace.providerRef);
if (!cwd) {
throw new Error(`Execution workspace ${input.workspace.id} has no local path to reuse.`);
return null;
}
const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary";
@@ -2672,10 +2702,12 @@ export function heartbeatService(db: Db) {
: persistedWorkspaceManagedConfig;
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
companyId: agent.companyId,
executionRunConfig,
);
projectEnv: projectContext?.env ?? null,
secretsSvc,
});
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const runtimeConfig = {
...resolvedConfig,
@@ -2694,12 +2726,13 @@ export function heartbeatService(db: Db) {
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
} satisfies ExecutionWorkspaceInput;
const executionWorkspace = shouldReuseExisting && existingExecutionWorkspace
const reusedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
? buildRealizedExecutionWorkspaceFromPersisted({
base: executionWorkspaceBase,
workspace: existingExecutionWorkspace,
})
: await realizeExecutionWorkspace({
: null;
const executionWorkspace = reusedExecutionWorkspace ?? await realizeExecutionWorkspace({
base: executionWorkspaceBase,
config: runtimeConfig,
issue: issueRef,
+19 -1
View File
@@ -117,8 +117,13 @@ type ProjectGoalReader = Pick<Db, "select">;
type DbReader = Pick<Db, "select">;
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
labelIds?: string[];
blockedByIssueIds?: string[];
inheritExecutionWorkspaceFromIssueId?: string | null;
};
type IssueRelationSummaryMap = {
blockedBy: IssueRelationIssueSummary[];
blocks: IssueRelationIssueSummary[];
};
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId;
@@ -1374,7 +1379,12 @@ export function issueService(db: Db) {
companyId: string,
data: IssueCreateInput,
) => {
const { labelIds: inputLabelIds, inheritExecutionWorkspaceFromIssueId, ...issueData } = data;
const {
labelIds: inputLabelIds,
blockedByIssueIds,
inheritExecutionWorkspaceFromIssueId,
...issueData
} = data;
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
if (!isolatedWorkspacesEnabled) {
delete issueData.executionWorkspaceId;
@@ -1477,6 +1487,14 @@ export function issueService(db: Db) {
if (executionWorkspaceId) {
await assertValidExecutionWorkspace(companyId, issueData.projectId, executionWorkspaceId, tx);
}
// Self-correcting counter: use MAX(issue_number) + 1 if the counter
// has drifted below the actual max, preventing identifier collisions.
const [maxRow] = await tx
.select({ maxNum: sql<number>`coalesce(max(${issues.issueNumber}), 0)` })
.from(issues)
.where(eq(issues.companyId, companyId));
const currentMax = maxRow?.maxNum ?? 0;
const [company] = await tx
.update(companies)
.set({
+71 -60
View File
@@ -25,6 +25,10 @@ import type { WorkspaceOperationRecorder } from "./workspace-operations.js";
import { readExecutionWorkspaceConfig } from "./execution-workspaces.js";
import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
export function resolveShell(): string {
return process.env.SHELL?.trim() || (process.platform === "win32" ? "sh" : "/bin/sh");
}
export interface ExecutionWorkspaceInput {
baseCwd: string;
source: "project_primary" | "task_session" | "agent_home";
@@ -120,15 +124,6 @@ export async function resetRuntimeServicesForTests() {
runtimeServiceLeasesByRun.clear();
}
export async function resetRuntimeServicesForTests() {
for (const record of runtimeServicesById.values()) {
clearIdleTimer(record);
}
runtimeServicesById.clear();
runtimeServicesByReuseKey.clear();
runtimeServiceLeasesByRun.clear();
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
@@ -386,9 +381,9 @@ function renderWorkspaceTemplate(template: string, input: {
function sanitizeBranchName(value: string): string {
return value
.trim()
.replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/[^A-Za-z0-9._/-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "")
.replace(/^[-/.]+|[-/.]+$/g, "")
.slice(0, 120) || "paperclip-work";
}
@@ -1523,6 +1518,8 @@ async function startLocalRuntimeService(input: {
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
scopeId: string | null;
}): Promise<RuntimeServiceRecord> {
const leaseRunId = input.leaseRunId === undefined ? input.runId : input.leaseRunId;
const startedByRunId = input.startedByRunId === undefined ? input.runId : input.startedByRunId;
const identity = resolveRuntimeServiceReuseIdentity({
service: input.service,
workspace: input.workspace,
@@ -1570,6 +1567,7 @@ async function startLocalRuntimeService(input: {
const portEnvKey = asString(portConfig.envKey, "PORT");
env[portEnvKey] = String(port);
}
const expose = parseObject(input.service.expose);
const readiness = parseObject(input.service.readiness);
const urlTemplate =
@@ -1619,7 +1617,7 @@ async function startLocalRuntimeService(input: {
provider: "local_process",
providerRef: String(adoptedRecord.pid),
ownerAgentId: input.agent.id ?? null,
startedByRunId: input.runId,
startedByRunId,
lastUsedAt: new Date().toISOString(),
startedAt: adoptedRecord.startedAt,
stoppedAt: null,
@@ -1628,7 +1626,7 @@ async function startLocalRuntimeService(input: {
reused: true,
db: input.db,
child: null,
leaseRunIds: new Set([input.runId]),
leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(),
idleTimer: null,
envFingerprint,
serviceKey,
@@ -1644,7 +1642,12 @@ async function startLocalRuntimeService(input: {
);
}
}
const shell = process.env.SHELL?.trim() || "/bin/sh";
await ensureServerWorkspaceLinksCurrent(serviceCwd, {
onLog: input.onLog,
});
const shell = resolveShell();
const child = spawn(shell, ["-lc", command], {
cwd: serviceCwd,
env,
@@ -1693,7 +1696,7 @@ async function startLocalRuntimeService(input: {
provider: "local_process",
providerRef: child.pid ? String(child.pid) : null,
ownerAgentId: input.agent.id ?? null,
startedByRunId: input.runId,
startedByRunId,
lastUsedAt: new Date().toISOString(),
startedAt: new Date().toISOString(),
stoppedAt: null,
@@ -1968,9 +1971,13 @@ export async function startRuntimeServicesForWorkspaceControl(input: {
}
}
// Manually controlled services are not tied to a heartbeat run lifecycle, so they do not
// retain a run lease and never persist a startedByRunId foreign key.
const record = await startLocalRuntimeService({
db: input.db,
runId: invocationId,
leaseRunId: null,
startedByRunId: null,
agent: input.actor,
issue: input.issue,
workspace: input.workspace,
@@ -1982,7 +1989,6 @@ export async function startRuntimeServicesForWorkspaceControl(input: {
scopeType,
scopeId,
});
record.startedByRunId = null;
registerRuntimeService(input.db, record);
await persistRuntimeServiceRecord(input.db, record);
refs.push(toRuntimeServiceRef(record));
@@ -2123,50 +2129,55 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
profileKind: "workspace-runtime",
});
if (adoptedRecord) {
const record: RuntimeServiceRecord = {
id: row.id,
companyId: row.companyId,
projectId: row.projectId ?? null,
projectWorkspaceId: row.projectWorkspaceId ?? null,
executionWorkspaceId: row.executionWorkspaceId ?? null,
issueId: row.issueId ?? null,
serviceName: row.serviceName,
status: "running",
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
scopeId: row.scopeId ?? null,
reuseKey: row.reuseKey ?? null,
command: row.command ?? null,
cwd: row.cwd ?? null,
port: adoptedRecord.port ?? row.port ?? null,
url: adoptedRecord.url ?? row.url ?? null,
provider: "local_process",
providerRef: String(adoptedRecord.pid),
ownerAgentId: row.ownerAgentId ?? null,
startedByRunId: row.startedByRunId ?? null,
lastUsedAt: new Date().toISOString(),
startedAt: row.startedAt.toISOString(),
stoppedAt: null,
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
healthStatus: "healthy",
reused: true,
db,
child: null,
leaseRunIds: new Set(),
idleTimer: null,
envFingerprint: row.reuseKey ?? "",
serviceKey: adoptedRecord.serviceKey,
profileKind: "workspace-runtime",
processGroupId: adoptedRecord.processGroupId ?? null,
};
registerRuntimeService(db, record);
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
runtimeServiceId: row.id,
lastSeenAt: record.lastUsedAt,
});
await persistRuntimeServiceRecord(db, record);
adopted += 1;
continue;
const adoptedUrl = adoptedRecord.url ?? row.url ?? null;
if (!(await isRuntimeServiceUrlHealthy(adoptedUrl))) {
await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
} else {
const record: RuntimeServiceRecord = {
id: row.id,
companyId: row.companyId,
projectId: row.projectId ?? null,
projectWorkspaceId: row.projectWorkspaceId ?? null,
executionWorkspaceId: row.executionWorkspaceId ?? null,
issueId: row.issueId ?? null,
serviceName: row.serviceName,
status: "running",
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
scopeId: row.scopeId ?? null,
reuseKey: row.reuseKey ?? null,
command: row.command ?? null,
cwd: row.cwd ?? null,
port: adoptedRecord.port ?? row.port ?? null,
url: adoptedRecord.url ?? row.url ?? null,
provider: "local_process",
providerRef: String(adoptedRecord.pid),
ownerAgentId: row.ownerAgentId ?? null,
startedByRunId: row.startedByRunId ?? null,
lastUsedAt: new Date().toISOString(),
startedAt: row.startedAt.toISOString(),
stoppedAt: null,
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
healthStatus: "healthy",
reused: true,
db,
child: null,
leaseRunIds: new Set(),
idleTimer: null,
envFingerprint: row.reuseKey ?? "",
serviceKey: adoptedRecord.serviceKey,
profileKind: "workspace-runtime",
processGroupId: adoptedRecord.processGroupId ?? null,
};
registerRuntimeService(db, record);
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
runtimeServiceId: row.id,
lastSeenAt: record.lastUsedAt,
});
await persistRuntimeServiceRecord(db, record);
adopted += 1;
continue;
}
}
const now = new Date();
+74 -107
View File
@@ -18,7 +18,10 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { ApprovalCard } from "./ApprovalCard";
import { AgentIcon } from "./AgentIconPicker";
import { formatDateTime } from "../lib/utils";
import { formatAssigneeUserLabel } from "../lib/assignees";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import { timeAgo } from "../lib/timeAgo";
import { cn, formatDateTime } from "../lib/utils";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import { PluginSlotOutlet } from "@/plugins/slots";
@@ -48,6 +51,10 @@ interface CommentReassignment {
interface CommentThreadProps {
comments: CommentWithRunMeta[];
queuedComments?: CommentWithRunMeta[];
linkedApprovals?: Approval[];
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
linkedRuns?: LinkedRunItem[];
timelineEvents?: IssueTimelineEvent[];
companyId?: string | null;
@@ -79,6 +86,7 @@ interface CommentThreadProps {
mentions?: MentionOption[];
onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null;
composerDisabledReason?: string | null;
}
const DRAFT_DEBOUNCE_MS = 800;
@@ -276,6 +284,11 @@ function CommentCard({
agentMap,
companyId,
projectId,
feedbackVote = null,
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
onVote,
voting = false,
highlightCommentId,
queued = false,
}: {
@@ -283,6 +296,14 @@ function CommentCard({
agentMap?: Map<string, Agent>;
companyId?: string | null;
projectId?: string | null;
feedbackVote?: FeedbackVoteValue | null;
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
onVote?: (
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
voting?: boolean;
highlightCommentId?: string | null;
queued?: boolean;
}) {
@@ -367,8 +388,31 @@ function CommentCard({
/>
</div>
) : null}
{comment.runId && !isPending ? (
<div className="mt-2 pt-2 border-t border-border/60">
{comment.authorAgentId && onVote && !isQueued && !isPending ? (
<OutputFeedbackButtons
activeVote={feedbackVote}
disabled={voting}
sharingPreference={feedbackDataSharingPreference}
termsUrl={feedbackTermsUrl}
onVote={onVote}
rightSlot={comment.runId && !isPending ? (
comment.runAgentId ? (
<Link
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
run {comment.runId.slice(0, 8)}
</Link>
) : (
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
run {comment.runId.slice(0, 8)}
</span>
)
) : undefined}
/>
) : null}
{comment.runId && !isPending && !(comment.authorAgentId && onVote && !isQueued) ? (
<div className="mt-3 pt-3 border-t border-border/60">
{comment.runAgentId ? (
<Link
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
@@ -574,6 +618,11 @@ const TimelineList = memo(function TimelineList({
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
feedbackVote={feedbackVoteByTargetId?.get(comment.id) ?? null}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={feedbackTermsUrl}
onVote={onVote ? (vote, options) => onVote(comment.id, vote, options) : undefined}
voting={votingTargetId === comment.id}
highlightCommentId={highlightCommentId}
/>
);
@@ -585,6 +634,10 @@ const TimelineList = memo(function TimelineList({
export const CommentThread = memo(function CommentThread({
comments,
queuedComments = [],
linkedApprovals = [],
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
linkedRuns = [],
timelineEvents = [],
companyId,
@@ -607,6 +660,7 @@ export const CommentThread = memo(function CommentThread({
mentions: providedMentions,
onInterruptQueued,
interruptingQueuedRunId = null,
composerDisabledReason = null,
}: CommentThreadProps) {
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
@@ -862,26 +916,8 @@ const CommentComposer = memo(function CommentComposer({
}, []);
useEffect(() => {
setReassignTarget(effectiveSuggestedAssigneeValue);
}, [effectiveSuggestedAssigneeValue]);
// Scroll to comment when URL hash matches #comment-{id}
useEffect(() => {
const hash = location.hash;
if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return;
const commentId = hash.slice("#comment-".length);
// Only scroll once per hash
if (hasScrolledRef.current) return;
const el = document.getElementById(`comment-${commentId}`);
if (el) {
hasScrolledRef.current = true;
setHighlightCommentId(commentId);
el.scrollIntoView({ behavior: "smooth", block: "center" });
// Clear highlight after animation
const timer = setTimeout(() => setHighlightCommentId(null), 3000);
return () => clearTimeout(timer);
}
}, [location.hash, comments, queuedComments]);
setReassignTarget(suggestedAssigneeValue);
}, [suggestedAssigneeValue]);
async function handleSubmit() {
const trimmed = body.trim();
@@ -896,7 +932,7 @@ const CommentComposer = memo(function CommentComposer({
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
if (draftKey) clearDraft(draftKey);
setReopen(true);
setReassignTarget(effectiveSuggestedAssigneeValue);
setReassignTarget(suggestedAssigneeValue);
} catch {
setBody((current) =>
restoreSubmittedCommentDraft({
@@ -904,7 +940,6 @@ const CommentComposer = memo(function CommentComposer({
submittedBody,
}),
);
// Parent mutation handlers surface the failure and the draft is restored for retry.
} finally {
setSubmitting(false);
}
@@ -932,88 +967,20 @@ const CommentComposer = memo(function CommentComposer({
const canSubmit = !submitting && !!body.trim();
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length + queuedComments.length})</h3>
{timeline.length > 0 ? (
<TimelineList
timeline={timeline}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
/>
) : null}
{liveRunSlot}
{queuedComments.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.14em] text-amber-700 dark:text-amber-300">
Queued Comments ({queuedComments.length})
</h4>
{onInterruptQueued && queuedComments[0]?.queueTargetRunId ? (
<Button
size="sm"
variant="outline"
className="border-red-300 text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
disabled={interruptingQueuedRunId === queuedComments[0].queueTargetRunId}
onClick={() => void onInterruptQueued(queuedComments[0]!.queueTargetRunId!)}
>
{interruptingQueuedRunId === queuedComments[0].queueTargetRunId ? "Interrupting..." : "Interrupt"}
</Button>
) : null}
</div>
<div className="space-y-3">
{queuedComments.map((comment) => (
<CommentCard
key={comment.id}
comment={comment}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
queued
/>
))}
</div>
</div>
)}
<div className="space-y-2">
<MarkdownEditor
ref={editorRef}
value={body}
onChange={setBody}
placeholder="Leave a comment..."
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={imageUploadHandler}
contentClassName="min-h-[60px] text-sm"
/>
<div className="flex items-center justify-end gap-3">
{(imageUploadHandler || onAttachImage) && (
<div className="mr-auto flex items-center gap-3">
<input
ref={attachInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleAttachFile}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => attachInputRef.current?.click()}
disabled={attaching}
title="Attach image"
>
<Paperclip className="h-4 w-4" />
</Button>
</div>
)}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<div className="space-y-2">
<MarkdownEditor
ref={editorRef}
value={body}
onChange={setBody}
placeholder="Leave a comment..."
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={imageUploadHandler}
contentClassName="min-h-[60px] text-sm"
/>
<div className="flex items-center justify-end gap-3">
{(imageUploadHandler || onAttachImage) && (
<div className="mr-auto flex items-center gap-3">
<input
ref={attachInputRef}
type="file"
+59
View File
@@ -113,4 +113,63 @@ describe("IssueRow", () => {
root.unmount();
});
});
it("preserves the issue detail breadcrumb source and href in the link target", () => {
const root = createRoot(container);
const issue = createIssue();
const state = {
issueDetailBreadcrumb: { label: "Inbox", href: "/PAP/inbox/mine" },
issueDetailSource: "inbox",
};
act(() => {
root.render(<IssueRow issue={issue} issueLinkState={state} />);
});
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toBe("/issues/PAP-1");
act(() => {
root.unmount();
});
});
it("renders titleSuffix inline after the issue title", () => {
const root = createRoot(container);
const issue = createIssue({ title: "Parent task" });
act(() => {
root.render(
<IssueRow
issue={issue}
titleSuffix={<span data-testid="suffix">(3 sub-tasks)</span>}
/>,
);
});
const titleEl = container.querySelector(".line-clamp-2, .truncate");
expect(titleEl?.textContent).toContain("Parent task");
expect(titleEl?.textContent).toContain("(3 sub-tasks)");
expect(container.querySelector('[data-testid="suffix"]')).not.toBeNull();
act(() => {
root.unmount();
});
});
it("renders without error when titleSuffix is omitted", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} />);
});
const titleEl = container.querySelector(".line-clamp-2, .truncate");
expect(titleEl?.textContent).toContain("Inbox item");
act(() => {
root.unmount();
});
});
});
+3 -2
View File
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { X } from "lucide-react";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { createIssueDetailPath, rememberIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
@@ -51,9 +51,10 @@ export function IssueRow({
return (
<Link
to={createIssueDetailPath(issuePathId, issueLinkState)}
to={createIssueDetailPath(issuePathId)}
state={issueLinkState}
data-inbox-issue-link
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, issueLinkState)}
className={cn(
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
+25 -105
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen, LogOut, Moon, Settings, Sun } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
import { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar";
@@ -25,7 +25,6 @@ import { useSidebar } from "../context/SidebarContext";
import { useTheme } from "../context/ThemeContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { authApi, type AuthSession } from "../api/auth";
import { healthApi } from "../api/health";
import { instanceSettingsApi } from "../api/instanceSettings";
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
@@ -38,16 +37,7 @@ import { scheduleMainContentFocus } from "../lib/main-content-focus";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Identity } from "./Identity";
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
@@ -60,78 +50,7 @@ function readRememberedInstanceSettingsPath(): string {
}
}
function getSessionDisplayName(session: AuthSession) {
return session.user.name?.trim() || session.user.email?.trim() || session.user.id;
}
function FooterPrimaryAction({
session,
onSignOut,
signingOut,
}: {
session: AuthSession | null;
onSignOut: () => Promise<void>;
signingOut: boolean;
}) {
if (!session) {
return (
<a
href="https://docs.paperclip.ing/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
);
}
const displayName = getSessionDisplayName(session);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-testid="layout-account-menu-trigger"
className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent/50"
aria-label="Open account menu"
>
<Identity name={displayName} size="sm" className="min-w-0 flex-1" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="space-y-0.5">
<div className="truncate text-sm font-medium">{displayName}</div>
<div className="truncate text-xs font-normal text-muted-foreground">
{session.user.email?.trim() || session.user.id}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href="https://docs.paperclip.ing/" target="_blank" rel="noopener noreferrer">
<BookOpen className="h-4 w-4" />
Documentation
</a>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="layout-account-menu-sign-out"
disabled={signingOut}
onClick={() => {
void onSignOut();
}}
>
<LogOut className="h-4 w-4" />
{signingOut ? "Signing out..." : "Sign out"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function Layout() {
const queryClient = useQueryClient();
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog();
const { togglePanelVisible } = usePanel();
@@ -152,7 +71,7 @@ export function Layout() {
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
const [isSigningOut, setIsSigningOut] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const nextTheme = theme === "dark" ? "light" : "dark";
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
@@ -171,25 +90,10 @@ export function Layout() {
},
refetchIntervalInBackground: true,
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
enabled: health?.deploymentMode === "authenticated",
retry: false,
});
const handleSignOut = useCallback(async () => {
if (isSigningOut) return;
setIsSigningOut(true);
try {
await authApi.signOut();
queryClient.setQueryData(queryKeys.auth.session, null);
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
navigate("/auth", { replace: true });
} finally {
setIsSigningOut(false);
}
}, [isSigningOut, navigate, queryClient]);
const keyboardShortcutsEnabled = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.keyboardShortcuts === true;
useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return;
@@ -410,7 +314,15 @@ export function Layout() {
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
<div className="flex items-center gap-1">
<FooterPrimaryAction session={session ?? null} onSignOut={handleSignOut} signingOut={isSigningOut} />
<a
href="https://docs.paperclip.ing/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
{health?.version && (
<Tooltip>
<TooltipTrigger asChild>
@@ -460,7 +372,15 @@ export function Layout() {
</div>
<div className="border-t border-r border-border px-3 py-2">
<div className="flex items-center gap-1">
<FooterPrimaryAction session={session ?? null} onSignOut={handleSignOut} signingOut={isSigningOut} />
<a
href="https://docs.paperclip.ing/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
{health?.version && (
<Tooltip>
<TooltipTrigger asChild>
+2 -9
View File
@@ -24,6 +24,7 @@ import { queryKeys } from "../lib/queryKeys";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { Button } from "@/components/ui/button";
import { PluginSlotOutlet } from "@/plugins/slots";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
export function Sidebar() {
const { openNewIssue } = useDialog();
@@ -50,15 +51,7 @@ export function Sidebar() {
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
{selectedCompany?.brandColor && (
<div
className="w-4 h-4 rounded-sm shrink-0 ml-1"
style={{ backgroundColor: selectedCompany.brandColor }}
/>
)}
<span className="flex-1 text-sm font-bold text-foreground truncate pl-1">
{selectedCompany?.name ?? "Select company"}
</span>
<SidebarCompanyMenu />
<Button
variant="ghost"
size="icon-sm"
@@ -0,0 +1,125 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
signInEmail: vi.fn(),
signUpEmail: vi.fn(),
getProfile: vi.fn(),
updateProfile: vi.fn(),
signOut: vi.fn(),
}));
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
vi.mock("@/api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompany: {
id: "company-1",
name: "Acme Labs",
brandColor: "#3366ff",
},
}),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({
isMobile: false,
setSidebarOpen: mockSetSidebarOpen,
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("SidebarCompanyMenu", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
},
});
mockAuthApi.signOut.mockResolvedValue(undefined);
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("shows the requested company actions and signs out through the dropdown", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenu />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Acme Labs");
const trigger = container.querySelector('button[aria-label="Open Acme Labs menu"]');
expect(trigger).not.toBeNull();
await act(async () => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(document.body.textContent).toContain("Invite people to Acme Labs");
expect(document.body.textContent).toContain("Company settings");
expect(document.body.textContent).toContain("Sign out");
const signOutButton = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Sign out"));
expect(signOutButton).toBeTruthy();
await act(async () => {
signOutButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockAuthApi.signOut).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
});
});
});
+102
View File
@@ -0,0 +1,102 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, LogOut, Settings, UserPlus } from "lucide-react";
import { Link } from "@/lib/router";
import { authApi } from "@/api/auth";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useCompany } from "@/context/CompanyContext";
import { queryKeys } from "@/lib/queryKeys";
import { useSidebar } from "../context/SidebarContext";
export function SidebarCompanyMenu() {
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const { selectedCompany } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
retry: false,
});
const signOutMutation = useMutation({
mutationFn: () => authApi.signOut(),
onSuccess: async () => {
setOpen(false);
if (isMobile) setSidebarOpen(false);
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
},
});
function closeNavigationChrome() {
setOpen(false);
if (isMobile) setSidebarOpen(false);
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-auto flex-1 justify-start gap-1 px-2 py-1.5 text-left"
aria-label={selectedCompany ? `Open ${selectedCompany.name} menu` : "Open company menu"}
disabled={!selectedCompany}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{selectedCompany?.brandColor ? (
<span
className="size-4 shrink-0 rounded-sm"
style={{ backgroundColor: selectedCompany.brandColor }}
/>
) : null}
<span className="truncate text-sm font-bold text-foreground">
{selectedCompany?.name ?? "Select company"}
</span>
</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="truncate">
{selectedCompany?.name ?? "Company"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/company/settings/invites" onClick={closeNavigationChrome}>
<UserPlus className="size-4" />
<span className="truncate">
{selectedCompany ? `Invite people to ${selectedCompany.name}` : "Invite people"}
</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/company/settings" onClick={closeNavigationChrome}>
<Settings className="size-4" />
<span>Company settings</span>
</Link>
</DropdownMenuItem>
{session?.session ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => signOutMutation.mutate()}
disabled={signOutMutation.isPending}
>
<LogOut className="size-4" />
<span>{signOutMutation.isPending ? "Signing out..." : "Sign out"}</span>
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
+5
View File
@@ -20,11 +20,16 @@ import {
getInboxKeyboardSelectionIndex,
getRecentTouchedIssues,
getUnreadTouchedIssues,
isInboxEntityDismissed,
isMineInboxTab,
loadInboxIssueColumns,
loadLastInboxTab,
normalizeInboxIssueColumns,
RECENT_ISSUES_LIMIT,
resolveInboxNestingEnabled,
resolveIssueWorkspaceName,
resolveInboxSelectionIndex,
saveInboxIssueColumns,
saveLastInboxTab,
shouldShowInboxSection,
} from "./inbox";
+60 -8
View File
@@ -1,34 +1,86 @@
import { describe, expect, it } from "vitest";
import {
armIssueDetailInboxQuickArchive,
createIssueDetailLocationState,
createIssueDetailPath,
hasLegacyIssueDetailQuery,
readIssueDetailLocationState,
readIssueDetailBreadcrumb,
rememberIssueDetailLocationState,
shouldArmIssueDetailInboxQuickArchive,
} from "./issueDetailBreadcrumb";
const sessionStorageMock = (() => {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
clear: () => {
store.clear();
},
};
})();
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { sessionStorage: sessionStorageMock },
});
describe("issueDetailBreadcrumb", () => {
it("returns clean issue detail paths", () => {
expect(createIssueDetailPath("PAP-465")).toBe("/issues/PAP-465");
});
it("prefers the full breadcrumb from route state", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({
expect(readIssueDetailBreadcrumb("PAP-465", state, "?from=issues")).toEqual({
label: "Inbox",
href: "/inbox/mine",
});
});
it("falls back to the source query param when route state is unavailable", () => {
expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({
expect(readIssueDetailBreadcrumb("PAP-465", null, "?from=inbox")).toEqual({
label: "Inbox",
href: "/inbox",
});
});
it("adds the source query param when building an issue detail path", () => {
it("can detect legacy query-based breadcrumb links", () => {
expect(hasLegacyIssueDetailQuery("?from=inbox&fromHref=%2Finbox%2Fmine")).toBe(true);
expect(hasLegacyIssueDetailQuery("?q=test")).toBe(false);
});
it("restores the exact breadcrumb href from the query fallback", () => {
expect(
readIssueDetailBreadcrumb("PAP-465", null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
).toEqual({
label: "Inbox",
href: "/PAP/inbox/unread",
});
});
it("reads hidden breadcrumb context from session storage when route state is unavailable", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
sessionStorageMock.clear();
rememberIssueDetailLocationState("PAP-465", state);
expect(
readIssueDetailLocationState("PAP-465", null),
).toEqual({
issueDetailBreadcrumb: { label: "Inbox", href: "/inbox/mine" },
issueDetailSource: "inbox",
issueDetailInboxQuickArchiveArmed: false,
});
});
it("can arm quick archive only for explicit inbox keyboard entry state", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox");
});
it("reuses the current source query param when state has been dropped", () => {
expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues");
expect(shouldArmIssueDetailInboxQuickArchive(state)).toBe(false);
expect(shouldArmIssueDetailInboxQuickArchive(armIssueDetailInboxQuickArchive(state))).toBe(true);
});
});
+121 -9
View File
@@ -8,9 +8,12 @@ type IssueDetailBreadcrumb = {
type IssueDetailLocationState = {
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
issueDetailSource?: IssueDetailSource;
issueDetailInboxQuickArchiveArmed?: boolean;
};
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
const ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM = "fromHref";
const ISSUE_DETAIL_STORAGE_KEY_PREFIX = "paperclip:issue-detail-breadcrumb:";
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
if (typeof value !== "object" || value === null) return false;
@@ -35,6 +38,24 @@ function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | n
return isIssueDetailSource(source) ? source : null;
}
function readIssueDetailBreadcrumbHrefFromSearch(search?: string): string | null {
if (!search) return null;
const params = new URLSearchParams(search);
const href = params.get(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM);
return href && href.startsWith("/") ? href : null;
}
function inferIssueDetailSource(
state: Partial<IssueDetailLocationState> | null,
breadcrumb: IssueDetailBreadcrumb | null,
): IssueDetailSource | null {
if (isIssueDetailSource(state?.issueDetailSource)) return state.issueDetailSource;
if (!breadcrumb) return null;
if (breadcrumb.label === "Inbox" || breadcrumb.href.includes("/inbox")) return "inbox";
if (breadcrumb.label === "Issues" || breadcrumb.href.includes("/issues")) return "issues";
return null;
}
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
return { label: "Issues", href: "/issues" };
@@ -51,20 +72,111 @@ export function createIssueDetailLocationState(
};
}
export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
if (!source) return `/issues/${issuePathId}`;
const params = new URLSearchParams();
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
return `/issues/${issuePathId}?${params.toString()}`;
export function armIssueDetailInboxQuickArchive(state: unknown): IssueDetailLocationState {
if (typeof state !== "object" || state === null) {
return { issueDetailInboxQuickArchiveArmed: true };
}
return {
...(state as IssueDetailLocationState),
issueDetailInboxQuickArchiveArmed: true,
};
}
export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null {
function readStoredIssueDetailLocationState(issuePathId: string): IssueDetailLocationState | null {
if (typeof window === "undefined" || !window.sessionStorage) return null;
const raw = window.sessionStorage.getItem(`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<IssueDetailLocationState>;
const breadcrumb = isIssueDetailBreadcrumb(parsed.issueDetailBreadcrumb)
? parsed.issueDetailBreadcrumb
: null;
const source = inferIssueDetailSource(parsed, breadcrumb);
if (!breadcrumb || !source) return null;
return {
issueDetailBreadcrumb: breadcrumb,
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed: parsed.issueDetailInboxQuickArchiveArmed === true,
};
} catch {
return null;
}
}
function normalizeIssueDetailLocationState(
state: unknown,
search?: string,
): IssueDetailLocationState | null {
if (typeof state === "object" && state !== null) {
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
if (isIssueDetailBreadcrumb(candidate)) return candidate;
if (isIssueDetailBreadcrumb(candidate)) {
const source = inferIssueDetailSource(state as Partial<IssueDetailLocationState>, candidate);
if (!source) return null;
return {
issueDetailBreadcrumb: candidate,
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed:
(state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true,
};
}
}
const source = readIssueDetailSourceFromSearch(search);
return source ? breadcrumbForSource(source) : null;
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
if (!source) return null;
return {
issueDetailBreadcrumb: href ? { ...breadcrumbForSource(source), href } : breadcrumbForSource(source),
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed: false,
};
}
export function rememberIssueDetailLocationState(issuePathId: string, state: unknown, search?: string): void {
if (typeof window === "undefined" || !window.sessionStorage) return;
const normalized = normalizeIssueDetailLocationState(state, search);
if (!normalized) return;
window.sessionStorage.setItem(
`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`,
JSON.stringify(normalized),
);
}
export function createIssueDetailPath(issuePathId: string): string {
return `/issues/${issuePathId}`;
}
export function hasLegacyIssueDetailQuery(search?: string): boolean {
if (!search) return false;
const params = new URLSearchParams(search);
return params.has(ISSUE_DETAIL_SOURCE_QUERY_PARAM) || params.has(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM);
}
export function readIssueDetailLocationState(
issuePathId: string | null | undefined,
state: unknown,
search?: string,
): IssueDetailLocationState | null {
const normalized = normalizeIssueDetailLocationState(state, search);
if (normalized) return normalized;
if (!issuePathId) return null;
return readStoredIssueDetailLocationState(issuePathId);
}
export function readIssueDetailBreadcrumb(
issuePathId: string | null | undefined,
state: unknown,
search?: string,
): IssueDetailBreadcrumb | null {
return readIssueDetailLocationState(issuePathId, state, search)?.issueDetailBreadcrumb ?? null;
}
export function shouldArmIssueDetailInboxQuickArchive(state: unknown): boolean {
if (typeof state !== "object" || state === null) return false;
return (state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true;
}
@@ -1,10 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Issue } from "@paperclipai/shared";
import {
applyOptimisticIssueFieldUpdate,
applyOptimisticIssueFieldUpdateToCollection,
applyOptimisticIssueCommentUpdate,
createOptimisticIssueComment,
flattenIssueCommentPages,
getNextIssueCommentPageParam,
isQueuedIssueComment,
matchesIssueRef,
mergeIssueComments,
upsertIssueComment,
upsertIssueCommentInPages,
} from "./optimistic-issue-comments";
describe("optimistic issue comments", () => {
@@ -124,6 +131,125 @@ describe("optimistic issue comments", () => {
expect(next[0]?.body).toBe("Updated");
});
it("flattens paged comments into one chronological thread", () => {
const flattened = flattenIssueCommentPages([
[
{
id: "comment-3",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
],
[
{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
{
id: "comment-2",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Middle",
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
],
]);
expect(flattened.map((comment) => comment.id)).toEqual(["comment-1", "comment-2", "comment-3"]);
});
it("returns no next page param when the last page is missing", () => {
expect(getNextIssueCommentPageParam(undefined, 50)).toBeUndefined();
});
it("returns the oldest id when the last page is full", () => {
expect(
getNextIssueCommentPageParam(
[
{
id: "comment-2",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Second",
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "First",
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
],
2,
),
).toBe("comment-1");
});
it("upserts paged comments without dropping older pages", () => {
const nextPages = upsertIssueCommentInPages(
[
[
{
id: "comment-3",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
],
[
{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
],
],
{
id: "comment-4",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Brand new",
createdAt: new Date("2026-03-28T14:00:04.000Z"),
updatedAt: new Date("2026-03-28T14:00:04.000Z"),
},
);
expect(nextPages[0]?.map((comment) => comment.id)).toEqual(["comment-4", "comment-3"]);
expect(nextPages[1]?.map((comment) => comment.id)).toEqual(["comment-1"]);
});
it("applies optimistic reopen and reassignment updates to the issue cache", () => {
const next = applyOptimisticIssueCommentUpdate(
{
@@ -177,6 +303,267 @@ describe("optimistic issue comments", () => {
expect(next?.assigneeUserId).toBe("board-2");
});
it("applies optimistic field updates for issue property edits", () => {
const next = applyOptimisticIssueFieldUpdate(
{
id: "issue-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
goalId: null,
parentId: null,
title: "Fix property pane",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "board-1",
issueNumber: 1,
identifier: "PAP-1",
originKind: "manual",
originId: null,
originRunId: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: "exec-1",
executionWorkspacePreference: "shared_workspace",
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labelIds: ["label-1", "label-2"],
labels: [
{
id: "label-1",
companyId: "company-1",
name: "One",
color: "#111111",
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
{
id: "label-2",
companyId: "company-1",
name: "Two",
color: "#222222",
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
],
blockedBy: [
{
id: "issue-2",
identifier: "PAP-2",
title: "First blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
{
id: "issue-3",
identifier: "PAP-3",
title: "Second blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
project: {
id: "project-1",
companyId: "company-1",
urlKey: "project-one",
goalId: null,
goalIds: [],
goals: [],
name: "Project one",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/paperclip",
effectiveLocalFolder: "/tmp/paperclip",
origin: "local_folder",
},
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
currentExecutionWorkspace: {
id: "exec-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
sourceIssueId: "issue-1",
mode: "shared_workspace",
strategyType: "project_primary",
branchName: null,
status: "active",
name: "Execution workspace",
cwd: "/tmp/paperclip",
repoUrl: null,
baseRef: null,
providerType: "local_fs",
providerRef: null,
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date("2026-03-28T14:00:00.000Z"),
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
openedAt: new Date("2026-03-28T14:00:00.000Z"),
closedAt: null,
},
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
{
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "board-2",
labelIds: ["label-2"],
blockedByIssueIds: ["issue-3"],
projectId: "project-2",
executionWorkspaceId: "exec-2",
},
);
expect(next?.status).toBe("in_review");
expect(next?.assigneeAgentId).toBeNull();
expect(next?.assigneeUserId).toBe("board-2");
expect(next?.labelIds).toEqual(["label-2"]);
expect(next?.labels?.map((label) => label.id)).toEqual(["label-2"]);
expect(next?.blockedBy?.map((relation) => relation.id)).toEqual(["issue-3"]);
expect(next?.projectId).toBe("project-2");
expect(next?.project).toBeNull();
expect(next?.executionWorkspaceId).toBe("exec-2");
expect(next?.currentExecutionWorkspace).toBeNull();
});
it("matches issues by either uuid or identifier reference", () => {
expect(matchesIssueRef({ id: "issue-1", identifier: "PAP-1" } as const, ["issue-1"])).toBe(true);
expect(matchesIssueRef({ id: "issue-1", identifier: "PAP-1" } as const, ["PAP-1"])).toBe(true);
expect(matchesIssueRef({ id: "issue-1", identifier: "PAP-1" } as const, ["issue-2", "PAP-2"])).toBe(false);
});
it("applies optimistic field updates across cached issue collections", () => {
const issues: Issue[] = [
{
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Fix property pane",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "board-1",
issueNumber: 1,
identifier: "PAP-1",
originKind: "manual",
originId: null,
originRunId: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labelIds: [],
labels: [],
blockedBy: [],
blocks: [],
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
{
id: "issue-2",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Leave me alone",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: "agent-2",
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "board-1",
issueNumber: 2,
identifier: "PAP-2",
originKind: "manual",
originId: null,
originRunId: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labelIds: [],
labels: [],
blockedBy: [],
blocks: [],
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
];
const next = applyOptimisticIssueFieldUpdateToCollection(issues, ["PAP-1"], { assigneeAgentId: "agent-9" });
expect(next?.[0]?.assigneeAgentId).toBe("agent-9");
expect(next?.[1]?.assigneeAgentId).toBe("agent-2");
});
it("treats comments without a run id as queued when they arrive during an active run", () => {
expect(
isQueuedIssueComment({
@@ -212,4 +599,18 @@ describe("optimistic issue comments", () => {
}),
).toBe(false);
});
it("does not mark comments from the active run agent as queued", () => {
expect(
isQueuedIssueComment({
comment: {
createdAt: new Date("2026-03-28T16:20:05.000Z"),
authorAgentId: "agent-1",
},
activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"),
activeRunAgentId: "agent-1",
runId: null,
}),
).toBe(false);
});
});
+129 -1
View File
@@ -33,6 +33,10 @@ export function sortIssueComments<T extends { createdAt: Date | string; id: stri
});
}
function sortIssueCommentsDesc<T extends { createdAt: Date | string; id: string }>(comments: T[]) {
return sortIssueComments(comments).reverse();
}
export function createOptimisticIssueComment(params: {
companyId: string;
issueId: string;
@@ -59,13 +63,20 @@ export function createOptimisticIssueComment(params: {
}
export function isQueuedIssueComment(params: {
comment: Pick<IssueTimelineComment, "createdAt"> & Partial<Pick<OptimisticIssueComment, "clientStatus">>;
comment: Pick<IssueTimelineComment, "createdAt"> &
Partial<Pick<OptimisticIssueComment, "clientStatus">> & {
authorAgentId?: string | null;
};
activeRunStartedAt?: Date | string | null;
activeRunAgentId?: string | null;
runId?: string | null;
interruptedRunId?: string | null;
}) {
if (params.runId) return false;
if (params.interruptedRunId) return false;
if (params.comment.authorAgentId && params.activeRunAgentId && params.comment.authorAgentId === params.activeRunAgentId) {
return false;
}
if (params.comment.clientStatus === "queued") return true;
if (!params.activeRunStartedAt) return false;
return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt);
@@ -85,6 +96,20 @@ export function mergeIssueComments(
return sortIssueComments(merged);
}
export function flattenIssueCommentPages(
pages: ReadonlyArray<ReadonlyArray<IssueComment>> | undefined,
): IssueComment[] {
return sortIssueComments((pages ?? []).flatMap((page) => page));
}
export function getNextIssueCommentPageParam(
lastPage: ReadonlyArray<IssueComment> | undefined,
pageSize: number,
): string | undefined {
if (!lastPage || lastPage.length < pageSize) return undefined;
return lastPage[lastPage.length - 1]?.id;
}
export function upsertIssueComment(
comments: IssueComment[] | undefined,
nextComment: IssueComment,
@@ -121,3 +146,106 @@ export function applyOptimisticIssueCommentUpdate(
return nextIssue;
}
export function applyOptimisticIssueFieldUpdate(
issue: Issue | undefined,
data: Record<string, unknown>,
) {
if (!issue) return issue;
const nextIssue: Issue = {
...issue,
updatedAt: new Date(),
};
const hasOwn = (key: string) => Object.prototype.hasOwnProperty.call(data, key);
const assign = <K extends keyof Issue>(key: K) => {
if (hasOwn(key)) {
nextIssue[key] = data[key] as Issue[K];
}
};
assign("status");
assign("priority");
assign("assigneeAgentId");
assign("assigneeUserId");
assign("projectId");
assign("projectWorkspaceId");
assign("executionWorkspaceId");
assign("executionWorkspacePreference");
assign("executionWorkspaceSettings");
assign("hiddenAt");
if (hasOwn("labelIds") && Array.isArray(data.labelIds)) {
const nextLabelIds = data.labelIds.filter((value): value is string => typeof value === "string");
nextIssue.labelIds = nextLabelIds;
if (issue.labels) {
nextIssue.labels = issue.labels.filter((label) => nextLabelIds.includes(label.id));
}
}
if (hasOwn("blockedByIssueIds") && Array.isArray(data.blockedByIssueIds) && issue.blockedBy) {
const nextBlockedByIds = new Set(
data.blockedByIssueIds.filter((value): value is string => typeof value === "string"),
);
nextIssue.blockedBy = issue.blockedBy.filter((relation) => nextBlockedByIds.has(relation.id));
}
if (hasOwn("projectId")) {
nextIssue.project = issue.project?.id === nextIssue.projectId ? issue.project : null;
}
if (hasOwn("executionWorkspaceId")) {
nextIssue.currentExecutionWorkspace =
issue.currentExecutionWorkspace?.id === nextIssue.executionWorkspaceId
? issue.currentExecutionWorkspace
: null;
}
return nextIssue;
}
export function matchesIssueRef(
issue: Pick<Issue, "id" | "identifier">,
refs: Iterable<string>,
) {
const refSet = refs instanceof Set ? refs : new Set(refs);
return refSet.has(issue.id) || (!!issue.identifier && refSet.has(issue.identifier));
}
export function applyOptimisticIssueFieldUpdateToCollection(
issues: Issue[] | undefined,
refs: Iterable<string>,
data: Record<string, unknown>,
) {
if (!issues) return issues;
let changed = false;
const nextIssues = issues.map((issue) => {
if (!matchesIssueRef(issue, refs)) return issue;
changed = true;
return applyOptimisticIssueFieldUpdate(issue, data) ?? issue;
});
return changed ? nextIssues : issues;
}
export function upsertIssueCommentInPages(
pages: ReadonlyArray<ReadonlyArray<IssueComment>> | undefined,
nextComment: IssueComment,
): IssueComment[][] {
if (!pages || pages.length === 0) {
return [[nextComment]];
}
const nextPages = pages.map((page) => [...page]);
for (let pageIndex = 0; pageIndex < nextPages.length; pageIndex += 1) {
const existingIndex = nextPages[pageIndex]!.findIndex((comment) => comment.id === nextComment.id);
if (existingIndex === -1) continue;
nextPages[pageIndex]![existingIndex] = nextComment;
nextPages[pageIndex] = sortIssueCommentsDesc(nextPages[pageIndex]!);
return nextPages;
}
nextPages[0] = sortIssueCommentsDesc([...nextPages[0]!, nextComment]);
return nextPages;
}
+2 -1
View File
@@ -39,6 +39,8 @@ export const queryKeys = {
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
listByProject: (companyId: string, projectId: string) =>
["issues", companyId, "project", projectId] as const,
listByParent: (companyId: string, parentId: string) =>
["issues", companyId, "parent", parentId] as const,
listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) =>
["issues", companyId, "execution-workspace", executionWorkspaceId] as const,
detail: (id: string) => ["issues", "detail", id] as const,
@@ -89,7 +91,6 @@ export const queryKeys = {
joinRequests: (companyId: string, status: string = "pending_approval") =>
["access", "join-requests", companyId, status] as const,
invite: (token: string) => ["access", "invite", token] as const,
members: (companyId: string) => ["access", "members", companyId] as const,
},
auth: {
session: ["auth", "session"] as const,
+25 -252
View File
@@ -1,10 +1,12 @@
import { ChangeEvent, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@/lib/router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION } from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { companiesApi } from "../api/companies";
import { accessApi, type HumanCompanyRole } from "../api/access";
import { accessApi } from "../api/access";
import { assetsApi } from "../api/assets";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
@@ -22,12 +24,7 @@ type AgentSnippetInput = {
testResolutionUrl?: string | null;
};
const HUMAN_ROLE_LABELS: Record<HumanCompanyRole, string> = {
owner: "Owner",
admin: "Admin",
operator: "Operator",
viewer: "Viewer",
};
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
export function CompanySettings() {
const {
@@ -59,10 +56,6 @@ export function CompanySettings() {
const [inviteSnippet, setInviteSnippet] = useState<string | null>(null);
const [snippetCopied, setSnippetCopied] = useState(false);
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
const [humanInviteRole, setHumanInviteRole] = useState<HumanCompanyRole>("operator");
const [humanInviteUrl, setHumanInviteUrl] = useState<string | null>(null);
const [humanInviteCopied, setHumanInviteCopied] = useState(false);
const [humanInviteError, setHumanInviteError] = useState<string | null>(null);
const generalDirty =
!!selectedCompany &&
@@ -91,27 +84,27 @@ export function CompanySettings() {
}
});
const membersQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.access.members(selectedCompanyId) : ["access", "members", "none"],
queryFn: () => accessApi.listMembers(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
retry: false,
const feedbackSharingMutation = useMutation({
mutationFn: (enabled: boolean) =>
companiesApi.update(selectedCompanyId!, {
feedbackDataSharingEnabled: enabled,
}),
onSuccess: (_company, enabled) => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
pushToast({
title: enabled ? "Feedback sharing enabled" : "Feedback sharing disabled",
tone: "success",
});
},
onError: (err) => {
pushToast({
title: "Failed to update feedback sharing",
body: err instanceof Error ? err.message : "Unknown error",
tone: "error",
});
},
});
const memberAccess = membersQuery.data?.access;
const canManageMembers = memberAccess?.canManageMembers ?? false;
const canInviteUsers = memberAccess?.canInviteUsers ?? false;
const availableHumanInviteRoles: HumanCompanyRole[] =
memberAccess?.currentUserRole === "owner"
? ["owner", "admin", "operator", "viewer"]
: ["operator", "viewer"];
useEffect(() => {
if (!availableHumanInviteRoles.includes(humanInviteRole)) {
setHumanInviteRole(availableHumanInviteRoles[0] ?? "operator");
}
}, [availableHumanInviteRoles, humanInviteRole]);
const inviteMutation = useMutation({
mutationFn: () =>
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
@@ -165,54 +158,6 @@ export function CompanySettings() {
}
});
const humanInviteMutation = useMutation({
mutationFn: () =>
accessApi.createCompanyInvite(selectedCompanyId!, {
allowedJoinTypes: "human",
humanRole: humanInviteRole,
}),
onSuccess: async (invite) => {
const base = window.location.origin.replace(/\/+$/, "");
const absoluteUrl = invite.inviteUrl.startsWith("http")
? invite.inviteUrl
: `${base}${invite.inviteUrl}`;
setHumanInviteError(null);
setHumanInviteUrl(absoluteUrl);
try {
await navigator.clipboard.writeText(absoluteUrl);
setHumanInviteCopied(true);
setTimeout(() => setHumanInviteCopied(false), 2000);
} catch {
/* clipboard may not be available */
}
await queryClient.invalidateQueries({
queryKey: queryKeys.access.members(selectedCompanyId!),
});
},
onError: (err) => {
setHumanInviteError(
err instanceof Error ? err.message : "Failed to create invite"
);
},
});
const memberMutation = useMutation({
mutationFn: ({
memberId,
membershipRole,
status,
}: {
memberId: string;
membershipRole?: HumanCompanyRole;
status?: "active" | "suspended";
}) => accessApi.updateMember(selectedCompanyId!, memberId, { membershipRole, status }),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: queryKeys.access.members(selectedCompanyId!),
});
},
});
const syncLogoState = (nextLogoUrl: string | null) => {
setLogoUrl(nextLogoUrl ?? "");
void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
@@ -254,9 +199,6 @@ export function CompanySettings() {
setInviteSnippet(null);
setSnippetCopied(false);
setSnippetCopyDelightId(0);
setHumanInviteError(null);
setHumanInviteUrl(null);
setHumanInviteCopied(false);
}, [selectedCompanyId]);
const archiveMutation = useMutation({
@@ -475,8 +417,7 @@ export function CompanySettings() {
</div>
</div>
{/* Invites */}
<div className="space-y-4" data-testid="company-settings-invites-section">
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Feedback Sharing
</div>
@@ -524,66 +465,6 @@ export function CompanySettings() {
Invites
</div>
<div className="space-y-3 rounded-md border border-border px-4 py-4">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
Invite a human teammate.
</span>
<HintIcon text="Creates a company invite link for authenticated human users." />
</div>
{canInviteUsers ? (
<>
<div className="flex flex-wrap items-center gap-2">
<select
data-testid="company-settings-human-invite-role"
className="rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={humanInviteRole}
onChange={(e) => setHumanInviteRole(e.target.value as HumanCompanyRole)}
>
{availableHumanInviteRoles.map((role) => (
<option key={role} value={role}>
{HUMAN_ROLE_LABELS[role]}
</option>
))}
</select>
<Button
data-testid="company-settings-create-human-invite"
size="sm"
onClick={() => humanInviteMutation.mutate()}
disabled={humanInviteMutation.isPending}
>
{humanInviteMutation.isPending ? "Creating..." : "Create Human Invite"}
</Button>
{humanInviteCopied && (
<span className="text-xs text-green-600">Copied</span>
)}
</div>
{humanInviteError && (
<p className="text-sm text-destructive">{humanInviteError}</p>
)}
{humanInviteUrl && (
<div className="rounded-md border border-border bg-muted/30 p-2">
<div className="text-xs text-muted-foreground">
Human invite link
</div>
<textarea
data-testid="company-settings-human-invite-url"
className="mt-1 h-24 w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
value={humanInviteUrl}
readOnly
/>
</div>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
Your current company role cannot create human invites.
</p>
)}
</div>
<div className="border-t border-border pt-3" />
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
Generate an OpenClaw agent invite snippet.
@@ -656,114 +537,6 @@ export function CompanySettings() {
</div>
</div>
<div
className="space-y-4"
data-testid="company-settings-team-section"
>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Team
</div>
<div
className="space-y-3 rounded-md border border-border px-4 py-4"
data-testid="company-settings-team-list"
>
{membersQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading members...</p>
) : membersQuery.isError ? (
<p className="text-sm text-destructive">
{membersQuery.error instanceof Error
? membersQuery.error.message
: "Failed to load members"}
</p>
) : membersQuery.data?.members.length ? (
membersQuery.data.members.map((member) => {
const updatingMember =
memberMutation.isPending &&
memberMutation.variables?.memberId === member.id;
return (
<div
key={member.id}
className="flex flex-col gap-3 rounded-md border border-border/70 p-3"
data-testid={`company-settings-member-${member.id}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">
{member.user?.name || member.user?.email || member.principalId}
</div>
<div className="text-xs text-muted-foreground">
{member.user?.email || member.principalId}
</div>
</div>
<div className="text-xs text-muted-foreground">
{member.status === "active" ? "Active" : "Suspended"}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{canManageMembers ? (
<select
data-testid={`company-settings-member-role-${member.id}`}
className="rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={member.membershipRole}
disabled={updatingMember}
onChange={(e) =>
memberMutation.mutate({
memberId: member.id,
membershipRole: e.target.value as HumanCompanyRole,
status: member.status === "pending" ? "active" : member.status,
})
}
>
{availableHumanInviteRoles.map((role) => (
<option key={role} value={role}>
{HUMAN_ROLE_LABELS[role]}
</option>
))}
</select>
) : (
<span className="rounded-md border border-border px-2.5 py-1.5 text-sm text-muted-foreground">
{HUMAN_ROLE_LABELS[member.membershipRole]}
</span>
)}
{canManageMembers && (
<Button
data-testid={`company-settings-member-status-${member.id}`}
size="sm"
variant="outline"
disabled={updatingMember}
onClick={() =>
memberMutation.mutate({
memberId: member.id,
membershipRole: member.membershipRole,
status: member.status === "active" ? "suspended" : "active",
})
}
>
{member.status === "active" ? "Suspend" : "Reactivate"}
</Button>
)}
{updatingMember && (
<span className="text-xs text-muted-foreground">Saving...</span>
)}
</div>
</div>
);
})
) : (
<p className="text-sm text-muted-foreground">
No human members yet.
</p>
)}
{memberMutation.isError && (
<p className="text-sm text-destructive">
{memberMutation.error instanceof Error
? memberMutation.error.message
: "Failed to update member"}
</p>
)}
</div>
</div>
{/* Import / Export */}
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
+77 -12
View File
@@ -5,7 +5,7 @@ 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, InboxIssueMetaLeading } from "./Inbox";
import { FailedRunInboxRow, InboxIssueMetaLeading, InboxIssueTrailingColumns } from "./Inbox";
vi.mock("@/lib/router", () => ({
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
@@ -56,6 +56,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
lastActivityAt: new Date("2026-03-11T00:00:00.000Z"),
isUnreadForMe: false,
...overrides,
};
@@ -148,31 +149,95 @@ describe("InboxIssueMetaLeading", () => {
container.remove();
});
it("neutralizes selected status and live accents", () => {
it("keeps status and live accents visible", () => {
const root = createRoot(container);
act(() => {
root.render(<InboxIssueMetaLeading issue={createIssue()} selected isLive />);
root.render(<InboxIssueMetaLeading issue={createIssue()} isLive />);
});
const statusIcon = container.querySelector('span[class*="border-muted-foreground"]');
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-muted"]');
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-muted-foreground/70"]');
const liveDot = container.querySelector('span[class*="bg-blue-500"]');
const pulseRing = container.querySelector('span[class*="animate-pulse"]');
expect(statusIcon).not.toBeNull();
expect(statusIcon?.className).toContain("!border-muted-foreground");
expect(statusIcon?.className).toContain("!text-muted-foreground");
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-muted");
expect(liveBadge?.className).toContain("bg-blue-500/10");
expect(liveBadgeLabel).not.toBeNull();
expect(liveBadgeLabel?.className).toContain("text-muted-foreground");
expect(liveBadgeLabel?.className).not.toContain("text-blue-600");
expect(liveBadgeLabel?.className).toContain("text-blue-600");
expect(liveDot).not.toBeNull();
expect(pulseRing).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();
+301 -169
View File
@@ -18,7 +18,13 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useGeneralSettings } from "../context/GeneralSettingsContext";
import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState, createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import {
armIssueDetailInboxQuickArchive,
createIssueDetailLocationState,
createIssueDetailPath,
rememberIssueDetailLocationState,
} from "../lib/issueDetailBreadcrumb";
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import {
@@ -78,8 +84,16 @@ import {
getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent,
getRecentTouchedIssues,
isInboxEntityDismissed,
isMineInboxTab,
loadInboxIssueColumns,
loadInboxNesting,
normalizeInboxIssueColumns,
resolveInboxNestingEnabled,
resolveIssueWorkspaceName,
resolveInboxSelectionIndex,
saveInboxIssueColumns,
saveInboxNesting,
InboxApprovalFilter,
type InboxIssueColumn,
saveLastInboxTab,
@@ -102,6 +116,11 @@ type SectionKey =
| "work_items"
| "alerts";
/** A flat navigation entry for keyboard j/k traversal that includes expanded children. */
type NavEntry =
| { type: "top"; index: number; item: InboxWorkItem }
| { type: "child"; parentIndex: number; issue: Issue };
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@@ -131,67 +150,6 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
const selectedInboxAccentClass = "!text-muted-foreground !border-muted-foreground";
function getSelectedUnreadButtonClass(selected: boolean): string {
return selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20";
}
function getSelectedUnreadDotClass(selected: boolean): string {
return selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400";
}
export function InboxIssueMetaLeading({
issue,
selected,
isLive,
}: {
issue: Issue;
selected: boolean;
isLive: boolean;
}) {
return (
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon
status={issue.status}
className={selected ? selectedInboxAccentClass : undefined}
/>
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{isLive && (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
selected ? "bg-muted" : "bg-blue-500/10",
)}
>
<span className="relative flex h-2 w-2">
{!selected ? (
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
) : null}
<span
className={cn(
"relative inline-flex h-2 w-2 rounded-full",
selected ? "bg-muted-foreground/70" : "bg-blue-500",
)}
/>
</span>
<span
className={cn(
"hidden text-[11px] font-medium sm:inline",
selected ? "text-muted-foreground" : "text-blue-600 dark:text-blue-400",
)}
>
Live
</span>
</span>
)}
</>
);
}
export function FailedRunInboxRow({
run,
@@ -242,13 +200,13 @@ export function FailedRunInboxRow({
onClick={onMarkRead}
className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
getSelectedUnreadButtonClass(selected),
"hover:bg-blue-500/20",
)}
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
"bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
@@ -398,13 +356,13 @@ function ApprovalInboxRow({
onClick={onMarkRead}
className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
getSelectedUnreadButtonClass(selected),
"hover:bg-blue-500/20",
)}
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
"bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
@@ -537,13 +495,13 @@ function JoinRequestInboxRow({
onClick={onMarkRead}
className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
getSelectedUnreadButtonClass(selected),
"hover:bg-blue-500/20",
)}
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
"bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
@@ -638,7 +596,9 @@ export function Inbox() {
const [searchQuery, setSearchQuery] = useState("");
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const { dismissed, dismiss } = useDismissedInboxItems();
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
const pathSegment = location.pathname.split("/").pop() ?? "mine";
@@ -689,6 +649,7 @@ export function Inbox() {
useEffect(() => {
saveLastInboxTab(tab);
setSelectedIndex(-1);
setSearchQuery("");
}, [tab]);
const {
@@ -1261,7 +1222,7 @@ export function Inbox() {
return next;
});
}, 200);
}, [dismiss]);
}, [dismissAlert, dismissInboxItem]);
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
if (!canArchiveFromTab) return null;
@@ -1281,12 +1242,13 @@ export function Inbox() {
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
useEffect(() => {
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length));
}, [workItemsToRender.length]);
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, flatNavItems.length));
}, [flatNavItems.length]);
// Use refs for keyboard handler to avoid stale closures
const kbStateRef = useRef({
workItems: workItemsToRender,
workItems: nestedWorkItems,
flatNavItems,
selectedIndex,
canArchive: canArchiveFromTab,
archivingIssueIds,
@@ -1295,7 +1257,8 @@ export function Inbox() {
readItems,
});
kbStateRef.current = {
workItems: workItemsToRender,
workItems: nestedWorkItems,
flatNavItems,
selectedIndex,
canArchive: canArchiveFromTab,
archivingIssueIds,
@@ -1325,6 +1288,8 @@ export function Inbox() {
// Keyboard shortcuts (mail-client style) — single stable listener using refs
useEffect(() => {
if (!keyboardShortcutsEnabled) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
@@ -1332,9 +1297,8 @@ export function Inbox() {
const target = e.target;
if (
!(target instanceof HTMLElement) ||
target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") ||
target.isContentEditable ||
document.querySelector("[role='dialog'], [aria-modal='true']") ||
isKeyboardShortcutTextInputTarget(target) ||
hasBlockingShortcutDialog(document) ||
e.metaKey ||
e.ctrlKey ||
e.altKey
@@ -1348,75 +1312,94 @@ export function Inbox() {
// Keyboard shortcuts are only active on the "mine" tab
if (!st.canArchive) return;
const itemCount = st.workItems.length;
if (itemCount === 0) return;
const navItems = st.flatNavItems;
const navCount = navItems.length;
if (navCount === 0) return;
/** Resolve the nav entry at selectedIndex to an issue (for child entries) or work item. */
const resolveNavEntry = (idx: number): { issue?: Issue; item?: InboxWorkItem } => {
const entry = navItems[idx];
if (!entry) return {};
if (entry.type === "child") return { issue: entry.issue };
return { item: entry.item };
};
switch (e.key) {
case "j": {
e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "next"));
break;
}
case "k": {
e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "previous"));
break;
}
case "a":
case "y": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
if (!st.archivingIssueIds.has(item.issue.id)) {
act.archiveIssue(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
if (!st.archivingNonIssueIds.has(key)) {
act.archiveNonIssue(key);
const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) {
if (!st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
} else if (item) {
if (item.kind === "issue") {
if (!st.archivingIssueIds.has(item.issue.id)) act.archiveIssue(item.issue.id);
} else {
const key = getWorkItemKey(item);
if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key);
}
}
break;
}
case "U": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
act.markUnreadIssue(item.issue.id);
} else {
act.markNonIssueUnread(getWorkItemKey(item));
const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) {
act.markUnreadIssue(issue.id);
} else if (item) {
if (item.kind === "issue") act.markUnreadIssue(item.issue.id);
else act.markNonIssueUnread(getWorkItemKey(item));
}
break;
}
case "r": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) {
act.markRead(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
if (!st.readItems.has(key)) {
act.markNonIssueRead(key);
const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) {
if (issue.isUnreadForMe && !st.fadingOutIssues.has(issue.id)) act.markRead(issue.id);
} else if (item) {
if (item.kind === "issue") {
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) act.markRead(item.issue.id);
} else {
const key = getWorkItemKey(item);
if (!st.readItems.has(key)) act.markNonIssueRead(key);
}
}
break;
}
case "Enter": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
const pathId = item.issue.identifier ?? item.issue.id;
act.navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState });
} else if (item.kind === "approval") {
act.navigate(`/approvals/${item.approval.id}`);
} else if (item.kind === "failed_run") {
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) {
const pathId = issue.identifier ?? issue.id;
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
rememberIssueDetailLocationState(pathId, detailState);
act.navigate(createIssueDetailPath(pathId), { state: detailState });
} else if (item) {
if (item.kind === "issue") {
const pathId = item.issue.identifier ?? item.issue.id;
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
rememberIssueDetailLocationState(pathId, detailState);
act.navigate(createIssueDetailPath(pathId), { state: detailState });
} else if (item.kind === "approval") {
act.navigate(`/approvals/${item.approval.id}`);
} else if (item.kind === "failed_run") {
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
}
}
break;
}
@@ -1426,7 +1409,7 @@ export function Inbox() {
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [getWorkItemKey, issueLinkState]);
}, [getWorkItemKey, issueLinkState, keyboardShortcutsEnabled]);
// Scroll selected item into view
useEffect(() => {
@@ -1484,7 +1467,19 @@ export function Inbox() {
const canMarkAllRead = unreadIssueIds.length > 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-2">
<div className="space-y-2">
{/* Search — full-width row on mobile, inline on desktop */}
<div className="relative sm:hidden">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search inbox…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-full pl-8 text-xs"
/>
</div>
<div className="flex items-center justify-between gap-2">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
@@ -1503,6 +1498,33 @@ export function Inbox() {
</Tabs>
<div className="flex items-center gap-2">
<div className="relative hidden sm:block">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search inbox…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-[220px] pl-8 text-xs"
/>
</div>
<Button
type="button"
variant="outline"
size="icon"
className={cn("hidden h-8 w-8 shrink-0 sm:inline-flex", nestingEnabled && "bg-accent")}
onClick={toggleNesting}
title={nestingEnabled ? "Disable parent-child nesting" : "Enable parent-child nesting"}
>
<ListTree className="h-3.5 w-3.5" />
</Button>
<IssueColumnPicker
availableColumns={availableIssueColumns}
visibleColumnSet={visibleIssueColumnSet}
onToggleColumn={toggleIssueColumn}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which inbox columns stay visible"
/>
{canMarkAllRead && (
<>
<Button
@@ -1541,6 +1563,7 @@ export function Inbox() {
</>
)}
</div>
</div>
</div>
{tab === "all" && (
@@ -1609,13 +1632,34 @@ export function Inbox() {
{showSeparatorBefore("work_items") && <Separator />}
<div>
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
{workItemsToRender.flatMap((item, index) => {
{(() => {
// Pre-compute flat nav index for each top-level item and child issue
let flatIdx = 0;
const topFlatIndex = new Map<number, number>();
const childFlatIndex = new Map<string, number>();
for (let ti = 0; ti < nestedWorkItems.length; ti++) {
topFlatIndex.set(ti, flatIdx);
flatIdx++;
const topItem = nestedWorkItems[ti];
if (topItem.kind === "issue") {
const children = childrenByIssueId.get(topItem.issue.id);
const isExp = children?.length && !collapsedInboxParents.has(topItem.issue.id);
if (isExp) {
for (const c of children) {
childFlatIndex.set(c.id, flatIdx);
flatIdx++;
}
}
}
}
return nestedWorkItems.flatMap((item, index) => {
const navIdx = topFlatIndex.get(index) ?? index;
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
<div
key={`sel-${key}`}
data-inbox-item
className="relative"
onClick={() => setSelectedIndex(index)}
onClick={() => setSelectedIndex(navIdx)}
>
{child}
</div>
@@ -1625,19 +1669,19 @@ export function Inbox() {
index > 0 &&
item.timestamp > 0 &&
item.timestamp < todayCutoff &&
workItemsToRender[index - 1].timestamp >= todayCutoff;
nestedWorkItems[index - 1].timestamp >= todayCutoff;
const elements: ReactNode[] = [];
if (showTodayDivider) {
elements.push(
<div key="today-divider" className="flex items-center gap-3 px-4 my-2">
<div className="flex-1 border-t border-zinc-600" />
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Today
Earlier
</span>
</div>,
);
}
const isSelected = selectedIndex === index;
const isSelected = selectedIndex === navIdx;
if (item.kind === "approval") {
const approvalKey = `approval:${item.approval.id}`;
@@ -1749,62 +1793,150 @@ export function Inbox() {
}
const issue = item.issue;
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
const isArchiving = archivingIssueIds.has(issue.id);
const row = (
<IssueRow
key={`issue:${issue.id}`}
issue={issue}
issueLinkState={issueLinkState}
selected={isSelected}
className={
isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
desktopMetaLeading={
<InboxIssueMetaLeading
issue={issue}
selected={isSelected}
isLive={liveIssueIds.has(issue.id)}
/>
}
mobileMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`
}
unreadState={
isUnread ? "visible" : isFading ? "fading" : "hidden"
}
onMarkRead={() => markReadMutation.mutate(issue.id)}
onArchive={
canArchiveFromTab
? () => archiveIssueMutation.mutate(issue.id)
: undefined
}
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
trailingMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`
}
/>
);
const childIssues = childrenByIssueId.get(issue.id) ?? [];
const hasChildren = childIssues.length > 0;
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
const renderInboxIssue = (iss: Issue, depth: number, sel: boolean) => {
const isUnread = iss.isUnreadForMe && !fadingOutIssues.has(iss.id);
const isFading = fadingOutIssues.has(iss.id);
const isArch = archivingIssueIds.has(iss.id);
const proj = iss.projectId ? projectById.get(iss.projectId) ?? null : null;
return (
<IssueRow
key={`issue:${iss.id}`}
issue={iss}
issueLinkState={issueLinkState}
selected={sel}
className={
isArch
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
desktopMetaLeading={
<>
{nestingEnabled ? (
depth === 0 && hasChildren ? (
<button
type="button"
className="hidden w-4 shrink-0 items-center justify-center sm:inline-flex"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleInboxParentCollapse(issue.id);
}}
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span className="hidden w-4 shrink-0 sm:block" />
)
) : null}
{depth > 0 ? (
<span className="hidden w-4 shrink-0 sm:block" />
) : null}
<InboxIssueMetaLeading
issue={iss}
isLive={liveIssueIds.has(iss.id)}
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
/>
</>
}
titleSuffix={hasChildren && !isExpanded && depth === 0 ? (
<span className="ml-1.5 text-xs text-muted-foreground">
({childIssues.length} sub-task{childIssues.length !== 1 ? "s" : ""})
</span>
) : undefined}
mobileMeta={issueActivityText(iss).toLowerCase()}
mobileLeading={
depth === 0 && hasChildren ? (
<button type="button" onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleInboxParentCollapse(issue.id);
}}>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : undefined
}
unreadState={
isUnread ? "visible" : isFading ? "fading" : "hidden"
}
onMarkRead={() => markReadMutation.mutate(iss.id)}
onArchive={
canArchiveFromTab
? () => archiveIssueMutation.mutate(iss.id)
: undefined
}
archiveDisabled={isArch || archiveIssueMutation.isPending}
desktopTrailing={
visibleTrailingIssueColumns.length > 0 ? (
<InboxIssueTrailingColumns
issue={iss}
columns={visibleTrailingIssueColumns}
projectName={proj?.name ?? null}
projectColor={proj?.color ?? null}
workspaceName={resolveIssueWorkspaceName(iss, {
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
})}
assigneeName={agentName(iss.assigneeAgentId)}
currentUserId={currentUserId}
parentIdentifier={iss.parentId ? (issueById.get(iss.parentId)?.identifier ?? null) : null}
parentTitle={iss.parentId ? (issueById.get(iss.parentId)?.title ?? null) : null}
/>
) : undefined
}
/>
);
};
// Render parent issue
const parentRow = renderInboxIssue(issue, 0, isSelected);
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={`issue:${issue.id}`}
selected={isSelected}
disabled={isArchiving || archiveIssueMutation.isPending}
disabled={archivingIssueIds.has(issue.id) || archiveIssueMutation.isPending}
onArchive={() => archiveIssueMutation.mutate(issue.id)}
>
{parentRow}
</SwipeToArchive>
) : row));
) : parentRow));
// Render children if expanded
if (isExpanded) {
for (const child of childIssues) {
const cNavIdx = childFlatIndex.get(child.id) ?? -1;
const isChildSelected = selectedIndex === cNavIdx;
const childRow = renderInboxIssue(child, 1, isChildSelected);
const isChildArchiving = archivingIssueIds.has(child.id);
elements.push(
<div
key={`sel-issue:${child.id}`}
data-inbox-item
className="relative"
onClick={() => setSelectedIndex(cNavIdx)}
>
{canArchiveFromTab ? (
<SwipeToArchive
key={`issue:${child.id}`}
selected={isChildSelected}
disabled={isChildArchiving || archiveIssueMutation.isPending}
onArchive={() => archiveIssueMutation.mutate(child.id)}
>
{childRow}
</SwipeToArchive>
) : childRow}
</div>,
);
}
}
return elements;
})}
});
})()}
</div>
</div>
</>
+266 -95
View File
@@ -18,16 +18,33 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import {
hasLegacyIssueDetailQuery,
createIssueDetailPath,
readIssueDetailLocationState,
readIssueDetailBreadcrumb,
rememberIssueDetailLocationState,
} from "../lib/issueDetailBreadcrumb";
import {
hasBlockingShortcutDialog,
resolveIssueDetailGoKeyAction,
resolveInboxQuickArchiveKeyAction,
} from "../lib/keyboardShortcuts";
import {
applyOptimisticIssueFieldUpdate,
applyOptimisticIssueFieldUpdateToCollection,
applyOptimisticIssueCommentUpdate,
createOptimisticIssueComment,
flattenIssueCommentPages,
getNextIssueCommentPageParam,
isQueuedIssueComment,
matchesIssueRef,
mergeIssueComments,
upsertIssueComment,
upsertIssueCommentInPages,
type IssueCommentReassignment,
type OptimisticIssueComment,
} from "../lib/optimistic-issue-comments";
import { removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { ApprovalCard } from "../components/ApprovalCard";
@@ -68,8 +85,17 @@ import {
SlidersHorizontal,
Trash2,
} from "lucide-react";
import type { ActivityEvent } from "@paperclipai/shared";
import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
import {
getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,
type ActivityEvent,
type Agent,
type FeedbackVote,
type FeedbackVoteValue,
type Issue,
type IssueAttachment,
type IssueComment,
} from "@paperclipai/shared";
type CommentReassignment = IssueCommentReassignment;
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
@@ -279,7 +305,10 @@ export function IssueDetail() {
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
const [galleryOpen, setGalleryOpen] = useState(false);
const [galleryIndex, setGalleryIndex] = useState(0);
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
const commentComposerRef = useRef<IssueChatComposerHandle | null>(null);
@@ -370,6 +399,15 @@ export function IssueDetail() {
});
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({
queryKey: queryKeys.issues.runs(issueId!),
queryFn: () => activityApi.runsForIssue(issueId!),
enabled: !!issueId,
refetchInterval: hasLiveRuns
? ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS
: IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS,
placeholderData: keepPreviousData,
});
const runningIssueRun = useMemo(
() => (
activeRun?.status === "running"
@@ -378,9 +416,13 @@ export function IssueDetail() {
),
[activeRun, liveRuns],
);
const resolvedIssueDetailState = useMemo(
() => readIssueDetailLocationState(issueId, location.state, location.search),
[issueId, location.state, location.search],
);
const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
[location.state, location.search],
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
[issueId, location.state, location.search],
);
// Filter out runs already shown by the live widget to avoid duplication
@@ -594,6 +636,7 @@ export function IssueDetail() {
isQueuedIssueComment({
comment: nextComment,
activeRunStartedAt,
activeRunAgentId: runningIssueRun?.agentId ?? null,
runId: meta?.runId ?? nextComment.runId ?? null,
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
})
@@ -608,14 +651,9 @@ export function IssueDetail() {
});
}, [activity, threadComments, linkedRuns, runningIssueRun]);
const queuedComments = useMemo(
() => commentsWithRunMeta.filter((comment) => comment.queueState === "queued"),
[commentsWithRunMeta],
);
const timelineComments = useMemo(
() => commentsWithRunMeta.filter((comment) => comment.queueState !== "queued"),
[commentsWithRunMeta],
const timelineEvents = useMemo(
() => extractIssueTimelineEvents(activity),
[activity],
);
const issueCostSummary = useMemo(() => {
@@ -847,9 +885,15 @@ export function IssueDetail() {
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
);
}
queryClient.setQueryData<IssueComment[]>(
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(issueId!),
(current) => upsertIssueComment(current, comment),
(current) => current ? {
...current,
pages: upsertIssueCommentInPages(current.pages, comment),
} : {
pageParams: [null],
pages: upsertIssueCommentInPages(undefined, comment),
},
);
},
onError: (err, _variables, context) => {
@@ -867,9 +911,14 @@ export function IssueDetail() {
tone: "error",
});
},
onSettled: () => {
invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
onSettled: (_result, _error, variables) => {
invalidateIssueDetail();
if (variables.interrupt) {
invalidateIssueRunState();
}
if (variables.reopen) {
invalidateIssueCollections();
}
},
});
@@ -934,9 +983,15 @@ export function IssueDetail() {
const { comment, ...nextIssue } = result;
queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue);
if (comment) {
queryClient.setQueryData<IssueComment[]>(
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(issueId!),
(current) => upsertIssueComment(current, comment),
(current) => current ? {
...current,
pages: upsertIssueCommentInPages(current.pages, comment),
} : {
pageParams: [null],
pages: upsertIssueCommentInPages(undefined, comment),
},
);
}
},
@@ -955,23 +1010,68 @@ export function IssueDetail() {
tone: "error",
});
},
onSettled: () => {
invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
onSettled: (_result, _error, variables) => {
invalidateIssueDetail();
if (variables.interrupt) {
invalidateIssueRunState();
}
invalidateIssueCollections();
},
});
const interruptQueuedComment = useMutation({
mutationFn: (runId: string) => heartbeatsApi.cancel(runId),
onMutate: async (runId) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
const previousRuns = queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(issueId!));
const previousLiveRuns = queryClient.getQueryData<typeof liveRuns>(queryKeys.issues.liveRuns(issueId!));
const previousActiveRun = queryClient.getQueryData<typeof activeRun>(queryKeys.issues.activeRun(issueId!));
const liveRunList = previousLiveRuns ?? liveRuns ?? [];
const cachedActiveRun = previousActiveRun ?? activeRun;
const targetRun =
cachedActiveRun?.id === runId
? cachedActiveRun
: liveRunList?.find((run) => run.id === runId) ?? runningIssueRun ?? null;
if (targetRun) {
const interruptedAt = new Date().toISOString();
queryClient.setQueryData<RunForIssue[] | undefined>(
queryKeys.issues.runs(issueId!),
(current) => upsertInterruptedRun(current, targetRun, interruptedAt),
);
}
queryClient.setQueryData(
queryKeys.issues.liveRuns(issueId!),
(current: typeof liveRuns) => removeLiveRunById(current, runId),
);
queryClient.setQueryData(
queryKeys.issues.activeRun(issueId!),
(current: typeof activeRun) => (current?.id === runId ? null : current),
);
return {
previousRuns,
previousLiveRuns,
previousActiveRun,
};
},
onSuccess: () => {
invalidateIssue();
invalidateIssueDetail();
invalidateIssueRunState();
pushToast({
title: "Interrupt requested",
body: "The active run is stopping so queued comments can continue next.",
tone: "success",
});
},
onError: (err) => {
onError: (err, _runId, context) => {
queryClient.setQueryData(queryKeys.issues.runs(issueId!), context?.previousRuns);
queryClient.setQueryData(queryKeys.issues.liveRuns(issueId!), context?.previousLiveRuns);
queryClient.setQueryData(queryKeys.issues.activeRun(issueId!), context?.previousActiveRun);
pushToast({
title: "Interrupt failed",
body: err instanceof Error ? err.message : "Unable to interrupt the active run",
@@ -980,6 +1080,71 @@ export function IssueDetail() {
},
});
const feedbackVoteMutation = useMutation({
mutationFn: (variables: {
targetType: "issue_comment" | "issue_document_revision";
targetId: string;
vote: "up" | "down";
reason?: string;
allowSharing?: boolean;
sharingPreferenceAtSubmit: "allowed" | "not_allowed" | "prompt";
}) =>
issuesApi.upsertFeedbackVote(issueId!, {
targetType: variables.targetType,
targetId: variables.targetId,
vote: variables.vote,
...(variables.reason ? { reason: variables.reason } : {}),
...(variables.allowSharing ? { allowSharing: true } : {}),
}),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) });
const previousVotes = queryClient.getQueryData<FeedbackVote[]>(
queryKeys.issues.feedbackVotes(issueId!),
);
queryClient.setQueryData<FeedbackVote[]>(
queryKeys.issues.feedbackVotes(issueId!),
mergeOptimisticFeedbackVote(
previousVotes,
{
issueId: issueId!,
targetType: variables.targetType,
targetId: variables.targetId,
vote: variables.vote,
reason: variables.reason,
},
currentUserId,
),
);
return { previousVotes };
},
onSuccess: (_savedVote, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
pushToast({
title:
variables.sharingPreferenceAtSubmit === "prompt"
? variables.allowSharing
? "Feedback saved. Future votes will share"
: "Feedback saved. Future votes will stay local"
: variables.allowSharing
? "Feedback saved and sharing enabled"
: "Feedback saved",
tone: "success",
});
},
onError: (err, _variables, context) => {
if (context?.previousVotes) {
queryClient.setQueryData(queryKeys.issues.feedbackVotes(issueId!), context.previousVotes);
}
pushToast({
title: "Failed to save feedback",
body: err instanceof Error ? err.message : "Unknown error",
tone: "error",
});
},
});
const uploadAttachment = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
@@ -1107,12 +1272,22 @@ export function IssueDetail() {
useEffect(() => {
const nextState = resolvedIssueDetailState ?? location.state;
if (issue?.identifier && issueId !== issue.identifier) {
navigate(createIssueDetailPath(issue.identifier, location.state, location.search), {
rememberIssueDetailLocationState(issue.identifier, nextState, location.search);
navigate(createIssueDetailPath(issue.identifier), {
replace: true,
state: location.state,
state: nextState,
});
return;
}
if (issueId && hasLegacyIssueDetailQuery(location.search)) {
rememberIssueDetailLocationState(issueId, nextState, location.search);
navigate(createIssueDetailPath(issueId), {
replace: true,
state: nextState,
});
}
}, [issue, issueId, navigate, location.state, location.search]);
}, [issue, issueId, navigate, location.state, location.search, resolvedIssueDetailState]);
useEffect(() => {
if (!issue?.id) return;
@@ -1391,8 +1566,14 @@ export function IssueDetail() {
<span key={ancestor.id} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
<Link
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id, location.state, location.search)}
state={location.state}
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id)}
state={resolvedIssueDetailState ?? location.state}
onClickCapture={() =>
rememberIssueDetailLocationState(
ancestor.identifier ?? ancestor.id,
resolvedIssueDetailState ?? location.state,
location.search,
)}
className="hover:text-foreground transition-colors truncate max-w-[200px]"
title={ancestor.title}
>
@@ -1859,71 +2040,61 @@ export function IssueDetail() {
))}
</TabsList>
<TabsContent value="comments">
<CommentThread
comments={timelineComments}
queuedComments={queuedComments}
linkedRuns={timelineRuns}
companyId={issue.companyId}
projectId={issue.projectId}
issueStatus={issue.status}
agentMap={agentMap}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
enableReassign
reassignOptions={commentReassignOptions}
currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
onInterruptQueued={async (runId) => {
await interruptQueuedComment.mutateAsync(runId);
}}
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
onAdd={async (body, reopen, reassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
return;
}
await addComment.mutateAsync({ body, reopen });
}}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onAttachImage={async (file) => {
await uploadAttachment.mutateAsync(file);
}}
liveRunSlot={<LiveRunWidget issueId={issueId!} companyId={issue.companyId} />}
/>
</TabsContent>
<TabsContent value="subissues">
{childIssues.length === 0 ? (
<p className="text-xs text-muted-foreground">No sub-issues.</p>
<TabsContent value="chat">
{issueChatInitialLoading ? (
<IssueChatSkeleton />
) : (
<div className="border border-border rounded-lg divide-y divide-border">
{childIssues.map((child) => (
<Link
key={child.id}
to={createIssueDetailPath(child.identifier ?? child.id, location.state, location.search)}
state={location.state}
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={child.status} />
<PriorityIcon priority={child.priority} />
<span className="font-mono text-muted-foreground shrink-0">
{child.identifier ?? child.id.slice(0, 8)}
</span>
<span className="truncate">{child.title}</span>
</div>
{child.assigneeAgentId && (() => {
const name = agentMap.get(child.assigneeAgentId)?.name;
return name
? <Identity name={name} size="sm" />
: <span className="text-muted-foreground font-mono">{child.assigneeAgentId.slice(0, 8)}</span>;
})()}
</Link>
))}
<div className="space-y-3">
{hasOlderComments ? (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
size="sm"
disabled={commentsLoadingOlder}
onClick={() => {
void fetchOlderComments();
}}
>
{commentsLoadingOlder ? "Loading earlier comments..." : "Load earlier comments"}
</Button>
</div>
) : null}
<IssueChatThread
composerRef={commentComposerRef}
comments={commentsWithRunMeta}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
linkedRuns={timelineRuns}
timelineEvents={timelineEvents}
liveRuns={liveRuns}
activeRun={activeRun}
companyId={issue.companyId}
projectId={issue.projectId}
issueStatus={issue.status}
agentMap={agentMap}
currentUserId={currentUserId}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
enableReassign
reassignOptions={commentReassignOptions}
currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
onInterruptQueued={handleInterruptQueued}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
composerDisabledReason={commentComposerDisabledReason}
onVote={handleCommentVote}
onAdd={handleCommentAdd}
imageUploadHandler={handleCommentImageUpload}
onAttachImage={handleCommentAttachImage}
onCancelRun={runningIssueRun
? async () => {
await interruptQueuedComment.mutateAsync(runningIssueRun.id);
}
: undefined}
onImageClick={handleChatImageClick}
/>
</div>
)}
</TabsContent>