forked from farhoodlabs/paperclip
[codex] Improve issue detail and issue-list UX (#3678)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - A core part of that is the operator experience around reading issue state, agent chat, and sub-task structure > - The current branch had a long run of issue-detail and issue-list UX fixes that all improve how humans follow and steer active work > - Those changes mostly live in the UI/chat surface and should be reviewed together instead of mixed with workspace/runtime work > - This pull request packages the issue-detail, chat, markdown, and sub-issue list improvements into one standalone change > - The benefit is a cleaner, less jumpy, more reliable issue workflow on desktop and mobile without coupling it to unrelated server/runtime refactors ## What Changed - Stabilized issue chat runtime wiring, optimistic comment handling, queued-comment cancellation, and composer anchoring during live updates - Fixed several issue-detail rendering and navigation regressions including placeholder bleed, local polling scope, mobile inbox-to-issue transitions, and visible refresh resets - Improved markdown and rich-content handling with advisory image normalization, editor fallback behavior, touch mention recovery, and `issue:` quicklook links - Refined sub-issue behavior with parent-derived defaults, current-user inheritance fixes, empty-state cleanup, and a reusable issue-list presentation for sub-issues - Added targeted UI tests for the new issue-detail, chat scroll/message, placeholder-data, markdown, and issue-list behaviors ## Verification - `pnpm vitest run ui/src/components/IssueChatThread.test.tsx ui/src/components/MarkdownEditor.test.tsx ui/src/components/IssuesList.test.tsx ui/src/context/LiveUpdatesProvider.test.tsx ui/src/lib/issue-chat-messages.test.ts ui/src/lib/issue-chat-scroll.test.ts ui/src/lib/issue-detail-subissues.test.ts ui/src/lib/query-placeholder-data.test.tsx ui/src/hooks/usePaperclipIssueRuntime.test.tsx` ## Risks - Medium: this branch touches the highest-traffic issue-detail UI paths, so regressions would show up as chat/thread or sub-issue UX glitches - The changes are UI-heavy and would benefit from reviewer screenshots or a quick manual browser pass before merge ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -424,6 +424,192 @@ describe("heartbeat comment wake batching", () => {
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("promotes deferred comment wakes after the active run closes the issue", async () => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
try {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Gateway Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2_000,
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Reopen after deferred comment",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
});
|
||||
|
||||
const comment1 = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "user-1",
|
||||
body: "First comment",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const firstRun = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId, commentId: comment1.id },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
commentId: comment1.id,
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "user-1",
|
||||
});
|
||||
|
||||
expect(firstRun).not.toBeNull();
|
||||
await waitFor(async () => {
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, firstRun!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return run?.status === "running";
|
||||
});
|
||||
|
||||
const comment2 = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "user-1",
|
||||
body: "Please handle this follow-up after you finish",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const deferredRun = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId, commentId: comment2.id },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
commentId: comment2.id,
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "user-1",
|
||||
});
|
||||
|
||||
expect(deferredRun).toBeNull();
|
||||
|
||||
await waitFor(async () => {
|
||||
const deferred = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
eq(agentWakeupRequests.agentId, agentId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return Boolean(deferred);
|
||||
});
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
status: "done",
|
||||
completedAt: new Date(),
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
|
||||
gateway.releaseFirstWait();
|
||||
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000);
|
||||
await waitFor(async () => {
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
return runs.length === 2 && runs.every((run) => run.status === "succeeded");
|
||||
}, 90_000);
|
||||
|
||||
const reopenedIssue = await db
|
||||
.select({
|
||||
status: issues.status,
|
||||
completedAt: issues.completedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
expect(reopenedIssue).toMatchObject({
|
||||
status: "in_progress",
|
||||
completedAt: null,
|
||||
});
|
||||
|
||||
const secondPayload = gateway.getAgentPayloads()[1] ?? {};
|
||||
expect(secondPayload.paperclip).toMatchObject({
|
||||
wake: {
|
||||
reason: "issue_commented",
|
||||
commentIds: [comment2.id],
|
||||
latestCommentId: comment2.id,
|
||||
issue: {
|
||||
id: issueId,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
title: "Reopen after deferred comment",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(String(secondPayload.message ?? "")).toContain("Please handle this follow-up after you finish");
|
||||
} finally {
|
||||
gateway.releaseFirstWait();
|
||||
await gateway.close();
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("queues exactly one follow-up run when an issue-bound run exits without a comment", async () => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
@@ -575,4 +761,118 @@ describe("heartbeat comment wake batching", () => {
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("treats the automatic run summary as fallback-only when the run already posted a comment", async () => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
try {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Gateway Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2_000,
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Use existing comment",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
});
|
||||
|
||||
const firstRun = await heartbeat.wakeup(agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: { issueId },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
});
|
||||
|
||||
expect(firstRun).not.toBeNull();
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorAgentId: agentId,
|
||||
authorUserId: null,
|
||||
createdByRunId: firstRun!.id,
|
||||
body: "Manual completion comment from the run.",
|
||||
});
|
||||
|
||||
gateway.releaseFirstWait();
|
||||
|
||||
await waitFor(async () => {
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
return runs.length === 1 && runs[0]?.status === "succeeded" && runs[0]?.issueCommentStatus === "satisfied";
|
||||
});
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.issueCommentStatus).toBe("satisfied");
|
||||
expect(runs[0]?.issueCommentSatisfiedByCommentId).not.toBeNull();
|
||||
|
||||
const comments = await db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, issueId))
|
||||
.orderBy(asc(issueComments.createdAt));
|
||||
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]?.body).toBe("Manual completion comment from the run.");
|
||||
expect(comments[0]?.createdByRunId).toBe(firstRun?.id);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
|
||||
|
||||
expect(wakeups).toHaveLength(1);
|
||||
} finally {
|
||||
gateway.releaseFirstWait();
|
||||
await gateway.close();
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
assertCheckoutOwner: vi.fn(),
|
||||
getComment: vi.fn(),
|
||||
removeComment: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: vi.fn(),
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({ getById: vi.fn(async () => null) }),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
return app;
|
||||
}
|
||||
|
||||
async function installActor(app: express.Express, actor?: Record<string, unknown>) {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/issues.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor ?? {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeIssue() {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||
assigneeUserId: null,
|
||||
executionRunId: "run-1",
|
||||
identifier: "PAP-1353",
|
||||
title: "Queued cancel",
|
||||
};
|
||||
}
|
||||
|
||||
function makeComment(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
authorAgentId: null,
|
||||
authorUserId: "local-board",
|
||||
body: "Queued follow-up",
|
||||
createdAt: new Date("2026-04-11T15:01:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T15:01:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue comment cancel routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue());
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockIssueService.getComment.mockResolvedValue(makeComment());
|
||||
mockIssueService.removeComment.mockResolvedValue(makeComment());
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockHeartbeatService.getRun.mockResolvedValue({
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
status: "running",
|
||||
startedAt: new Date("2026-04-11T15:00:00.000Z"),
|
||||
createdAt: new Date("2026-04-11T14:59:00.000Z"),
|
||||
});
|
||||
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("cancels a queued comment from its author and restores the deleted body", async () => {
|
||||
const res = await request(await installActor(createApp()))
|
||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "comment-1",
|
||||
body: "Queued follow-up",
|
||||
});
|
||||
expect(mockIssueService.removeComment).toHaveBeenCalledWith("comment-1");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.comment_cancelled",
|
||||
details: expect.objectContaining({
|
||||
commentId: "comment-1",
|
||||
source: "queue_cancel",
|
||||
queueTargetRunId: "run-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects canceling comments that are no longer queued", async () => {
|
||||
mockIssueService.getComment.mockResolvedValue(
|
||||
makeComment({
|
||||
createdAt: new Date("2026-04-11T14:58:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T14:58:00.000Z"),
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toBe("Only queued comments can be canceled");
|
||||
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects canceling another actor's queued comment", async () => {
|
||||
mockIssueService.getComment.mockResolvedValue(
|
||||
makeComment({
|
||||
authorUserId: "someone-else",
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Only the comment author can cancel queued comments");
|
||||
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -225,6 +225,40 @@ describe("issue comment reopen routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("implicitly reopens closed issues via the PATCH comment path when reassigning to an agent", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeIssue("done"),
|
||||
...patch,
|
||||
}));
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "hello", assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
status: "todo",
|
||||
actorAgentId: null,
|
||||
actorUserId: "local-board",
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.updated",
|
||||
details: expect.objectContaining({
|
||||
reopened: true,
|
||||
reopenedFrom: "done",
|
||||
status: "todo",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reopens closed issues via the PATCH comment path", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
@@ -259,6 +293,48 @@ describe("issue comment reopen routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("implicitly reopens closed issues via POST comments when an agent is assigned", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeIssue("done"),
|
||||
...patch,
|
||||
}));
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: "hello" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
{ status: "todo" },
|
||||
);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
expect.objectContaining({
|
||||
reason: "issue_reopened_via_comment",
|
||||
payload: expect.objectContaining({
|
||||
reopenedFrom: "done",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => {
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
...makeIssue("done"),
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "local-board",
|
||||
});
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: "hello" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("interrupts an active run before a combined comment update", async () => {
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
|
||||
+133
-6
@@ -142,6 +142,22 @@ function summarizeExecutionParticipants(
|
||||
);
|
||||
}
|
||||
|
||||
function isClosedIssueStatus(status: string | null | undefined): status is "done" | "cancelled" {
|
||||
return status === "done" || status === "cancelled";
|
||||
}
|
||||
|
||||
function shouldImplicitlyReopenCommentForAgent(input: {
|
||||
issueStatus: string | null | undefined;
|
||||
assigneeAgentId: string | null | undefined;
|
||||
actorType: "agent" | "user";
|
||||
actorId: string;
|
||||
}) {
|
||||
if (!isClosedIssueStatus(input.issueStatus)) return false;
|
||||
if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false;
|
||||
if (input.actorType === "agent" && input.actorId === input.assigneeAgentId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function diffExecutionParticipants(
|
||||
previousPolicy: NormalizedExecutionPolicy | null,
|
||||
nextPolicy: NormalizedExecutionPolicy | null,
|
||||
@@ -444,6 +460,32 @@ export function issueRoutes(
|
||||
return runToInterrupt?.status === "running" ? runToInterrupt : null;
|
||||
}
|
||||
|
||||
function toValidTimestamp(value: Date | string | null | undefined) {
|
||||
if (!value) return null;
|
||||
const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
function isQueuedIssueCommentForActiveRun(params: {
|
||||
comment: {
|
||||
authorAgentId?: string | null;
|
||||
createdAt?: Date | string | null;
|
||||
};
|
||||
activeRun: {
|
||||
agentId?: string | null;
|
||||
startedAt?: Date | string | null;
|
||||
createdAt?: Date | string | null;
|
||||
};
|
||||
}) {
|
||||
const activeRunStartedAtMs =
|
||||
toValidTimestamp(params.activeRun.startedAt) ?? toValidTimestamp(params.activeRun.createdAt);
|
||||
const commentCreatedAtMs = toValidTimestamp(params.comment.createdAt);
|
||||
|
||||
if (activeRunStartedAtMs === null || commentCreatedAtMs === null) return false;
|
||||
if (params.comment.authorAgentId && params.comment.authorAgentId === params.activeRun.agentId) return false;
|
||||
return commentCreatedAtMs >= activeRunStartedAtMs;
|
||||
}
|
||||
|
||||
async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) {
|
||||
if (!issue.executionWorkspaceId) return null;
|
||||
const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId);
|
||||
@@ -1313,7 +1355,7 @@ export function issueRoutes(
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const isClosed = existing.status === "done" || existing.status === "cancelled";
|
||||
const isClosed = isClosedIssueStatus(existing.status);
|
||||
const existingRelations =
|
||||
Array.isArray(req.body.blockedByIssueIds)
|
||||
? await svc.getRelationSummaries(existing.id)
|
||||
@@ -1325,6 +1367,17 @@ export function issueRoutes(
|
||||
hiddenAt: hiddenAtRaw,
|
||||
...updateFields
|
||||
} = req.body;
|
||||
const requestedAssigneeAgentId =
|
||||
req.body.assigneeAgentId === undefined ? existing.assigneeAgentId : (req.body.assigneeAgentId as string | null);
|
||||
const effectiveReopenRequested =
|
||||
reopenRequested ||
|
||||
(!!commentBody &&
|
||||
shouldImplicitlyReopenCommentForAgent({
|
||||
issueStatus: existing.status,
|
||||
assigneeAgentId: requestedAssigneeAgentId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
}));
|
||||
let interruptedRunId: string | null = null;
|
||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
|
||||
const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0;
|
||||
@@ -1367,7 +1420,7 @@ export function issueRoutes(
|
||||
if (hiddenAtRaw !== undefined) {
|
||||
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
|
||||
}
|
||||
if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) {
|
||||
if (commentBody && effectiveReopenRequested && isClosed && updateFields.status === undefined) {
|
||||
updateFields.status = "todo";
|
||||
}
|
||||
if (req.body.executionPolicy !== undefined) {
|
||||
@@ -1526,7 +1579,7 @@ export function issueRoutes(
|
||||
const hasFieldChanges = Object.keys(previous).length > 0;
|
||||
const reopened =
|
||||
commentBody &&
|
||||
reopenRequested === true &&
|
||||
effectiveReopenRequested &&
|
||||
isClosed &&
|
||||
previous.status !== undefined &&
|
||||
issue.status === "todo";
|
||||
@@ -1748,7 +1801,7 @@ export function issueRoutes(
|
||||
const selfComment = actorIsAgent && actor.actorId === assigneeId;
|
||||
const skipAssigneeCommentWake = selfComment || isClosed;
|
||||
|
||||
if (assigneeId && !assigneeChanged && !skipAssigneeCommentWake) {
|
||||
if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) {
|
||||
addWakeup(assigneeId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
@@ -2069,6 +2122,72 @@ export function issueRoutes(
|
||||
res.json(comment);
|
||||
});
|
||||
|
||||
router.delete("/issues/:id/comments/:commentId", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const commentId = req.params.commentId as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return;
|
||||
|
||||
const comment = await svc.getComment(commentId);
|
||||
if (!comment || comment.issueId !== id) {
|
||||
res.status(404).json({ error: "Comment not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const actorOwnsComment =
|
||||
actor.actorType === "agent"
|
||||
? comment.authorAgentId === actor.agentId
|
||||
: comment.authorUserId === actor.actorId;
|
||||
if (!actorOwnsComment) {
|
||||
res.status(403).json({ error: "Only the comment author can cancel queued comments" });
|
||||
return;
|
||||
}
|
||||
|
||||
const activeRun = await resolveActiveIssueRun(issue);
|
||||
if (!activeRun) {
|
||||
res.status(409).json({ error: "Queued comment can no longer be canceled" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isQueuedIssueCommentForActiveRun({ comment, activeRun })) {
|
||||
res.status(409).json({ error: "Only queued comments can be canceled" });
|
||||
return;
|
||||
}
|
||||
|
||||
const removed = await svc.removeComment(commentId);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Comment not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.comment_cancelled",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
commentId: removed.id,
|
||||
bodySnippet: removed.body.slice(0, 120),
|
||||
identifier: issue.identifier,
|
||||
issueTitle: issue.title,
|
||||
source: "queue_cancel",
|
||||
queueTargetRunId: activeRun.id,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(removed);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/feedback-votes", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
@@ -2167,13 +2286,21 @@ export function issueRoutes(
|
||||
const actor = getActorInfo(req);
|
||||
const reopenRequested = req.body.reopen === true;
|
||||
const interruptRequested = req.body.interrupt === true;
|
||||
const isClosed = issue.status === "done" || issue.status === "cancelled";
|
||||
const isClosed = isClosedIssueStatus(issue.status);
|
||||
const effectiveReopenRequested =
|
||||
reopenRequested ||
|
||||
shouldImplicitlyReopenCommentForAgent({
|
||||
issueStatus: issue.status,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
});
|
||||
let reopened = false;
|
||||
let reopenFromStatus: string | null = null;
|
||||
let interruptedRunId: string | null = null;
|
||||
let currentIssue = issue;
|
||||
|
||||
if (reopenRequested && isClosed) {
|
||||
if (effectiveReopenRequested && isClosed) {
|
||||
const reopenedIssue = await svc.update(id, { status: "todo" });
|
||||
if (!reopenedIssue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
mergeHeartbeatRunResultJson,
|
||||
summarizeHeartbeatRunResultJson,
|
||||
} from "./heartbeat-run-summary.js";
|
||||
import { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
import {
|
||||
buildWorkspaceReadyComment,
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
@@ -3485,9 +3486,12 @@ export function heartbeatService(db: Db) {
|
||||
});
|
||||
if (issueId && outcome === "succeeded") {
|
||||
try {
|
||||
const issueComment = buildHeartbeatRunIssueComment(persistedResultJson);
|
||||
if (issueComment) {
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
|
||||
const existingRunComment = await findRunIssueComment(finalizedRun.id, finalizedRun.companyId, issueId);
|
||||
if (!existingRunComment) {
|
||||
const issueComment = buildHeartbeatRunIssueComment(persistedResultJson);
|
||||
if (issueComment) {
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
@@ -3632,31 +3636,50 @@ export function heartbeatService(db: Db) {
|
||||
}
|
||||
|
||||
async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) {
|
||||
const promotedRun = await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`,
|
||||
);
|
||||
const runContext = parseObject(run.contextSnapshot);
|
||||
const contextIssueId = readNonEmptyString(runContext.issueId);
|
||||
const promotionResult = await db.transaction(async (tx) => {
|
||||
if (contextIssueId) {
|
||||
await tx.execute(
|
||||
sql`select id from issues where company_id = ${run.companyId} and id = ${contextIssueId} for update`,
|
||||
);
|
||||
} else {
|
||||
await tx.execute(
|
||||
sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`,
|
||||
);
|
||||
}
|
||||
|
||||
const issue = await tx
|
||||
let issue = await tx
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
status: issues.status,
|
||||
executionRunId: issues.executionRunId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id)))
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, run.companyId),
|
||||
contextIssueId ? eq(issues.id, contextIssueId) : eq(issues.executionRunId, run.id),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!issue) return;
|
||||
if (!issue) return null;
|
||||
if (issue.executionRunId && issue.executionRunId !== run.id) return null;
|
||||
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, issue.id));
|
||||
if (issue.executionRunId === run.id) {
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, issue.id));
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const deferred = await tx
|
||||
@@ -3703,6 +3726,51 @@ export function heartbeatService(db: Db) {
|
||||
const deferredPayload = parseObject(deferred.payload);
|
||||
const deferredContextSeed = parseObject(deferredPayload[DEFERRED_WAKE_CONTEXT_KEY]);
|
||||
const promotedContextSeed: Record<string, unknown> = { ...deferredContextSeed };
|
||||
const deferredCommentIds = extractWakeCommentIds(deferredContextSeed);
|
||||
const shouldReopenDeferredCommentWake =
|
||||
deferredCommentIds.length > 0 && (issue.status === "done" || issue.status === "cancelled");
|
||||
let reopenedActivity: LogActivityInput | null = null;
|
||||
|
||||
if (shouldReopenDeferredCommentWake) {
|
||||
const reopenedFromStatus = issue.status;
|
||||
const reopenedIssue = await issuesSvc.update(
|
||||
issue.id,
|
||||
{
|
||||
status: "todo",
|
||||
executionState: null,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
if (reopenedIssue) {
|
||||
issue = {
|
||||
...issue,
|
||||
identifier: reopenedIssue.identifier,
|
||||
status: reopenedIssue.status,
|
||||
executionRunId: reopenedIssue.executionRunId,
|
||||
};
|
||||
if (!readNonEmptyString(promotedContextSeed.reopenedFrom)) {
|
||||
promotedContextSeed.reopenedFrom = reopenedFromStatus;
|
||||
}
|
||||
reopenedActivity = {
|
||||
companyId: issue.companyId,
|
||||
actorType: "system",
|
||||
actorId: "heartbeat",
|
||||
agentId: deferred.agentId,
|
||||
runId: run.id,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
status: "todo",
|
||||
reopened: true,
|
||||
reopenedFrom: reopenedFromStatus,
|
||||
source: "deferred_comment_wake",
|
||||
identifier: issue.identifier,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const promotedReason = readNonEmptyString(deferred.reason) ?? "issue_execution_promoted";
|
||||
const promotedSource =
|
||||
(readNonEmptyString(deferred.source) as WakeupOptions["source"]) ?? "automation";
|
||||
@@ -3764,12 +3832,20 @@ export function heartbeatService(db: Db) {
|
||||
})
|
||||
.where(eq(issues.id, issue.id));
|
||||
|
||||
return newRun;
|
||||
return {
|
||||
run: newRun,
|
||||
reopenedActivity,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const promotedRun = promotionResult?.run ?? null;
|
||||
if (!promotedRun) return;
|
||||
|
||||
if (promotionResult?.reopenedActivity) {
|
||||
await logActivity(db, promotionResult.reopenedActivity);
|
||||
}
|
||||
|
||||
publishLiveEvent({
|
||||
companyId: promotedRun.companyId,
|
||||
type: "heartbeat.run.queued",
|
||||
|
||||
@@ -2112,6 +2112,28 @@ export function issueService(db: Db) {
|
||||
return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
|
||||
})),
|
||||
|
||||
removeComment: async (commentId: string) => {
|
||||
const currentUserRedactionOptions = {
|
||||
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||
};
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [comment] = await tx
|
||||
.delete(issueComments)
|
||||
.where(eq(issueComments.id, commentId))
|
||||
.returning();
|
||||
|
||||
if (!comment) return null;
|
||||
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(issues.id, comment.issueId));
|
||||
|
||||
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
|
||||
});
|
||||
},
|
||||
|
||||
addComment: async (
|
||||
issueId: string,
|
||||
body: string,
|
||||
|
||||
@@ -97,6 +97,8 @@ export const issuesApi = {
|
||||
const qs = params.toString();
|
||||
return api.get<IssueComment[]>(`/issues/${id}/comments${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
getComment: (id: string, commentId: string) =>
|
||||
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
|
||||
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
||||
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -126,6 +128,8 @@ export const issuesApi = {
|
||||
...(interrupt === undefined ? {} : { interrupt }),
|
||||
},
|
||||
),
|
||||
cancelComment: (id: string, commentId: string) =>
|
||||
api.delete<IssueComment>(`/issues/${id}/comments/${commentId}`),
|
||||
listDocuments: (id: string) => api.get<IssueDocument[]>(`/issues/${id}/documents`),
|
||||
getDocument: (id: string, key: string) => api.get<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||
upsertDocument: (id: string, key: string, data: UpsertIssueDocument) =>
|
||||
|
||||
@@ -31,7 +31,7 @@ function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) {
|
||||
}
|
||||
|
||||
export function BreadcrumbBar() {
|
||||
const { breadcrumbs } = useBreadcrumbs();
|
||||
const { breadcrumbs, mobileToolbar } = useBreadcrumbs();
|
||||
const { toggleSidebar, isMobile } = useSidebar();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
|
||||
@@ -45,6 +45,14 @@ export function BreadcrumbBar() {
|
||||
|
||||
const globalToolbarSlots = <GlobalToolbarPlugins context={globalToolbarSlotContext} />;
|
||||
|
||||
if (isMobile && mobileToolbar) {
|
||||
return (
|
||||
<div className="border-b border-border px-2 h-12 shrink-0 flex items-center">
|
||||
{mobileToolbar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (breadcrumbs.length === 0) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
|
||||
|
||||
@@ -178,6 +178,52 @@ describe("CommentThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the reopen control and infers reopen for closed agent-assigned issues", async () => {
|
||||
const root = createRoot(container);
|
||||
const onAdd = vi.fn(async () => {});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<CommentThread
|
||||
comments={[]}
|
||||
issueStatus="done"
|
||||
currentAssigneeValue="agent:agent-1"
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Re-open");
|
||||
|
||||
const editor = container.querySelector('textarea[aria-label="Comment editor"]') as HTMLTextAreaElement | null;
|
||||
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent === "Comment",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(editor).not.toBeNull();
|
||||
expect(submitButton).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(editor, "Please pick this back up");
|
||||
editor?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.click();
|
||||
});
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("Please pick this back up", true, undefined);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders linked approvals inline in the timeline", () => {
|
||||
const root = createRoot(container);
|
||||
const agent: Agent = {
|
||||
|
||||
@@ -134,6 +134,11 @@ function parseReassignment(target: string): CommentReassignment | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) {
|
||||
const isClosed = issueStatus === "done" || issueStatus === "cancelled";
|
||||
return isClosed && assigneeValue.startsWith("agent:");
|
||||
}
|
||||
|
||||
function humanizeValue(value: string | null): string {
|
||||
if (!value) return "None";
|
||||
return value.replace(/_/g, " ");
|
||||
@@ -647,6 +652,7 @@ export function CommentThread({
|
||||
pendingApprovalAction = null,
|
||||
onVote,
|
||||
onAdd,
|
||||
issueStatus,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
imageUploadHandler,
|
||||
@@ -663,7 +669,6 @@ export function CommentThread({
|
||||
composerDisabledReason = null,
|
||||
}: CommentThreadProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
@@ -784,14 +789,17 @@ export function CommentThread({
|
||||
if (!trimmed) return;
|
||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
|
||||
const reopen = shouldImplicitlyReopenComment(
|
||||
issueStatus,
|
||||
hasReassignment ? reassignTarget : currentAssigneeValue,
|
||||
) ? true : undefined;
|
||||
const submittedBody = trimmed;
|
||||
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
try {
|
||||
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
|
||||
await onAdd(submittedBody, reopen, reassignment ?? undefined);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(true);
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} catch {
|
||||
setBody((current) =>
|
||||
@@ -935,15 +943,6 @@ export function CommentThread({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopen}
|
||||
onChange={(e) => setReopen(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Re-open
|
||||
</label>
|
||||
{enableReassign && reassignOptions.length > 0 && (
|
||||
<InlineEntitySelector
|
||||
value={reassignTarget}
|
||||
|
||||
@@ -5,16 +5,30 @@ import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
||||
import { IssueChatThread, canStopIssueChatRun, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
||||
|
||||
const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||
markdownEditorFocusMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const { appendMock } = vi.hoisted(() => ({
|
||||
appendMock: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const { threadMessagesMock } = vi.hoisted(() => ({
|
||||
threadMessagesMock: vi.fn(() => <div data-testid="thread-messages" />),
|
||||
}));
|
||||
|
||||
const {
|
||||
captureComposerViewportSnapshotMock,
|
||||
restoreComposerViewportSnapshotMock,
|
||||
shouldPreserveComposerViewportMock,
|
||||
} = vi.hoisted(() => ({
|
||||
captureComposerViewportSnapshotMock: vi.fn(),
|
||||
restoreComposerViewportSnapshotMock: vi.fn(),
|
||||
shouldPreserveComposerViewportMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@assistant-ui/react", () => ({
|
||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ThreadPrimitive: {
|
||||
@@ -32,7 +46,7 @@ vi.mock("@assistant-ui/react", () => ({
|
||||
Content: () => null,
|
||||
Parts: () => null,
|
||||
},
|
||||
useAui: () => ({ thread: () => ({ append: vi.fn() }) }),
|
||||
useAui: () => ({ thread: () => ({ append: appendMock }) }),
|
||||
useAuiState: () => false,
|
||||
useMessage: () => ({
|
||||
id: "message",
|
||||
@@ -51,6 +65,16 @@ vi.mock("./transcript/useLiveRunTranscripts", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/issue-chat-scroll", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/issue-chat-scroll")>();
|
||||
return {
|
||||
...actual,
|
||||
captureComposerViewportSnapshot: captureComposerViewportSnapshotMock.mockImplementation(actual.captureComposerViewportSnapshot),
|
||||
restoreComposerViewportSnapshot: restoreComposerViewportSnapshotMock.mockImplementation(actual.restoreComposerViewportSnapshot),
|
||||
shouldPreserveComposerViewport: shouldPreserveComposerViewportMock.mockImplementation(actual.shouldPreserveComposerViewport),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
@@ -126,8 +150,12 @@ describe("IssueChatThread", () => {
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
appendMock.mockReset();
|
||||
markdownEditorFocusMock.mockReset();
|
||||
threadMessagesMock.mockReset();
|
||||
captureComposerViewportSnapshotMock.mockClear();
|
||||
restoreComposerViewportSnapshotMock.mockClear();
|
||||
shouldPreserveComposerViewportMock.mockClear();
|
||||
});
|
||||
|
||||
it("drops the count heading and does not use an internal scrollbox", () => {
|
||||
@@ -338,9 +366,67 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the reopen control and infers reopen for closed agent-assigned issue replies", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
issueStatus="done"
|
||||
currentAssigneeValue="agent:agent-1"
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Re-open");
|
||||
|
||||
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent === "Send",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(editor).not.toBeNull();
|
||||
expect(submitButton).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(editor, "Please pick this back up");
|
||||
editor?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.click();
|
||||
});
|
||||
|
||||
expect(appendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: [{ type: "text", text: "Please pick this back up" }],
|
||||
runConfig: {
|
||||
custom: {
|
||||
reopen: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes a composer focus handle that forwards to the editor", () => {
|
||||
const root = createRoot(container);
|
||||
const composerRef = createRef<{ focus: () => void }>();
|
||||
const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>();
|
||||
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||
const requestAnimationFrameMock = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
@@ -387,6 +473,159 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("restores a cancelled queued draft into the composer handle", () => {
|
||||
const root = createRoot(container);
|
||||
const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>();
|
||||
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||
const requestAnimationFrameMock = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
composerRef={composerRef}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||
expect(editor).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
composerRef.current?.restoreDraft("Queued message");
|
||||
});
|
||||
|
||||
expect(editor?.value).toBe("Queued message");
|
||||
expect(markdownEditorFocusMock).toHaveBeenCalledTimes(1);
|
||||
expect(scrollByMock).toHaveBeenCalledWith({ top: 96, behavior: "smooth" });
|
||||
|
||||
scrollByMock.mockRestore();
|
||||
requestAnimationFrameMock.mockRestore();
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not restore the composer viewport for passive live updates by default", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[{
|
||||
id: "run-1",
|
||||
issueId: "issue-1",
|
||||
status: "running",
|
||||
invocationSource: "comment",
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-04-06T12:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-06T12:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent 1",
|
||||
adapterType: "codex_local",
|
||||
}]}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(restoreComposerViewportSnapshotMock).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("requests composer viewport restoration when live messages arrive during active composer interaction", () => {
|
||||
const root = createRoot(container);
|
||||
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||
shouldPreserveComposerViewportMock.mockReturnValue(true);
|
||||
captureComposerViewportSnapshotMock.mockReturnValue({ composerViewportTop: 420 });
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[{
|
||||
id: "run-1",
|
||||
issueId: "issue-1",
|
||||
status: "running",
|
||||
invocationSource: "comment",
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-04-06T12:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-06T12:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent 1",
|
||||
adapterType: "codex_local",
|
||||
}]}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(restoreComposerViewportSnapshotMock).toHaveBeenCalled();
|
||||
|
||||
scrollByMock.mockRestore();
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("folds chain-of-thought when the same message transitions from running to complete", () => {
|
||||
expect(resolveAssistantMessageFoldedState({
|
||||
messageId: "message-1",
|
||||
@@ -406,4 +645,20 @@ describe("IssueChatThread", () => {
|
||||
previousIsFoldable: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the stop-run action for active run-linked messages even without embedded run status", () => {
|
||||
expect(canStopIssueChatRun({
|
||||
runId: "run-1",
|
||||
runStatus: null,
|
||||
activeRunIds: new Set(["run-1"]),
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the stop-run action for completed historical runs", () => {
|
||||
expect(canStopIssueChatRun({
|
||||
runId: "run-1",
|
||||
runStatus: "cancelled",
|
||||
activeRunIds: new Set<string>(),
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -36,8 +37,10 @@ import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from
|
||||
import {
|
||||
buildIssueChatMessages,
|
||||
formatDurationWords,
|
||||
stabilizeThreadMessages,
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
type StableThreadMessageCacheEntry,
|
||||
type IssueChatTranscriptEntry,
|
||||
type SegmentTiming,
|
||||
} from "../lib/issue-chat-messages";
|
||||
@@ -65,6 +68,11 @@ import { Identity } from "./Identity";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
|
||||
import {
|
||||
captureComposerViewportSnapshot,
|
||||
restoreComposerViewportSnapshot,
|
||||
shouldPreserveComposerViewport,
|
||||
} from "../lib/issue-chat-scroll";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import {
|
||||
@@ -80,7 +88,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
|
||||
interface IssueChatMessageContext {
|
||||
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
||||
@@ -88,12 +96,16 @@ interface IssueChatMessageContext {
|
||||
feedbackTermsUrl: string | null;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
activeRunIds: ReadonlySet<string>;
|
||||
onVote?: (
|
||||
commentId: string,
|
||||
vote: FeedbackVoteValue,
|
||||
options?: { allowSharing?: boolean; reason?: string },
|
||||
) => Promise<void>;
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
stoppingRunId?: string | null;
|
||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
onCancelQueued?: (commentId: string) => void;
|
||||
interruptingQueuedRunId?: string | null;
|
||||
onImageClick?: (src: string) => void;
|
||||
}
|
||||
@@ -102,6 +114,7 @@ const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||
feedbackVoteByTargetId: new Map(),
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
feedbackTermsUrl: null,
|
||||
activeRunIds: new Set<string>(),
|
||||
});
|
||||
|
||||
export function resolveAssistantMessageFoldedState(args: {
|
||||
@@ -125,6 +138,17 @@ export function resolveAssistantMessageFoldedState(args: {
|
||||
return currentFolded;
|
||||
}
|
||||
|
||||
export function canStopIssueChatRun(args: {
|
||||
runId: string | null;
|
||||
runStatus: string | null;
|
||||
activeRunIds: ReadonlySet<string>;
|
||||
}) {
|
||||
const { runId, runStatus, activeRunIds } = args;
|
||||
if (!runId) return false;
|
||||
if (activeRunIds.has(runId)) return true;
|
||||
return runStatus === "queued" || runStatus === "running";
|
||||
}
|
||||
|
||||
function findCoTSegmentIndex(
|
||||
messageParts: ReadonlyArray<{ type: string }>,
|
||||
cotParts: ReadonlyArray<{ type: string }>,
|
||||
@@ -162,6 +186,7 @@ interface CommentReassignment {
|
||||
|
||||
export interface IssueChatComposerHandle {
|
||||
focus: () => void;
|
||||
restoreDraft: (submittedBody: string) => void;
|
||||
}
|
||||
|
||||
interface IssueChatComposerProps {
|
||||
@@ -199,6 +224,7 @@ interface IssueChatThreadProps {
|
||||
) => Promise<void>;
|
||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||
onCancelRun?: () => Promise<void>;
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
onAttachImage?: (file: File) => Promise<void>;
|
||||
draftKey?: string;
|
||||
@@ -217,7 +243,9 @@ interface IssueChatThreadProps {
|
||||
hasOutputForRun?: (runId: string) => boolean;
|
||||
includeSucceededRunsWithoutOutput?: boolean;
|
||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
onCancelQueued?: (commentId: string) => void;
|
||||
interruptingQueuedRunId?: string | null;
|
||||
stoppingRunId?: string | null;
|
||||
onImageClick?: (src: string) => void;
|
||||
composerRef?: Ref<IssueChatComposerHandle>;
|
||||
}
|
||||
@@ -412,6 +440,11 @@ function parseReassignment(target: string): PaperclipIssueRuntimeReassignment |
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) {
|
||||
const isClosed = issueStatus === "done" || issueStatus === "cancelled";
|
||||
return isClosed && assigneeValue.startsWith("agent:");
|
||||
}
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function commentDateLabel(date: Date | string | undefined): string {
|
||||
@@ -873,10 +906,11 @@ function IssueChatToolPart({
|
||||
}
|
||||
|
||||
function IssueChatUserMessage() {
|
||||
const { onInterruptQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
|
||||
const { onInterruptQueued, onCancelQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id;
|
||||
const queued = custom.queueState === "queued" || custom.clientStatus === "queued";
|
||||
const pending = custom.clientStatus === "pending";
|
||||
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
|
||||
@@ -911,6 +945,16 @@ function IssueChatUserMessage() {
|
||||
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
||||
</Button>
|
||||
) : null}
|
||||
{onCancelQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
|
||||
onClick={() => onCancelQueued(commentId)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
@@ -976,6 +1020,9 @@ function IssueChatAssistantMessage() {
|
||||
feedbackTermsUrl,
|
||||
onVote,
|
||||
agentMap,
|
||||
activeRunIds,
|
||||
onStopRun,
|
||||
stoppingRunId,
|
||||
} = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
@@ -988,6 +1035,7 @@ function IssueChatAssistantMessage() {
|
||||
const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null;
|
||||
const runId = typeof custom.runId === "string" ? custom.runId : null;
|
||||
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
||||
const runStatus = typeof custom.runStatus === "string" ? custom.runStatus : null;
|
||||
const agentId = authorAgentId ?? runAgentId;
|
||||
const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined;
|
||||
const commentId = typeof custom.commentId === "string" ? custom.commentId : null;
|
||||
@@ -997,6 +1045,7 @@ function IssueChatAssistantMessage() {
|
||||
const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : "";
|
||||
const isRunning = message.role === "assistant" && message.status?.type === "running";
|
||||
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
|
||||
const canStopRun = canStopIssueChatRun({ runId, runStatus, activeRunIds });
|
||||
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
|
||||
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
|
||||
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
||||
@@ -1162,6 +1211,18 @@ function IssueChatAssistantMessage() {
|
||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
Copy message
|
||||
</DropdownMenuItem>
|
||||
{canStopRun && onStopRun && runId ? (
|
||||
<DropdownMenuItem
|
||||
disabled={stoppingRunId === runId}
|
||||
className="text-red-700 focus:text-red-800 dark:text-red-300 dark:focus:text-red-200"
|
||||
onSelect={() => {
|
||||
void onStopRun(runId);
|
||||
}}
|
||||
>
|
||||
<Square className="mr-2 h-3.5 w-3.5 fill-current" />
|
||||
{stoppingRunId === runId ? "Stopping…" : "Stop run"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{runHref ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={runHref} target="_blank" rel="noreferrer noopener">
|
||||
@@ -1557,7 +1618,6 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
}, forwardedRef) {
|
||||
const api = useAui();
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
@@ -1567,6 +1627,23 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
function queueViewportRestore(snapshot: ReturnType<typeof captureComposerViewportSnapshot>) {
|
||||
if (!snapshot) return;
|
||||
requestAnimationFrame(() => {
|
||||
restoreComposerViewportSnapshot(snapshot, composerContainerRef.current);
|
||||
});
|
||||
}
|
||||
|
||||
function focusComposer() {
|
||||
if (typeof composerContainerRef.current?.scrollIntoView === "function") {
|
||||
composerContainerRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" });
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
setBody(loadDraft(draftKey));
|
||||
@@ -1591,12 +1668,15 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
}, [effectiveSuggestedAssigneeValue]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: () => {
|
||||
composerContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" });
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
focus: focusComposer,
|
||||
restoreDraft: (submittedBody: string) => {
|
||||
setBody((current) =>
|
||||
restoreSubmittedCommentDraft({
|
||||
currentBody: current,
|
||||
submittedBody,
|
||||
}),
|
||||
);
|
||||
focusComposer();
|
||||
},
|
||||
}), []);
|
||||
|
||||
@@ -1606,12 +1686,17 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
|
||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : undefined;
|
||||
const reopen = shouldImplicitlyReopenComment(
|
||||
issueStatus,
|
||||
hasReassignment ? reassignTarget : currentAssigneeValue,
|
||||
) ? true : undefined;
|
||||
const submittedBody = trimmed;
|
||||
const viewportSnapshot = captureComposerViewportSnapshot(composerContainerRef.current);
|
||||
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
try {
|
||||
await api.thread().append({
|
||||
const appendPromise = api.thread().append({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: submittedBody }],
|
||||
metadata: { custom: {} },
|
||||
@@ -1623,8 +1708,9 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
},
|
||||
},
|
||||
});
|
||||
queueViewportRestore(viewportSnapshot);
|
||||
await appendPromise;
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(issueStatus === "done" || issueStatus === "cancelled");
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} catch {
|
||||
setBody((current) =>
|
||||
@@ -1635,6 +1721,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
queueViewportRestore(viewportSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1707,16 +1794,6 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopen}
|
||||
onChange={(event) => setReopen(event.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Re-open
|
||||
</label>
|
||||
|
||||
{enableReassign && reassignOptions.length > 0 ? (
|
||||
<InlineEntitySelector
|
||||
value={reassignTarget}
|
||||
@@ -1781,6 +1858,7 @@ export function IssueChatThread({
|
||||
onVote,
|
||||
onAdd,
|
||||
onCancelRun,
|
||||
onStopRun,
|
||||
imageUploadHandler,
|
||||
onAttachImage,
|
||||
draftKey,
|
||||
@@ -1799,13 +1877,18 @@ export function IssueChatThread({
|
||||
hasOutputForRun: hasOutputForRunOverride,
|
||||
includeSucceededRunsWithoutOutput = false,
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId = null,
|
||||
stoppingRunId = null,
|
||||
onImageClick,
|
||||
composerRef,
|
||||
}: IssueChatThreadProps) {
|
||||
const location = useLocation();
|
||||
const hasScrolledRef = useRef(false);
|
||||
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerViewportSnapshotRef = useRef<ReturnType<typeof captureComposerViewportSnapshot>>(null);
|
||||
const preserveComposerViewportRef = useRef(false);
|
||||
const displayLiveRuns = useMemo(() => {
|
||||
const deduped = new Map<string, LiveRunForIssue>();
|
||||
for (const run of liveRuns) {
|
||||
@@ -1834,14 +1917,22 @@ export function IssueChatThread({
|
||||
activeRun,
|
||||
});
|
||||
}, [activeRun, displayLiveRuns, linkedRuns]);
|
||||
const activeRunIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const run of displayLiveRuns) {
|
||||
if (run.status === "queued" || run.status === "running") {
|
||||
ids.add(run.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [displayLiveRuns]);
|
||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||
runs: enableLiveTranscriptPolling ? transcriptRuns : [],
|
||||
companyId,
|
||||
});
|
||||
const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun;
|
||||
const resolvedHasOutputForRun = hasOutputForRunOverride ?? hasOutputForRun;
|
||||
|
||||
const messages = useMemo(
|
||||
const rawMessages = useMemo(
|
||||
() =>
|
||||
buildIssueChatMessages({
|
||||
comments,
|
||||
@@ -1872,6 +1963,18 @@ export function IssueChatThread({
|
||||
currentUserId,
|
||||
],
|
||||
);
|
||||
const stableMessagesRef = useRef<readonly import("@assistant-ui/react").ThreadMessage[]>([]);
|
||||
const stableMessageCacheRef = useRef<Map<string, StableThreadMessageCacheEntry>>(new Map());
|
||||
const messages = useMemo(() => {
|
||||
const stabilized = stabilizeThreadMessages(
|
||||
rawMessages,
|
||||
stableMessagesRef.current,
|
||||
stableMessageCacheRef.current,
|
||||
);
|
||||
stableMessagesRef.current = stabilized.messages;
|
||||
stableMessageCacheRef.current = stabilized.cache;
|
||||
return stabilized.messages;
|
||||
}, [rawMessages]);
|
||||
|
||||
const isRunning = displayLiveRuns.some((run) => run.status === "queued" || run.status === "running");
|
||||
const feedbackVoteByTargetId = useMemo(() => {
|
||||
@@ -1890,6 +1993,19 @@ export function IssueChatThread({
|
||||
onCancel: onCancelRun,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const composerElement = composerViewportAnchorRef.current;
|
||||
if (preserveComposerViewportRef.current) {
|
||||
restoreComposerViewportSnapshot(
|
||||
composerViewportSnapshotRef.current,
|
||||
composerElement,
|
||||
);
|
||||
}
|
||||
|
||||
composerViewportSnapshotRef.current = captureComposerViewportSnapshot(composerElement);
|
||||
preserveComposerViewportRef.current = shouldPreserveComposerViewport(composerElement);
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return;
|
||||
@@ -1912,8 +2028,12 @@ export function IssueChatThread({
|
||||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
activeRunIds,
|
||||
onVote,
|
||||
onStopRun,
|
||||
stoppingRunId,
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
onImageClick,
|
||||
}),
|
||||
@@ -1923,8 +2043,12 @@ export function IssueChatThread({
|
||||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
activeRunIds,
|
||||
onVote,
|
||||
onStopRun,
|
||||
stoppingRunId,
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
onImageClick,
|
||||
],
|
||||
@@ -1990,20 +2114,22 @@ export function IssueChatThread({
|
||||
</IssueChatErrorBoundary>
|
||||
|
||||
{showComposer ? (
|
||||
<IssueChatComposer
|
||||
ref={composerRef}
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentions}
|
||||
agentMap={agentMap}
|
||||
composerDisabledReason={composerDisabledReason}
|
||||
issueStatus={issueStatus}
|
||||
/>
|
||||
<div ref={composerViewportAnchorRef}>
|
||||
<IssueChatComposer
|
||||
ref={composerRef}
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentions}
|
||||
agentMap={agentMap}
|
||||
composerDisabledReason={composerDisabledReason}
|
||||
issueStatus={issueStatus}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</IssueChatCtx.Provider>
|
||||
|
||||
@@ -226,6 +226,79 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps server-side search scoped to the provided parent issue filters", async () => {
|
||||
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
|
||||
const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" });
|
||||
|
||||
mockIssuesApi.list.mockResolvedValue([serverIssue]);
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[localIssue]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
initialSearch="server"
|
||||
searchFilters={{ parentId: "parent-1" }}
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
|
||||
q: "server",
|
||||
projectId: undefined,
|
||||
parentId: "parent-1",
|
||||
});
|
||||
expect(container.textContent).toContain("Server result");
|
||||
expect(container.textContent).not.toContain("Local issue");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the supplied create defaults and label for sub-issue lists", async () => {
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[createIssue()]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
baseCreateIssueDefaults={{ parentId: "parent-1", projectId: "project-1" }}
|
||||
createIssueLabel="Sub-issue"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.includes("New Sub-issue"),
|
||||
);
|
||||
expect(button).not.toBeUndefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.includes("New Sub-issue"),
|
||||
);
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(dialogState.openNewIssue).toHaveBeenCalledWith({
|
||||
parentId: "parent-1",
|
||||
projectId: "project-1",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("debounces search updates so typing does not notify the page on every keystroke", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
||||
@@ -50,9 +50,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, Columns3, User, Search } from "lucide-react";
|
||||
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search } from "lucide-react";
|
||||
import { KanbanBoard } from "./KanbanBoard";
|
||||
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
|
||||
import type { Issue, Project } from "@paperclipai/shared";
|
||||
const ISSUE_SEARCH_DEBOUNCE_MS = 150;
|
||||
|
||||
@@ -63,6 +64,7 @@ export type IssueViewState = IssueFilterState & {
|
||||
sortDir: "asc" | "desc";
|
||||
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
|
||||
viewMode: "list" | "board";
|
||||
nestingEnabled: boolean;
|
||||
collapsedGroups: string[];
|
||||
collapsedParents: string[];
|
||||
};
|
||||
@@ -73,6 +75,7 @@ const defaultViewState: IssueViewState = {
|
||||
sortDir: "desc",
|
||||
groupBy: "none",
|
||||
viewMode: "list",
|
||||
nestingEnabled: true,
|
||||
collapsedGroups: [],
|
||||
collapsedParents: [],
|
||||
};
|
||||
@@ -118,6 +121,7 @@ interface Agent {
|
||||
}
|
||||
|
||||
type ProjectOption = Pick<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
|
||||
type IssueListRequestFilters = NonNullable<Parameters<typeof issuesApi.list>[1]>;
|
||||
|
||||
interface IssuesListProps {
|
||||
issues: Issue[];
|
||||
@@ -131,9 +135,9 @@ interface IssuesListProps {
|
||||
issueLinkState?: unknown;
|
||||
initialAssignees?: string[];
|
||||
initialSearch?: string;
|
||||
searchFilters?: {
|
||||
participantAgentId?: string;
|
||||
};
|
||||
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
|
||||
baseCreateIssueDefaults?: Record<string, unknown>;
|
||||
createIssueLabel?: string;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
onSearchChange?: (search: string) => void;
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
@@ -214,6 +218,8 @@ export function IssuesList({
|
||||
initialAssignees,
|
||||
initialSearch,
|
||||
searchFilters,
|
||||
baseCreateIssueDefaults,
|
||||
createIssueLabel,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
onSearchChange,
|
||||
onUpdateIssue,
|
||||
@@ -484,8 +490,8 @@ export function IssuesList({
|
||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
|
||||
|
||||
const newIssueDefaults = useCallback((groupKey?: string) => {
|
||||
const defaults: Record<string, string> = {};
|
||||
if (projectId) defaults.projectId = projectId;
|
||||
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
|
||||
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
|
||||
if (groupKey) {
|
||||
if (viewState.groupBy === "status") defaults.status = groupKey;
|
||||
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
|
||||
@@ -494,11 +500,19 @@ export function IssuesList({
|
||||
else defaults.assigneeAgentId = groupKey;
|
||||
}
|
||||
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
|
||||
defaults.parentId = groupKey;
|
||||
const parentIssue = issueById.get(groupKey);
|
||||
if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId));
|
||||
else defaults.parentId = groupKey;
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}, [projectId, viewState.groupBy]);
|
||||
}, [baseCreateIssueDefaults, currentUserId, issueById, projectId, viewState.groupBy]);
|
||||
|
||||
const createActionLabel = createIssueLabel ? `Create ${createIssueLabel}` : "Create Issue";
|
||||
const createButtonLabel = createIssueLabel ? `New ${createIssueLabel}` : "New Issue";
|
||||
const openCreateIssueDialog = useCallback((groupKey?: string) => {
|
||||
openNewIssue(newIssueDefaults(groupKey));
|
||||
}, [newIssueDefaults, openNewIssue]);
|
||||
|
||||
const filterToWorkspace = useCallback((workspaceId: string) => {
|
||||
updateView({ workspaces: [workspaceId] });
|
||||
@@ -530,9 +544,9 @@ export function IssuesList({
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-2 sm:gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||
<Button size="sm" variant="outline" onClick={() => openNewIssue(newIssueDefaults())}>
|
||||
<Button size="sm" variant="outline" onClick={() => openCreateIssueDialog()}>
|
||||
<Plus className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">New Issue</span>
|
||||
<span className="hidden sm:inline">{createButtonLabel}</span>
|
||||
</Button>
|
||||
<IssueSearchInput
|
||||
value={issueSearch}
|
||||
@@ -562,6 +576,19 @@ export function IssuesList({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewState.viewMode === "list" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn("hidden h-8 w-8 shrink-0 sm:inline-flex", viewState.nestingEnabled && "bg-accent")}
|
||||
onClick={() => updateView({ nestingEnabled: !viewState.nestingEnabled })}
|
||||
title={viewState.nestingEnabled ? "Disable parent-child nesting" : "Enable parent-child nesting"}
|
||||
>
|
||||
<ListTree className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<IssueColumnPicker
|
||||
availableColumns={availableIssueColumns}
|
||||
visibleColumnSet={visibleIssueColumnSet}
|
||||
@@ -670,8 +697,8 @@ export function IssuesList({
|
||||
<EmptyState
|
||||
icon={CircleDot}
|
||||
message="No issues match the current filters or search."
|
||||
action="Create Issue"
|
||||
onAction={() => openNewIssue(newIssueDefaults())}
|
||||
action={createActionLabel}
|
||||
onAction={() => openCreateIssueDialog()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -707,7 +734,7 @@ export function IssuesList({
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="ml-auto text-muted-foreground"
|
||||
onClick={() => openNewIssue(newIssueDefaults(group.key))}
|
||||
onClick={() => openCreateIssueDialog(group.key)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -715,7 +742,9 @@ export function IssuesList({
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
{(() => {
|
||||
const { roots, childMap } = buildIssueTree(group.items);
|
||||
const { roots, childMap } = viewState.nestingEnabled
|
||||
? buildIssueTree(group.items)
|
||||
: { roots: group.items, childMap: new Map<string, Issue[]>() };
|
||||
|
||||
const renderIssueRow = (issue: Issue, depth: number) => {
|
||||
const children = childMap.get(issue.id) ?? [];
|
||||
@@ -817,15 +846,15 @@ export function IssuesList({
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
Assignee
|
||||
</span>
|
||||
|
||||
@@ -146,6 +146,20 @@ describe("MarkdownBody", () => {
|
||||
expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<");
|
||||
});
|
||||
|
||||
it("rewrites issue scheme links to internal issue links", () => {
|
||||
const html = renderMarkdown("See issue://PAP-1310 and issue://:PAP-1311.", [
|
||||
{ identifier: "PAP-1310", status: "done" },
|
||||
{ identifier: "PAP-1311", status: "blocked" },
|
||||
]);
|
||||
|
||||
expect(html).toContain('href="/issues/PAP-1310"');
|
||||
expect(html).toContain('href="/issues/PAP-1311"');
|
||||
expect(html).toContain(">issue://PAP-1310<");
|
||||
expect(html).toContain(">issue://:PAP-1311<");
|
||||
expect(html).toContain("text-green-600");
|
||||
expect(html).toContain("text-red-600");
|
||||
});
|
||||
|
||||
it("linkifies issue identifiers inside inline code spans", () => {
|
||||
const html = renderMarkdown("Reference `PAP-1271` here.", [
|
||||
{ identifier: "PAP-1271", status: "done" },
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import { buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import {
|
||||
computeMentionMenuPosition,
|
||||
findClosestAutocompleteAnchor,
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
|
||||
const mdxEditorMockState = vi.hoisted(() => ({
|
||||
emitMountEmptyReset: false,
|
||||
emitMountParseError: false,
|
||||
emitMountSilentEmptyState: false,
|
||||
markdownValues: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("@mdxeditor/editor", async () => {
|
||||
@@ -36,19 +39,29 @@ vi.mock("@mdxeditor/editor", async () => {
|
||||
markdown,
|
||||
placeholder,
|
||||
onChange,
|
||||
onError,
|
||||
className,
|
||||
}: {
|
||||
markdown: string;
|
||||
placeholder?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
className?: string;
|
||||
},
|
||||
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
|
||||
) {
|
||||
mdxEditorMockState.markdownValues.push(markdown);
|
||||
const [content, setContent] = React.useState(markdown);
|
||||
const editableRef = React.useRef<HTMLDivElement>(null);
|
||||
const handle = React.useMemo(() => ({
|
||||
setMarkdown: (value: string) => setContent(value),
|
||||
focus: () => {},
|
||||
focus: () => editableRef.current?.focus(),
|
||||
}), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setContent(markdown);
|
||||
}, [markdown]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setForwardedRef(forwardedRef, null);
|
||||
const timer = window.setTimeout(() => {
|
||||
@@ -57,6 +70,16 @@ vi.mock("@mdxeditor/editor", async () => {
|
||||
setContent("");
|
||||
onChange?.("");
|
||||
}
|
||||
if (mdxEditorMockState.emitMountSilentEmptyState) {
|
||||
setContent("");
|
||||
}
|
||||
if (mdxEditorMockState.emitMountParseError) {
|
||||
setContent("");
|
||||
onError?.({
|
||||
error: "Unsupported markdown syntax",
|
||||
source: markdown,
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
@@ -64,7 +87,17 @@ vi.mock("@mdxeditor/editor", async () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div data-testid="mdx-editor">{content || placeholder || ""}</div>;
|
||||
return (
|
||||
<div
|
||||
ref={editableRef}
|
||||
data-testid="mdx-editor"
|
||||
className={className}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
>
|
||||
{content || placeholder || ""}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -105,16 +138,33 @@ async function flush() {
|
||||
|
||||
describe("MarkdownEditor", () => {
|
||||
let container: HTMLDivElement;
|
||||
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
originalRangeRect = Range.prototype.getBoundingClientRect;
|
||||
Range.prototype.getBoundingClientRect = () => ({
|
||||
x: 32,
|
||||
y: 24,
|
||||
width: 12,
|
||||
height: 18,
|
||||
top: 24,
|
||||
right: 44,
|
||||
bottom: 42,
|
||||
left: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
Range.prototype.getBoundingClientRect = originalRangeRect;
|
||||
vi.clearAllMocks();
|
||||
mdxEditorMockState.emitMountEmptyReset = false;
|
||||
mdxEditorMockState.emitMountParseError = false;
|
||||
mdxEditorMockState.emitMountSilentEmptyState = false;
|
||||
mdxEditorMockState.markdownValues = [];
|
||||
});
|
||||
|
||||
it("applies async external value updates once the editor ref becomes ready", async () => {
|
||||
@@ -172,6 +222,94 @@ describe("MarkdownEditor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("converts advisory-style html image tags to markdown image syntax before mounting the editor", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value={`Before\n\n<img width="10" height="10" alt="image" src="https://example.com/test.png" />\n\nAfter`}
|
||||
onChange={() => {}}
|
||||
placeholder="Markdown body"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(mdxEditorMockState.markdownValues.at(-1)).toContain("");
|
||||
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
|
||||
expect(container.textContent).toContain("Before");
|
||||
expect(container.textContent).toContain("After");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a raw textarea when the rich parser rejects the markdown", async () => {
|
||||
mdxEditorMockState.emitMountParseError = true;
|
||||
const handleChange = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value="Affected versions: <= v0.3.1"
|
||||
onChange={handleChange}
|
||||
placeholder="Markdown body"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelector("textarea")).not.toBeNull();
|
||||
});
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
expect(textarea).not.toBeNull();
|
||||
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
|
||||
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a raw textarea when the rich editor mounts into the placeholder without callbacks", async () => {
|
||||
mdxEditorMockState.emitMountSilentEmptyState = true;
|
||||
const handleChange = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value="Affected versions: <= v0.3.1"
|
||||
onChange={handleChange}
|
||||
placeholder="Add a description..."
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelector("textarea")).not.toBeNull();
|
||||
});
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
expect(textarea).not.toBeNull();
|
||||
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
|
||||
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
|
||||
expect(
|
||||
computeMentionMenuPosition(
|
||||
@@ -312,4 +450,64 @@ describe("MarkdownEditor", () => {
|
||||
|
||||
editable.remove();
|
||||
});
|
||||
|
||||
it("accepts mention selection from touchstart taps", async () => {
|
||||
const handleChange = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value="@Pap"
|
||||
onChange={handleChange}
|
||||
mentions={[
|
||||
{
|
||||
id: "project:project-123",
|
||||
kind: "project",
|
||||
name: "Paperclip App",
|
||||
projectId: "project-123",
|
||||
projectColor: "#336699",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const editable = container.querySelector('[contenteditable="true"]');
|
||||
expect(editable).not.toBeNull();
|
||||
|
||||
const textNode = editable?.firstChild;
|
||||
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode!, "@Pap".length);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("selectionchange"));
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const option = Array.from(document.body.querySelectorAll('button[type="button"]'))
|
||||
.find((node) => node.textContent?.includes("Paperclip App"));
|
||||
expect(option).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
option?.dispatchEvent(new Event("touchstart", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
`[@Paperclip App](${buildProjectMentionHref("project-123", "#336699")}) `,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type TouchEvent as ReactTouchEvent,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
@@ -75,10 +78,76 @@ export interface MarkdownEditorRef {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
function readHtmlAttribute(attrs: string, name: string): string | null {
|
||||
const match = new RegExp(`${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attrs);
|
||||
return match?.[2] ?? match?.[3] ?? match?.[4] ?? null;
|
||||
}
|
||||
|
||||
function convertHtmlImagesToMarkdown(text: string): string {
|
||||
return text.replace(/<img\b([^>]*?)\/?>/gi, (tag, attrs: string) => {
|
||||
const src = readHtmlAttribute(attrs, "src");
|
||||
if (!src) return tag;
|
||||
const alt = readHtmlAttribute(attrs, "alt") ?? "image";
|
||||
const title = readHtmlAttribute(attrs, "title");
|
||||
const escapedAlt = alt.replace(/[[\]]/g, "\\$&");
|
||||
const escapedTitle = title?.replace(/"/g, '\\"');
|
||||
return escapedTitle
|
||||
? ``
|
||||
: ``;
|
||||
});
|
||||
}
|
||||
|
||||
function prepareMarkdownForEditor(value: string): string {
|
||||
const normalizedLineEndings = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
return convertHtmlImagesToMarkdown(normalizedLineEndings);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function hasMeaningfulEditorContent(node: Node | null): boolean {
|
||||
if (!node) return false;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return (node.textContent ?? "").trim().length > 0;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const element = node as HTMLElement;
|
||||
if (["IMG", "HR", "TABLE", "VIDEO", "IFRAME"].includes(element.tagName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Array.from(element.childNodes).some((child) => hasMeaningfulEditorContent(child));
|
||||
}
|
||||
|
||||
function isRichEditorDomEmpty(
|
||||
editable: HTMLElement,
|
||||
expectedValue: string,
|
||||
placeholder?: string,
|
||||
): boolean {
|
||||
const expectedText = expectedValue.trim();
|
||||
if (!expectedText) return false;
|
||||
|
||||
const visibleText = (editable.textContent ?? "").trim();
|
||||
if (visibleText.length === 0) {
|
||||
return !Array.from(editable.childNodes).some((child) => hasMeaningfulEditorContent(child));
|
||||
}
|
||||
|
||||
const normalizedPlaceholder = placeholder?.trim();
|
||||
if (
|
||||
normalizedPlaceholder
|
||||
&& visibleText === normalizedPlaceholder
|
||||
&& expectedText !== normalizedPlaceholder
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSafeMarkdownLinkUrl(url: string): boolean {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return true;
|
||||
@@ -417,12 +486,14 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
mentions,
|
||||
onSubmit,
|
||||
}: MarkdownEditorProps, forwardedRef) {
|
||||
const editorValue = useMemo(() => prepareMarkdownForEditor(value), [value]);
|
||||
const { slashCommands } = useEditorAutocomplete();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const ref = useRef<MDXEditorMethods>(null);
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
const latestValueRef = useRef(value);
|
||||
const fallbackTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const valueRef = useRef(editorValue);
|
||||
valueRef.current = editorValue;
|
||||
const latestValueRef = useRef(editorValue);
|
||||
const initialChildOnChangeRef = useRef(true);
|
||||
/**
|
||||
* After imperative `setMarkdown` (prop sync, mentions, image upload), MDXEditor may emit `onChange`
|
||||
@@ -432,6 +503,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const echoIgnoreMarkdownRef = useRef<string | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [richEditorError, setRichEditorError] = useState<string | null>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
// Stable ref for imageUploadHandler so plugins don't recreate on every render
|
||||
@@ -443,6 +515,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const mentionStateRef = useRef<MentionState | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const skillEnterArmedRef = useRef(false);
|
||||
const autocompleteSelectionHandledRef = useRef(false);
|
||||
const mentionActive = mentionState !== null && (
|
||||
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|
||||
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
|
||||
@@ -491,9 +564,59 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: () => {
|
||||
if (richEditorError) {
|
||||
fallbackTextareaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
},
|
||||
}), []);
|
||||
}), [richEditorError]);
|
||||
|
||||
const autoSizeFallbackTextarea = useCallback((element: HTMLTextAreaElement | null) => {
|
||||
if (!element) return;
|
||||
element.style.height = "auto";
|
||||
element.style.height = `${element.scrollHeight}px`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!richEditorError) return;
|
||||
autoSizeFallbackTextarea(fallbackTextareaRef.current);
|
||||
}, [autoSizeFallbackTextarea, richEditorError, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (richEditorError || editorValue.trim().length === 0) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let timeoutId = 0;
|
||||
const scheduleCheck = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
timeoutId = window.setTimeout(() => {
|
||||
const editable = container.querySelector('[contenteditable="true"]');
|
||||
if (!(editable instanceof HTMLElement)) return;
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement === editable || editable.contains(activeElement)) return;
|
||||
if (isRichEditorDomEmpty(editable, editorValue, placeholder)) {
|
||||
setRichEditorError("Rich editor failed to load content");
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
scheduleCheck();
|
||||
const observer = new MutationObserver(() => {
|
||||
scheduleCheck();
|
||||
});
|
||||
observer.observe(container, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [editorValue, placeholder, richEditorError]);
|
||||
|
||||
// Whether the image plugin should be included (boolean is stable across renders
|
||||
// as long as the handler presence doesn't toggle)
|
||||
@@ -558,15 +681,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
}, [hasImageUpload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== latestValueRef.current) {
|
||||
if (editorValue !== latestValueRef.current) {
|
||||
if (ref.current) {
|
||||
// Pair with onChange echo suppression (echoIgnoreMarkdownRef).
|
||||
echoIgnoreMarkdownRef.current = value;
|
||||
ref.current.setMarkdown(value);
|
||||
latestValueRef.current = value;
|
||||
echoIgnoreMarkdownRef.current = editorValue;
|
||||
ref.current.setMarkdown(editorValue);
|
||||
latestValueRef.current = editorValue;
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
}, [editorValue]);
|
||||
|
||||
const decorateProjectMentions = useCallback(() => {
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
@@ -676,6 +799,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
};
|
||||
}, [checkMention, mentionActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mentionActive) return;
|
||||
autocompleteSelectionHandledRef.current = false;
|
||||
}, [mentionActive]);
|
||||
|
||||
useEffect(() => {
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
if (!editable) return;
|
||||
@@ -696,7 +824,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
// Read from ref to avoid stale-closure issues (selectionchange can
|
||||
// update state between the last render and this callback firing).
|
||||
const state = mentionStateRef.current;
|
||||
if (!state) return;
|
||||
if (!state) return false;
|
||||
const current = latestValueRef.current;
|
||||
const next = applyMention(current, state, option);
|
||||
if (next !== current) {
|
||||
@@ -729,10 +857,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return true;
|
||||
},
|
||||
[decorateProjectMentions, onChange],
|
||||
);
|
||||
|
||||
const handleAutocompletePress = useCallback((
|
||||
event: ReactMouseEvent<HTMLButtonElement> | ReactPointerEvent<HTMLButtonElement> | ReactTouchEvent<HTMLButtonElement>,
|
||||
option: AutocompleteOption,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (autocompleteSelectionHandledRef.current) return;
|
||||
const handled = selectMention(option);
|
||||
if (handled) {
|
||||
autocompleteSelectionHandledRef.current = true;
|
||||
}
|
||||
}, [selectMention]);
|
||||
|
||||
function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
|
||||
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
||||
}
|
||||
@@ -761,6 +903,52 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
)
|
||||
: null;
|
||||
|
||||
if (richEditorError) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative paperclip-mdxeditor-scope",
|
||||
bordered ? "rounded-md border border-border bg-transparent" : "bg-transparent",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-3 pt-2 text-xs text-muted-foreground">
|
||||
<p>Rich editor unavailable for this markdown. Showing raw source instead.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 underline underline-offset-2 hover:text-foreground"
|
||||
onClick={() => {
|
||||
setRichEditorError(null);
|
||||
}}
|
||||
>
|
||||
Retry rich editor
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={fallbackTextareaRef}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value);
|
||||
autoSizeFallbackTextarea(event.target);
|
||||
}}
|
||||
onBlur={() => onBlur?.()}
|
||||
onKeyDown={(event) => {
|
||||
if (onSubmit && event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"min-h-[12rem] w-full resize-none bg-transparent px-3 pb-3 pt-2 font-mono text-sm leading-6 outline-none",
|
||||
contentClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -868,7 +1056,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
>
|
||||
<MDXEditor
|
||||
ref={setEditorRef}
|
||||
markdown={value}
|
||||
markdown={editorValue}
|
||||
placeholder={placeholder}
|
||||
onChange={(next) => {
|
||||
const echo = echoIgnoreMarkdownRef.current;
|
||||
@@ -883,9 +1071,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
|
||||
if (initialChildOnChangeRef.current) {
|
||||
initialChildOnChangeRef.current = false;
|
||||
if (next === "" && value !== "") {
|
||||
echoIgnoreMarkdownRef.current = value;
|
||||
ref.current?.setMarkdown(value);
|
||||
if (next === "" && editorValue !== "") {
|
||||
echoIgnoreMarkdownRef.current = editorValue;
|
||||
ref.current?.setMarkdown(editorValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -893,6 +1081,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
onChange(next);
|
||||
}}
|
||||
onBlur={() => onBlur?.()}
|
||||
onError={(payload) => {
|
||||
setRichEditorError(payload.error);
|
||||
}}
|
||||
className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")}
|
||||
contentEditableClassName={cn(
|
||||
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
|
||||
@@ -917,10 +1108,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
||||
i === mentionIndex && "bg-accent",
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault(); // prevent blur
|
||||
selectMention(option);
|
||||
}}
|
||||
onPointerDown={(e) => handleAutocompletePress(e, option)}
|
||||
onMouseDown={(e) => handleAutocompletePress(e, option)}
|
||||
onTouchStart={(e) => handleAutocompletePress(e, option)}
|
||||
onMouseEnter={() => {
|
||||
if (mentionStateRef.current?.trigger === "skill") {
|
||||
skillEnterArmedRef.current = true;
|
||||
|
||||
@@ -372,6 +372,43 @@ describe("NewIssueDialog", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("submits the parent assignee when a sub-issue opens with inherited defaults", async () => {
|
||||
dialogState.newIssueDefaults = {
|
||||
parentId: "issue-1",
|
||||
parentIdentifier: "PAP-1",
|
||||
parentTitle: "Parent issue",
|
||||
title: "Child issue",
|
||||
projectId: "project-1",
|
||||
goalId: "goal-1",
|
||||
assigneeAgentId: "agent-1",
|
||||
};
|
||||
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Create Sub-Issue"));
|
||||
expect(submitButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
title: "Child issue",
|
||||
parentId: "issue-1",
|
||||
goalId: "goal-1",
|
||||
projectId: "project-1",
|
||||
assigneeAgentId: "agent-1",
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the mobile dialog bounded with an internal flexible scroll region", async () => {
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
@@ -8,17 +8,24 @@ export interface Breadcrumb {
|
||||
interface BreadcrumbContextValue {
|
||||
breadcrumbs: Breadcrumb[];
|
||||
setBreadcrumbs: (crumbs: Breadcrumb[]) => void;
|
||||
mobileToolbar: ReactNode | null;
|
||||
setMobileToolbar: (node: ReactNode | null) => void;
|
||||
}
|
||||
|
||||
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
|
||||
|
||||
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||
const [breadcrumbs, setBreadcrumbsState] = useState<Breadcrumb[]>([]);
|
||||
const [mobileToolbar, setMobileToolbarState] = useState<ReactNode | null>(null);
|
||||
|
||||
const setBreadcrumbs = useCallback((crumbs: Breadcrumb[]) => {
|
||||
setBreadcrumbsState(crumbs);
|
||||
}, []);
|
||||
|
||||
const setMobileToolbar = useCallback((node: ReactNode | null) => {
|
||||
setMobileToolbarState(node);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (breadcrumbs.length === 0) {
|
||||
document.title = "Paperclip";
|
||||
@@ -29,7 +36,7 @@ export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||
}, [breadcrumbs]);
|
||||
|
||||
return (
|
||||
<BreadcrumbContext.Provider value={{ breadcrumbs, setBreadcrumbs }}>
|
||||
<BreadcrumbContext.Provider value={{ breadcrumbs, setBreadcrumbs, mobileToolbar, setMobileToolbar }}>
|
||||
{children}
|
||||
</BreadcrumbContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
// @vitest-environment node
|
||||
|
||||
const { getCommentMock } = vi.hoisted(() => ({
|
||||
getCommentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
getComment: getCommentMock,
|
||||
},
|
||||
}));
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -163,6 +173,244 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
refetchType: "inactive",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps visible issue detail refetches inactive for downstream agent updates", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: (key: unknown) => {
|
||||
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-759",
|
||||
assigneeAgentId: "agent-1",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
action: "issue.updated",
|
||||
actorType: "system",
|
||||
actorId: "heartbeat",
|
||||
details: {
|
||||
identifier: "PAP-759",
|
||||
source: "deferred_comment_wake",
|
||||
},
|
||||
},
|
||||
{ userId: null, agentId: null },
|
||||
{ pathname: "/PAP/issues/PAP-759", isForegrounded: true },
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.detail("issue-1"),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.activity("issue-1"),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
});
|
||||
|
||||
it("still actively refetches visible issue detail for board-authored updates", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: (key: unknown) => {
|
||||
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-759",
|
||||
assigneeAgentId: "agent-1",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
action: "issue.updated",
|
||||
actorType: "user",
|
||||
actorId: "user-2",
|
||||
details: {
|
||||
identifier: "PAP-759",
|
||||
status: "in_progress",
|
||||
},
|
||||
},
|
||||
{ userId: "user-1", agentId: null },
|
||||
{ pathname: "/PAP/issues/PAP-759", isForegrounded: true },
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.detail("issue-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.activity("issue-1"),
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.detail("issue-1"),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps visible issue comment updates inactive-only instead of active refetching", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: (key: unknown) => {
|
||||
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-759",
|
||||
assigneeAgentId: "agent-1",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
action: "issue.comment_added",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
details: {
|
||||
identifier: "PAP-759",
|
||||
commentId: "comment-1",
|
||||
bodySnippet: "New agent comment",
|
||||
},
|
||||
},
|
||||
{ userId: null, agentId: null },
|
||||
{ pathname: "/PAP/issues/PAP-759", isForegrounded: true },
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.detail("issue-1"),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.activity("issue-1"),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.comments("issue-1"),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LiveUpdatesProvider visible issue comment hydration", () => {
|
||||
it("hydrates the visible issue comments cache with only the new comment", async () => {
|
||||
getCommentMock.mockResolvedValueOnce({
|
||||
id: "comment-2",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
body: "Second comment",
|
||||
createdAt: "2026-04-13T15:00:00.000Z",
|
||||
updatedAt: "2026-04-13T15:00:00.000Z",
|
||||
});
|
||||
|
||||
const setCalls: Array<{ key: unknown; value: unknown }> = [];
|
||||
const queryClient = {
|
||||
getQueryData: (key: unknown) => {
|
||||
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-759",
|
||||
assigneeAgentId: "agent-1",
|
||||
};
|
||||
}
|
||||
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.comments("PAP-759"))) {
|
||||
return {
|
||||
pages: [[{
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "First comment",
|
||||
createdAt: "2026-04-13T14:00:00.000Z",
|
||||
updatedAt: "2026-04-13T14:00:00.000Z",
|
||||
}]],
|
||||
pageParams: [null],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
setQueryData: (key: unknown, updater: (value: unknown) => unknown) => {
|
||||
const current = queryClient.getQueryData(key);
|
||||
setCalls.push({ key, value: updater(current) });
|
||||
},
|
||||
invalidateQueries: vi.fn(),
|
||||
};
|
||||
|
||||
await __liveUpdatesTestUtils.hydrateVisibleIssueComment(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
action: "issue.comment_added",
|
||||
details: {
|
||||
identifier: "PAP-759",
|
||||
commentId: "comment-2",
|
||||
},
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
);
|
||||
|
||||
expect(getCommentMock).toHaveBeenCalledWith("PAP-759", "comment-2");
|
||||
expect(setCalls).toHaveLength(1);
|
||||
expect(setCalls[0]?.key).toEqual(queryKeys.issues.comments("PAP-759"));
|
||||
expect(setCalls[0]?.value).toEqual({
|
||||
pages: [[
|
||||
{
|
||||
id: "comment-2",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
body: "Second comment",
|
||||
createdAt: "2026-04-13T15:00:00.000Z",
|
||||
updatedAt: "2026-04-13T15:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "First comment",
|
||||
createdAt: "2026-04-13T14:00:00.000Z",
|
||||
updatedAt: "2026-04-13T14:00:00.000Z",
|
||||
},
|
||||
]],
|
||||
pageParams: [null],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LiveUpdatesProvider visible issue toast suppression", () => {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import type { Agent, Issue, LiveEvent } from "@paperclipai/shared";
|
||||
import { useQuery, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query";
|
||||
import type { Agent, Issue, IssueComment, LiveEvent } from "@paperclipai/shared";
|
||||
import type { RunForIssue } from "../api/activity";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { authApi } from "../api/auth";
|
||||
import { useCompany } from "./CompanyContext";
|
||||
import type { ToastInput } from "./ToastContext";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { upsertIssueCommentInPages } from "../lib/optimistic-issue-comments";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { toCompanyRelativePath } from "../lib/company-routes";
|
||||
import { useLocation } from "../lib/router";
|
||||
@@ -83,6 +85,7 @@ interface VisibleRouteOptions {
|
||||
}
|
||||
|
||||
interface VisibleIssueRouteContext {
|
||||
routeIssueRef: string;
|
||||
issueRefs: Set<string>;
|
||||
assigneeAgentId: string | null;
|
||||
runIds: Set<string>;
|
||||
@@ -189,6 +192,7 @@ function resolveVisibleIssueRouteContext(
|
||||
}
|
||||
|
||||
return {
|
||||
routeIssueRef: issueRef,
|
||||
issueRefs,
|
||||
assigneeAgentId: issue?.assigneeAgentId ?? null,
|
||||
runIds,
|
||||
@@ -254,6 +258,95 @@ function shouldSuppressAgentStatusToastForVisibleIssue(
|
||||
return !!agentId && agentId === context.assigneeAgentId;
|
||||
}
|
||||
|
||||
function shouldDeferIssueRefetchForVisibleAgentActivity(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: VisibleRouteOptions,
|
||||
): boolean {
|
||||
const entityType = readString(payload.entityType);
|
||||
const entityId = readString(payload.entityId);
|
||||
const actorType = readString(payload.actorType);
|
||||
const action = readString(payload.action);
|
||||
const details = readRecord(payload.details);
|
||||
|
||||
if (entityType !== "issue" || !entityId) return false;
|
||||
if (actorType !== "agent" && actorType !== "system") return false;
|
||||
if (action !== "issue.updated") return false;
|
||||
if (readString(details?.source) === "comment") return false;
|
||||
|
||||
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||
if (!context) return false;
|
||||
|
||||
return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, details));
|
||||
}
|
||||
|
||||
function shouldDeferVisibleIssueCommentActivity(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: VisibleRouteOptions,
|
||||
): boolean {
|
||||
const entityType = readString(payload.entityType);
|
||||
const entityId = readString(payload.entityId);
|
||||
const action = readString(payload.action);
|
||||
const details = readRecord(payload.details);
|
||||
|
||||
if (entityType !== "issue" || !entityId) return false;
|
||||
if (action !== "issue.comment_added") return false;
|
||||
|
||||
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||
if (!context) return false;
|
||||
|
||||
return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, details));
|
||||
}
|
||||
|
||||
async function hydrateVisibleIssueComment(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: VisibleRouteOptions,
|
||||
) {
|
||||
const entityType = readString(payload.entityType);
|
||||
const action = readString(payload.action);
|
||||
const details = readRecord(payload.details);
|
||||
const commentId = readString(details?.commentId);
|
||||
|
||||
if (entityType !== "issue" || action !== "issue.comment_added" || !commentId) return false;
|
||||
|
||||
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||
if (!context) return false;
|
||||
|
||||
const entityId = readString(payload.entityId);
|
||||
if (!entityId || !overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, details))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const comment = await issuesApi.getComment(context.routeIssueRef, commentId);
|
||||
queryClient.setQueryData<InfiniteData<IssueComment[], string | null> | undefined>(
|
||||
queryKeys.issues.comments(context.routeIssueRef),
|
||||
(current) => {
|
||||
if (!current) {
|
||||
return {
|
||||
pages: [[comment]],
|
||||
pageParams: [null],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
pages: upsertIssueCommentInPages(current.pages, comment),
|
||||
};
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(context.routeIssueRef) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
|
||||
const AGENT_TOAST_STATUSES = new Set(["error"]);
|
||||
const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]);
|
||||
@@ -481,6 +574,7 @@ function invalidateActivityQueries(
|
||||
companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
currentActor: { userId: string | null; agentId: string | null },
|
||||
options?: { pathname?: string; isForegrounded?: boolean },
|
||||
) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
||||
@@ -504,9 +598,28 @@ function invalidateActivityQueries(
|
||||
(action === "issue.updated" && readString(details?.source) === "comment")) &&
|
||||
((actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) ||
|
||||
(actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId));
|
||||
const visibleIssueAgentActivity =
|
||||
!!options?.pathname &&
|
||||
shouldDeferIssueRefetchForVisibleAgentActivity(
|
||||
queryClient,
|
||||
options.pathname,
|
||||
payload,
|
||||
{ isForegrounded: options.isForegrounded },
|
||||
);
|
||||
const visibleIssueCommentActivity =
|
||||
!!options?.pathname &&
|
||||
shouldDeferVisibleIssueCommentActivity(
|
||||
queryClient,
|
||||
options.pathname,
|
||||
payload,
|
||||
{ isForegrounded: options.isForegrounded },
|
||||
);
|
||||
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
||||
for (const ref of issueRefs) {
|
||||
const invalidationOptions = selfCommentActivity ? { refetchType: "inactive" as const } : undefined;
|
||||
const invalidationOptions =
|
||||
(selfCommentActivity || visibleIssueAgentActivity || visibleIssueCommentActivity)
|
||||
? { refetchType: "inactive" as const }
|
||||
: undefined;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref), ...invalidationOptions });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), ...invalidationOptions });
|
||||
if (action === "issue.comment_added") {
|
||||
@@ -655,7 +768,10 @@ function handleLiveEvent(
|
||||
}
|
||||
|
||||
if (event.type === "activity.logged") {
|
||||
invalidateActivityQueries(queryClient, expectedCompanyId, payload, currentActor);
|
||||
invalidateActivityQueries(queryClient, expectedCompanyId, payload, currentActor, { pathname });
|
||||
if (shouldDeferVisibleIssueCommentActivity(queryClient, pathname, payload)) {
|
||||
void hydrateVisibleIssueComment(queryClient, pathname, payload);
|
||||
}
|
||||
const action = readString(payload.action);
|
||||
const toast =
|
||||
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
|
||||
@@ -712,8 +828,11 @@ export const __liveUpdatesTestUtils = {
|
||||
buildAgentStatusToast,
|
||||
buildRunStatusToast,
|
||||
closeSocketQuietly,
|
||||
hydrateVisibleIssueComment,
|
||||
invalidateActivityQueries,
|
||||
resolveLiveCompanyId,
|
||||
shouldDeferIssueRefetchForVisibleAgentActivity,
|
||||
shouldDeferVisibleIssueCommentActivity,
|
||||
shouldSuppressActivityToastForVisibleIssue,
|
||||
shouldSuppressRunStatusToastForVisibleIssue,
|
||||
shouldSuppressAgentStatusToastForVisibleIssue,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AppendMessage, ExternalStoreAdapter, ThreadMessage } from "@assistant-ui/react";
|
||||
import { usePaperclipIssueRuntime } from "./usePaperclipIssueRuntime";
|
||||
|
||||
const { useExternalStoreRuntimeMock } = vi.hoisted(() => ({
|
||||
useExternalStoreRuntimeMock: vi.fn(() => ({ kind: "runtime" })),
|
||||
}));
|
||||
|
||||
vi.mock("@assistant-ui/react", () => ({
|
||||
useExternalStoreRuntime: useExternalStoreRuntimeMock,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function HookHarness({
|
||||
messages,
|
||||
isRunning,
|
||||
onSend,
|
||||
onCancel,
|
||||
}: {
|
||||
messages: readonly ThreadMessage[];
|
||||
isRunning: boolean;
|
||||
onSend: (options: { body: string; reopen?: boolean; reassignment?: { assigneeAgentId: string | null; assigneeUserId: string | null } }) => Promise<void>;
|
||||
onCancel?: (() => Promise<void>) | undefined;
|
||||
}) {
|
||||
usePaperclipIssueRuntime({
|
||||
messages,
|
||||
isRunning,
|
||||
onSend,
|
||||
onCancel,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function createAppendMessage(body: string): AppendMessage {
|
||||
return {
|
||||
createdAt: new Date("2026-04-11T14:00:02.000Z"),
|
||||
parentId: null,
|
||||
role: "user",
|
||||
sourceId: null,
|
||||
content: [{ type: "text", text: body }],
|
||||
metadata: { custom: {} },
|
||||
attachments: [],
|
||||
runConfig: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function createUserMessage(id: string, text: string): ThreadMessage {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
metadata: { custom: {} },
|
||||
attachments: [],
|
||||
createdAt: new Date("2026-04-11T14:00:00.000Z"),
|
||||
} as unknown as ThreadMessage;
|
||||
}
|
||||
|
||||
function createAssistantMessage(id: string, text: string): ThreadMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
metadata: { custom: {} },
|
||||
status: { type: "complete", reason: "stop" },
|
||||
createdAt: new Date("2026-04-11T14:00:01.000Z"),
|
||||
} as unknown as ThreadMessage;
|
||||
}
|
||||
|
||||
describe("usePaperclipIssueRuntime", () => {
|
||||
afterEach(() => {
|
||||
useExternalStoreRuntimeMock.mockReset();
|
||||
});
|
||||
|
||||
it("keeps the external-store adapter stable across unrelated rerenders", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const messages: ThreadMessage[] = [createUserMessage("message-1", "hello")];
|
||||
const firstOnSend = vi.fn(async () => {});
|
||||
const secondOnSend = vi.fn(async () => {});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<HookHarness
|
||||
messages={messages}
|
||||
isRunning={false}
|
||||
onSend={firstOnSend}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const runtimeCalls = useExternalStoreRuntimeMock.mock.calls as unknown as Array<
|
||||
[ExternalStoreAdapter<ThreadMessage>]
|
||||
>;
|
||||
expect(runtimeCalls.length).toBeGreaterThanOrEqual(1);
|
||||
const firstAdapter = runtimeCalls[0]![0];
|
||||
expect(firstAdapter).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<HookHarness
|
||||
messages={messages}
|
||||
isRunning={false}
|
||||
onSend={secondOnSend}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runtimeCalls.length).toBeGreaterThanOrEqual(2);
|
||||
const secondAdapter = runtimeCalls[1]![0];
|
||||
expect(secondAdapter).toBe(firstAdapter);
|
||||
|
||||
await act(async () => {
|
||||
await secondAdapter.onNew?.(createAppendMessage("latest callback"));
|
||||
});
|
||||
|
||||
expect(firstOnSend).not.toHaveBeenCalled();
|
||||
expect(secondOnSend).toHaveBeenCalledWith({
|
||||
body: "latest callback",
|
||||
reopen: undefined,
|
||||
reassignment: undefined,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("rebuilds the adapter when thread data changes", () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const onSend = vi.fn(async () => {});
|
||||
const firstMessages: ThreadMessage[] = [createUserMessage("message-1", "hello")];
|
||||
const secondMessages: ThreadMessage[] = [...firstMessages, createAssistantMessage("message-2", "world")];
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<HookHarness
|
||||
messages={firstMessages}
|
||||
isRunning={false}
|
||||
onSend={onSend}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const runtimeCalls = useExternalStoreRuntimeMock.mock.calls as unknown as Array<
|
||||
[ExternalStoreAdapter<ThreadMessage>]
|
||||
>;
|
||||
expect(runtimeCalls.length).toBeGreaterThanOrEqual(1);
|
||||
const firstAdapter = runtimeCalls[0]![0];
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<HookHarness
|
||||
messages={secondMessages}
|
||||
isRunning={false}
|
||||
onSend={onSend}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runtimeCalls.length).toBeGreaterThanOrEqual(2);
|
||||
const secondAdapter = runtimeCalls[1]![0];
|
||||
expect(secondAdapter).not.toBe(firstAdapter);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useExternalStoreRuntime, type ThreadMessage, type AppendMessage } from "@assistant-ui/react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
useExternalStoreRuntime,
|
||||
type ThreadMessage,
|
||||
type AppendMessage,
|
||||
type ExternalStoreAdapter,
|
||||
} from "@assistant-ui/react";
|
||||
|
||||
export interface PaperclipIssueRuntimeReassignment {
|
||||
assigneeAgentId: string | null;
|
||||
@@ -37,7 +43,18 @@ export function usePaperclipIssueRuntime({
|
||||
onSend,
|
||||
onCancel,
|
||||
}: UsePaperclipIssueRuntimeOptions) {
|
||||
return useExternalStoreRuntime({
|
||||
const onSendRef = useRef(onSend);
|
||||
const onCancelRef = useRef(onCancel);
|
||||
|
||||
useEffect(() => {
|
||||
onSendRef.current = onSend;
|
||||
}, [onSend]);
|
||||
|
||||
useEffect(() => {
|
||||
onCancelRef.current = onCancel;
|
||||
}, [onCancel]);
|
||||
|
||||
const adapter = useMemo<ExternalStoreAdapter<ThreadMessage>>(() => ({
|
||||
messages,
|
||||
isRunning,
|
||||
onNew: async (message) => {
|
||||
@@ -57,12 +74,18 @@ export function usePaperclipIssueRuntime({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await onSend({
|
||||
await onSendRef.current({
|
||||
body,
|
||||
reopen: custom?.reopen === true ? true : undefined,
|
||||
reassignment,
|
||||
});
|
||||
},
|
||||
...(onCancel ? { onCancel } : {}),
|
||||
});
|
||||
...(onCancel ? {
|
||||
onCancel: async () => {
|
||||
await onCancelRef.current?.();
|
||||
},
|
||||
} : {}),
|
||||
}), [messages, isRunning, !!onCancel]);
|
||||
|
||||
return useExternalStoreRuntime(adapter);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
||||
"issue.checked_out": "checked out",
|
||||
"issue.released": "released",
|
||||
"issue.comment_added": "commented on",
|
||||
"issue.comment_cancelled": "cancelled a queued comment on",
|
||||
"issue.attachment_added": "attached file to",
|
||||
"issue.attachment_removed": "removed attachment from",
|
||||
"issue.document_created": "created document for",
|
||||
@@ -65,6 +66,7 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
||||
"issue.checked_out": "checked out the issue",
|
||||
"issue.released": "released the issue",
|
||||
"issue.comment_added": "added a comment",
|
||||
"issue.comment_cancelled": "cancelled a queued comment",
|
||||
"issue.feedback_vote_saved": "saved feedback on an AI output",
|
||||
"issue.attachment_added": "added an attachment",
|
||||
"issue.attachment_removed": "removed an attachment",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Agent } from "@paperclipai/shared";
|
||||
import {
|
||||
buildAssistantPartsFromTranscript,
|
||||
buildIssueChatMessages,
|
||||
stabilizeThreadMessages,
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
} from "./issue-chat-messages";
|
||||
@@ -527,3 +528,69 @@ describe("buildIssueChatMessages", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stabilizeThreadMessages", () => {
|
||||
it("reuses unchanged message objects across rebuilds", () => {
|
||||
const firstPass = buildIssueChatMessages({
|
||||
comments: [createComment()],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
const firstStable = stabilizeThreadMessages(firstPass, [], new Map());
|
||||
const secondPass = buildIssueChatMessages({
|
||||
comments: [
|
||||
createComment(),
|
||||
createComment({
|
||||
id: "comment-2",
|
||||
body: "New message",
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
}),
|
||||
],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
const secondStable = stabilizeThreadMessages(
|
||||
secondPass,
|
||||
firstStable.messages,
|
||||
firstStable.cache,
|
||||
);
|
||||
|
||||
expect(secondStable.messages).toHaveLength(2);
|
||||
expect(secondStable.messages[0]).toBe(firstStable.messages[0]);
|
||||
expect(secondStable.messages[1]?.id).toBe("comment-2");
|
||||
});
|
||||
|
||||
it("reuses the previous array when nothing semantically changed", () => {
|
||||
const firstPass = buildIssueChatMessages({
|
||||
comments: [createComment()],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
const firstStable = stabilizeThreadMessages(firstPass, [], new Map());
|
||||
const secondPass = buildIssueChatMessages({
|
||||
comments: [createComment()],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
const secondStable = stabilizeThreadMessages(
|
||||
secondPass,
|
||||
firstStable.messages,
|
||||
firstStable.cache,
|
||||
);
|
||||
|
||||
expect(secondStable.messages).toBe(firstStable.messages);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,11 @@ type MessageWithOrder = {
|
||||
message: ThreadMessage;
|
||||
};
|
||||
|
||||
export interface StableThreadMessageCacheEntry {
|
||||
fingerprint: string;
|
||||
message: ThreadMessage;
|
||||
}
|
||||
|
||||
function toDate(value: Date | string | null | undefined) {
|
||||
return value instanceof Date ? value : new Date(value ?? Date.now());
|
||||
}
|
||||
@@ -89,6 +94,41 @@ function toTimestamp(value: Date | string | null | undefined) {
|
||||
return toDate(value).getTime();
|
||||
}
|
||||
|
||||
function fingerprintThreadMessage(message: ThreadMessage) {
|
||||
return JSON.stringify(message);
|
||||
}
|
||||
|
||||
export function stabilizeThreadMessages(
|
||||
messages: readonly ThreadMessage[],
|
||||
previousMessages: readonly ThreadMessage[],
|
||||
previousById: ReadonlyMap<string, StableThreadMessageCacheEntry>,
|
||||
) {
|
||||
const nextById = new Map<string, StableThreadMessageCacheEntry>();
|
||||
let sameSequence = previousMessages.length === messages.length;
|
||||
|
||||
const stabilizedMessages = messages.map((message, index) => {
|
||||
const fingerprint = fingerprintThreadMessage(message);
|
||||
const cached = previousById.get(message.id);
|
||||
const stableMessage =
|
||||
cached && cached.fingerprint === fingerprint
|
||||
? cached.message
|
||||
: message;
|
||||
nextById.set(message.id, {
|
||||
fingerprint,
|
||||
message: stableMessage,
|
||||
});
|
||||
if (sameSequence && previousMessages[index] !== stableMessage) {
|
||||
sameSequence = false;
|
||||
}
|
||||
return stableMessage;
|
||||
});
|
||||
|
||||
return {
|
||||
messages: sameSequence ? previousMessages : stabilizedMessages,
|
||||
cache: nextById,
|
||||
};
|
||||
}
|
||||
|
||||
function sortByCreated<T extends { createdAt: Date | string; id: string }>(items: readonly T[]) {
|
||||
return [...items].sort((a, b) => {
|
||||
const diff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
captureComposerViewportSnapshot,
|
||||
restoreComposerViewportSnapshot,
|
||||
shouldPreserveComposerViewport,
|
||||
} from "./issue-chat-scroll";
|
||||
|
||||
function mockTop(element: HTMLElement, top: number) {
|
||||
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
|
||||
top,
|
||||
bottom: top + 48,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 0,
|
||||
height: 48,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
}
|
||||
|
||||
describe("issue-chat-scroll", () => {
|
||||
it("restores page scroll when the composer shifts in the viewport", () => {
|
||||
const composer = document.createElement("div");
|
||||
document.body.appendChild(composer);
|
||||
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||
|
||||
mockTop(composer, 420);
|
||||
const snapshot = captureComposerViewportSnapshot(composer);
|
||||
|
||||
mockTop(composer, 560);
|
||||
restoreComposerViewportSnapshot(snapshot, composer);
|
||||
|
||||
expect(scrollByMock).toHaveBeenCalledWith({ top: 140, left: 0, behavior: "auto" });
|
||||
|
||||
scrollByMock.mockRestore();
|
||||
composer.remove();
|
||||
});
|
||||
|
||||
it("restores main-content scroll when the layout uses an internal scroller", () => {
|
||||
const mainContent = document.createElement("main");
|
||||
mainContent.id = "main-content";
|
||||
mainContent.style.overflowY = "auto";
|
||||
Object.defineProperty(mainContent, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1800,
|
||||
});
|
||||
Object.defineProperty(mainContent, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 900,
|
||||
});
|
||||
mainContent.scrollTop = 240;
|
||||
document.body.appendChild(mainContent);
|
||||
|
||||
const composer = document.createElement("div");
|
||||
document.body.appendChild(composer);
|
||||
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||
|
||||
mockTop(composer, 300);
|
||||
const snapshot = captureComposerViewportSnapshot(composer);
|
||||
|
||||
mockTop(composer, 380);
|
||||
restoreComposerViewportSnapshot(snapshot, composer);
|
||||
|
||||
expect(mainContent.scrollTop).toBe(320);
|
||||
expect(scrollByMock).not.toHaveBeenCalled();
|
||||
|
||||
scrollByMock.mockRestore();
|
||||
composer.remove();
|
||||
mainContent.remove();
|
||||
});
|
||||
|
||||
it("does not preserve the composer viewport just because the composer is visible", () => {
|
||||
const composer = document.createElement("div");
|
||||
document.body.appendChild(composer);
|
||||
mockTop(composer, 540);
|
||||
|
||||
expect(shouldPreserveComposerViewport(composer)).toBe(false);
|
||||
|
||||
composer.remove();
|
||||
});
|
||||
|
||||
it("preserves the composer viewport when focus stays inside the composer", () => {
|
||||
const composer = document.createElement("div");
|
||||
const input = document.createElement("textarea");
|
||||
composer.appendChild(input);
|
||||
document.body.appendChild(composer);
|
||||
mockTop(composer, 1200);
|
||||
|
||||
input.focus();
|
||||
|
||||
expect(shouldPreserveComposerViewport(composer)).toBe(true);
|
||||
|
||||
composer.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
export type IssueChatScrollTarget =
|
||||
| { type: "element"; element: HTMLElement }
|
||||
| { type: "window" };
|
||||
|
||||
export interface ComposerViewportSnapshot {
|
||||
composerViewportTop: number;
|
||||
}
|
||||
|
||||
export function resolveIssueChatScrollTarget(
|
||||
doc: Document = document,
|
||||
win: Window = window,
|
||||
): IssueChatScrollTarget {
|
||||
const mainContent = doc.getElementById("main-content");
|
||||
|
||||
if (mainContent instanceof HTMLElement) {
|
||||
const overflowY = win.getComputedStyle(mainContent).overflowY;
|
||||
const usesOwnScroll =
|
||||
(overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay")
|
||||
&& mainContent.scrollHeight > mainContent.clientHeight + 1;
|
||||
|
||||
if (usesOwnScroll) {
|
||||
return { type: "element", element: mainContent };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "window" };
|
||||
}
|
||||
|
||||
export function captureComposerViewportSnapshot(
|
||||
composerElement: HTMLElement | null,
|
||||
): ComposerViewportSnapshot | null {
|
||||
if (!composerElement) return null;
|
||||
|
||||
return {
|
||||
composerViewportTop: composerElement.getBoundingClientRect().top,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPreserveComposerViewport(
|
||||
composerElement: HTMLElement | null,
|
||||
doc: Document = document,
|
||||
) {
|
||||
if (!composerElement) return false;
|
||||
|
||||
const activeElement = doc.activeElement;
|
||||
if (activeElement instanceof Node && composerElement.contains(activeElement)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function restoreComposerViewportSnapshot(
|
||||
snapshot: ComposerViewportSnapshot | null,
|
||||
composerElement: HTMLElement | null,
|
||||
doc: Document = document,
|
||||
win: Window = window,
|
||||
) {
|
||||
if (!snapshot || !composerElement) return;
|
||||
|
||||
const delta = composerElement.getBoundingClientRect().top - snapshot.composerViewportTop;
|
||||
if (!Number.isFinite(delta) || Math.abs(delta) < 1) return;
|
||||
|
||||
const target = resolveIssueChatScrollTarget(doc, win);
|
||||
if (target.type === "element") {
|
||||
target.element.scrollTop += delta;
|
||||
return;
|
||||
}
|
||||
|
||||
win.scrollBy({ top: delta, left: 0, behavior: "auto" });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldRenderRichSubIssuesSection } from "./issue-detail-subissues";
|
||||
|
||||
describe("shouldRenderRichSubIssuesSection", () => {
|
||||
it("shows the rich sub-issues section while child issues are loading", () => {
|
||||
expect(shouldRenderRichSubIssuesSection(true, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the rich sub-issues section when at least one child issue exists", () => {
|
||||
expect(shouldRenderRichSubIssuesSection(false, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the rich sub-issues section when there are no child issues", () => {
|
||||
expect(shouldRenderRichSubIssuesSection(false, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function shouldRenderRichSubIssuesSection(childIssuesLoading: boolean, childIssueCount: number): boolean {
|
||||
return childIssuesLoading || childIssueCount > 0;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { buildIssuePropertiesPanelKey } from "./issue-properties-panel-key";
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
status: "in_progress" as const,
|
||||
priority: "medium" as const,
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
projectId: "project-1",
|
||||
parentId: null,
|
||||
createdByUserId: "user-1",
|
||||
hiddenAt: null,
|
||||
labelIds: ["label-1"],
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
ancestors: [],
|
||||
updatedAt: new Date("2026-04-12T12:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildIssuePropertiesPanelKey", () => {
|
||||
it("ignores plain updatedAt churn", () => {
|
||||
const first = buildIssuePropertiesPanelKey(createIssue(), []);
|
||||
const second = buildIssuePropertiesPanelKey(
|
||||
createIssue({ updatedAt: new Date("2026-04-12T12:05:00.000Z") }),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("changes when a displayed property changes", () => {
|
||||
const first = buildIssuePropertiesPanelKey(createIssue(), []);
|
||||
const second = buildIssuePropertiesPanelKey(
|
||||
createIssue({ assigneeAgentId: "agent-2" }),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
type IssuePropertiesPanelKeyIssue = Pick<
|
||||
Issue,
|
||||
| "id"
|
||||
| "status"
|
||||
| "priority"
|
||||
| "assigneeAgentId"
|
||||
| "assigneeUserId"
|
||||
| "projectId"
|
||||
| "parentId"
|
||||
| "createdByUserId"
|
||||
| "hiddenAt"
|
||||
| "labelIds"
|
||||
| "executionPolicy"
|
||||
| "executionState"
|
||||
| "blocks"
|
||||
| "blockedBy"
|
||||
| "ancestors"
|
||||
>;
|
||||
|
||||
type IssuePropertiesPanelKeyChild = Pick<Issue, "id" | "updatedAt" | "identifier" | "title">;
|
||||
|
||||
export function buildIssuePropertiesPanelKey(
|
||||
issue: IssuePropertiesPanelKeyIssue | null | undefined,
|
||||
childIssues: readonly IssuePropertiesPanelKeyChild[],
|
||||
) {
|
||||
if (!issue) return "";
|
||||
|
||||
return JSON.stringify({
|
||||
id: issue.id,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
projectId: issue.projectId,
|
||||
parentId: issue.parentId,
|
||||
createdByUserId: issue.createdByUserId,
|
||||
hiddenAt: issue.hiddenAt,
|
||||
labelIds: issue.labelIds ?? [],
|
||||
executionPolicy: issue.executionPolicy ?? null,
|
||||
executionState: issue.executionState
|
||||
? {
|
||||
status: issue.executionState.status,
|
||||
currentStageType: issue.executionState.currentStageType,
|
||||
currentParticipant: issue.executionState.currentParticipant,
|
||||
returnAssignee: issue.executionState.returnAssignee,
|
||||
}
|
||||
: null,
|
||||
blocks: (issue.blocks ?? []).map((relation) => ({
|
||||
id: relation.id,
|
||||
identifier: relation.identifier ?? null,
|
||||
title: relation.title,
|
||||
status: relation.status,
|
||||
})),
|
||||
blockedBy: (issue.blockedBy ?? []).map((relation) => ({
|
||||
id: relation.id,
|
||||
identifier: relation.identifier ?? null,
|
||||
title: relation.title,
|
||||
status: relation.status,
|
||||
})),
|
||||
parentSummary: issue.ancestors?.[0]
|
||||
? {
|
||||
id: issue.ancestors[0].id,
|
||||
identifier: issue.ancestors[0].identifier ?? null,
|
||||
title: issue.ancestors[0].title,
|
||||
}
|
||||
: null,
|
||||
childIssues: childIssues.map((child) => ({
|
||||
id: child.id,
|
||||
updatedAt: String(child.updatedAt),
|
||||
identifier: child.identifier ?? null,
|
||||
title: child.title,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ describe("issue-reference", () => {
|
||||
expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179");
|
||||
});
|
||||
|
||||
it("normalizes bare identifiers and issue URLs into internal links", () => {
|
||||
it("normalizes bare identifiers, issue URLs, and issue scheme links into internal links", () => {
|
||||
expect(parseIssueReferenceFromHref("pap-1271")).toEqual({
|
||||
issuePathId: "PAP-1271",
|
||||
href: "/issues/PAP-1271",
|
||||
@@ -20,6 +20,14 @@ describe("issue-reference", () => {
|
||||
issuePathId: "PAP-1179",
|
||||
href: "/issues/PAP-1179",
|
||||
});
|
||||
expect(parseIssueReferenceFromHref("issue://PAP-1310")).toEqual({
|
||||
issuePathId: "PAP-1310",
|
||||
href: "/issues/PAP-1310",
|
||||
});
|
||||
expect(parseIssueReferenceFromHref("issue://:PAP-1311")).toEqual({
|
||||
issuePathId: "PAP-1311",
|
||||
href: "/issues/PAP-1311",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes exact inline-code-like issue identifiers", () => {
|
||||
|
||||
@@ -6,7 +6,8 @@ type MarkdownNode = {
|
||||
};
|
||||
|
||||
const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]+-\d+$/i;
|
||||
const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\b[A-Z][A-Z0-9]+-\d+\b/gi;
|
||||
const ISSUE_SCHEME_RE = /^issue:\/\/:?([^?#\s]+)(?:[?#].*)?$/i;
|
||||
const ISSUE_REFERENCE_TOKEN_RE = /issue:\/\/:?[^\s<>()]+|https?:\/\/[^\s<>()]+|\b[A-Z][A-Z0-9]+-\d+\b/gi;
|
||||
|
||||
export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null {
|
||||
if (!pathOrUrl) return null;
|
||||
@@ -29,6 +30,16 @@ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined):
|
||||
|
||||
export function parseIssueReferenceFromHref(href: string | null | undefined) {
|
||||
if (!href) return null;
|
||||
const trimmed = href.trim();
|
||||
const issueSchemeMatch = trimmed.match(ISSUE_SCHEME_RE);
|
||||
if (issueSchemeMatch?.[1]) {
|
||||
const issuePathId = decodeURIComponent(issueSchemeMatch[1]);
|
||||
return {
|
||||
issuePathId,
|
||||
href: `/issues/${encodeURIComponent(issuePathId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const pathId = parseIssuePathIdFromPath(href);
|
||||
if (pathId) {
|
||||
return {
|
||||
@@ -37,7 +48,6 @@ export function parseIssueReferenceFromHref(href: string | null | undefined) {
|
||||
};
|
||||
}
|
||||
|
||||
const trimmed = href.trim();
|
||||
if (!BARE_ISSUE_IDENTIFIER_RE.test(trimmed)) return null;
|
||||
const normalized = trimmed.toUpperCase();
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
isQueuedIssueComment,
|
||||
matchesIssueRef,
|
||||
mergeIssueComments,
|
||||
removeIssueCommentFromPages,
|
||||
takeOptimisticIssueComment,
|
||||
upsertIssueComment,
|
||||
upsertIssueCommentInPages,
|
||||
} from "./optimistic-issue-comments";
|
||||
@@ -101,6 +103,30 @@ describe("optimistic issue comments", () => {
|
||||
expect(merged.map((comment) => comment.id)).toEqual(["optimistic-1", "comment-2"]);
|
||||
});
|
||||
|
||||
it("can take one optimistic queued comment back out of the queue", () => {
|
||||
const first = createOptimisticIssueComment({
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
body: "First",
|
||||
authorUserId: "board-1",
|
||||
clientStatus: "queued",
|
||||
queueTargetRunId: "run-1",
|
||||
});
|
||||
const second = createOptimisticIssueComment({
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
body: "Second",
|
||||
authorUserId: "board-1",
|
||||
clientStatus: "queued",
|
||||
queueTargetRunId: "run-1",
|
||||
});
|
||||
|
||||
const result = takeOptimisticIssueComment([first, second], first.clientId);
|
||||
|
||||
expect(result.comment?.body).toBe("First");
|
||||
expect(result.comments.map((comment) => comment.clientId)).toEqual([second.clientId]);
|
||||
});
|
||||
|
||||
it("upserts confirmed comments without creating duplicates", () => {
|
||||
const next = upsertIssueComment(
|
||||
[
|
||||
@@ -250,6 +276,52 @@ describe("optimistic issue comments", () => {
|
||||
expect(nextPages[1]?.map((comment) => comment.id)).toEqual(["comment-1"]);
|
||||
});
|
||||
|
||||
it("removes a confirmed queued comment from paged caches", () => {
|
||||
const nextPages = removeIssueCommentFromPages(
|
||||
[
|
||||
[
|
||||
{
|
||||
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-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"),
|
||||
},
|
||||
{
|
||||
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"),
|
||||
},
|
||||
],
|
||||
],
|
||||
"comment-2",
|
||||
);
|
||||
|
||||
expect(nextPages).toHaveLength(2);
|
||||
expect(nextPages[0]?.map((comment) => comment.id)).toEqual(["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(
|
||||
{
|
||||
|
||||
@@ -96,6 +96,21 @@ export function mergeIssueComments(
|
||||
return sortIssueComments(merged);
|
||||
}
|
||||
|
||||
export function takeOptimisticIssueComment(
|
||||
comments: OptimisticIssueComment[],
|
||||
clientId: string,
|
||||
): { comments: OptimisticIssueComment[]; comment: OptimisticIssueComment | null } {
|
||||
const index = comments.findIndex((comment) => comment.clientId === clientId);
|
||||
if (index === -1) {
|
||||
return { comments, comment: null };
|
||||
}
|
||||
|
||||
return {
|
||||
comments: comments.filter((comment) => comment.clientId !== clientId),
|
||||
comment: comments[index] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function flattenIssueCommentPages(
|
||||
pages: ReadonlyArray<ReadonlyArray<IssueComment>> | undefined,
|
||||
): IssueComment[] {
|
||||
@@ -254,3 +269,16 @@ export function upsertIssueCommentInPages(
|
||||
nextPages[0] = sortIssueCommentsDesc([...nextPages[0]!, nextComment]);
|
||||
return nextPages;
|
||||
}
|
||||
|
||||
export function removeIssueCommentFromPages(
|
||||
pages: ReadonlyArray<ReadonlyArray<IssueComment>> | undefined,
|
||||
commentId: string,
|
||||
): IssueComment[][] {
|
||||
if (!pages || pages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return pages
|
||||
.map((page) => page.filter((comment) => comment.id !== commentId))
|
||||
.filter((page) => page.length > 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { keepPreviousDataForSameQueryTail } from "./query-placeholder-data";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function Harness({
|
||||
issueId,
|
||||
fetchIssueRuns,
|
||||
}: {
|
||||
issueId: string;
|
||||
fetchIssueRuns: (issueId: string) => Promise<string[]>;
|
||||
}) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["issues", "live-runs", issueId],
|
||||
queryFn: () => fetchIssueRuns(issueId),
|
||||
placeholderData: keepPreviousDataForSameQueryTail(issueId),
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-testid="query-state">
|
||||
{JSON.stringify({
|
||||
issueId,
|
||||
runs: data ?? null,
|
||||
isLoading,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("keepPreviousDataForSameQueryTail", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("clears issue-scoped placeholder data when the query tail changes", async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
});
|
||||
const root = createRoot(container);
|
||||
const issueBRuns = createDeferred<string[]>();
|
||||
|
||||
queryClient.setQueryData(["issues", "live-runs", "issue-a"], ["run-a"]);
|
||||
|
||||
const fetchIssueRuns = (issueId: string) => {
|
||||
if (issueId === "issue-a") return Promise.resolve(["run-a"]);
|
||||
if (issueId === "issue-b") return issueBRuns.promise;
|
||||
return Promise.resolve([]);
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness issueId="issue-a" fetchIssueRuns={fetchIssueRuns} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe(JSON.stringify({
|
||||
issueId: "issue-a",
|
||||
runs: ["run-a"],
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness issueId="issue-b" fetchIssueRuns={fetchIssueRuns} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe(JSON.stringify({
|
||||
issueId: "issue-b",
|
||||
runs: null,
|
||||
isLoading: true,
|
||||
}));
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PlaceholderDataFunction, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
export function keepPreviousDataForSameQueryTail<TQueryData, TQueryKey extends QueryKey = QueryKey>(
|
||||
tail: unknown,
|
||||
): PlaceholderDataFunction<TQueryData, Error, TQueryData, TQueryKey> {
|
||||
return (previousData, previousQuery) => {
|
||||
const previousKey = Array.isArray(previousQuery?.queryKey) ? previousQuery.queryKey : [];
|
||||
return previousKey.at(-1) === tail ? previousData : undefined;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExecutionWorkspace, Issue } from "@paperclipai/shared";
|
||||
import { buildSubIssueDefaults, buildSubIssueDefaultsForViewer } from "./subIssueDefaults";
|
||||
|
||||
function makeExecutionWorkspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
|
||||
return {
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
sourceIssueId: null,
|
||||
status: "active",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Parent workspace",
|
||||
cwd: "/tmp/workspace-1",
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: "feature/pap-1",
|
||||
providerType: "git_worktree",
|
||||
providerRef: null,
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
openedAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
lastUsedAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||
createdAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
goalId: "goal-1",
|
||||
parentId: null,
|
||||
title: "Parent issue",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: "shared_workspace",
|
||||
executionWorkspaceSettings: null,
|
||||
currentExecutionWorkspace: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSubIssueDefaults", () => {
|
||||
it("inherits the parent agent assignee and workspace context", () => {
|
||||
const defaults = buildSubIssueDefaults(
|
||||
makeIssue({
|
||||
assigneeAgentId: "agent-1",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
currentExecutionWorkspace: makeExecutionWorkspace(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(defaults).toEqual({
|
||||
parentId: "issue-1",
|
||||
parentIdentifier: "PAP-1",
|
||||
parentTitle: "Parent issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
goalId: "goal-1",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
executionWorkspaceMode: "reuse_existing",
|
||||
parentExecutionWorkspaceLabel: "Parent workspace",
|
||||
assigneeAgentId: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits a user assignee when the parent is assigned to a user", () => {
|
||||
const defaults = buildSubIssueDefaults(
|
||||
makeIssue({
|
||||
assigneeUserId: "user-1",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(defaults).toEqual({
|
||||
parentId: "issue-1",
|
||||
parentIdentifier: "PAP-1",
|
||||
parentTitle: "Parent issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
goalId: "goal-1",
|
||||
executionWorkspaceMode: "shared_workspace",
|
||||
assigneeUserId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the sub-issue unassigned when the parent assignee is the current user", () => {
|
||||
const defaults = buildSubIssueDefaultsForViewer(
|
||||
makeIssue({
|
||||
assigneeUserId: "user-1",
|
||||
}),
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(defaults).toEqual({
|
||||
parentId: "issue-1",
|
||||
parentIdentifier: "PAP-1",
|
||||
parentTitle: "Parent issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
goalId: "goal-1",
|
||||
executionWorkspaceMode: "shared_workspace",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
type SubIssueDefaultSource = Pick<
|
||||
Issue,
|
||||
| "id"
|
||||
| "identifier"
|
||||
| "title"
|
||||
| "projectId"
|
||||
| "projectWorkspaceId"
|
||||
| "goalId"
|
||||
| "executionWorkspaceId"
|
||||
| "executionWorkspacePreference"
|
||||
| "currentExecutionWorkspace"
|
||||
| "assigneeAgentId"
|
||||
| "assigneeUserId"
|
||||
>;
|
||||
|
||||
export function buildSubIssueDefaults(issue: SubIssueDefaultSource) {
|
||||
return buildSubIssueDefaultsForViewer(issue);
|
||||
}
|
||||
|
||||
export function buildSubIssueDefaultsForViewer(
|
||||
issue: SubIssueDefaultSource,
|
||||
currentUserId?: string | null,
|
||||
) {
|
||||
const parentExecutionWorkspaceLabel =
|
||||
issue.currentExecutionWorkspace?.name
|
||||
?? issue.currentExecutionWorkspace?.branchName
|
||||
?? issue.currentExecutionWorkspace?.cwd
|
||||
?? issue.executionWorkspaceId
|
||||
?? null;
|
||||
const shouldInheritUserAssignee = Boolean(issue.assigneeUserId && issue.assigneeUserId !== currentUserId);
|
||||
const inheritedAssigneeUserId = shouldInheritUserAssignee ? issue.assigneeUserId ?? undefined : undefined;
|
||||
|
||||
return {
|
||||
parentId: issue.id,
|
||||
parentIdentifier: issue.identifier ?? undefined,
|
||||
parentTitle: issue.title,
|
||||
...(issue.projectId ? { projectId: issue.projectId } : {}),
|
||||
...(issue.projectWorkspaceId ? { projectWorkspaceId: issue.projectWorkspaceId } : {}),
|
||||
...(issue.goalId ? { goalId: issue.goalId } : {}),
|
||||
...(issue.executionWorkspaceId ? { executionWorkspaceId: issue.executionWorkspaceId } : {}),
|
||||
...(issue.executionWorkspaceId
|
||||
? { executionWorkspaceMode: "reuse_existing" }
|
||||
: issue.executionWorkspacePreference
|
||||
? { executionWorkspaceMode: issue.executionWorkspacePreference }
|
||||
: {}),
|
||||
...(parentExecutionWorkspaceLabel ? { parentExecutionWorkspaceLabel } : {}),
|
||||
...(issue.assigneeAgentId ? { assigneeAgentId: issue.assigneeAgentId } : {}),
|
||||
...(inheritedAssigneeUserId ? { assigneeUserId: inheritedAssigneeUserId } : {}),
|
||||
};
|
||||
}
|
||||
+815
-459
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user