mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
Add sidebar company context menu
This commit is contained in:
1 parent
9779b0f16d
commit
d2f40c10a4
35 files changed
+2696
-1214
No files matched your search
@@ -44,6 +44,9 @@ function createExistingConfigFixture() {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 & 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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,16 @@ import {
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getRecentTouchedIssues,
|
||||
getUnreadTouchedIssues,
|
||||
isInboxEntityDismissed,
|
||||
isMineInboxTab,
|
||||
loadInboxIssueColumns,
|
||||
loadLastInboxTab,
|
||||
normalizeInboxIssueColumns,
|
||||
RECENT_ISSUES_LIMIT,
|
||||
resolveInboxNestingEnabled,
|
||||
resolveIssueWorkspaceName,
|
||||
resolveInboxSelectionIndex,
|
||||
saveInboxIssueColumns,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
} from "./inbox";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user