[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:
Dotta
2026-04-14 12:50:48 -05:00
committed by GitHub
parent 5d1ed71779
commit 6e6f538630
41 changed files with 4141 additions and 590 deletions
@@ -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
View File
@@ -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" });
+96 -20
View File
@@ -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",
+22
View File
@@ -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,
+4
View File
@@ -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) =>
+9 -1
View File
@@ -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">
+46
View File
@@ -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 = {
+11 -12
View File
@@ -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}
+258 -3
View File
@@ -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);
});
});
+163 -37
View File
@@ -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>
+73
View File
@@ -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();
+47 -18
View File
@@ -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>
+14
View File
@@ -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" },
+201 -3
View File
@@ -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("![image](https://example.com/test.png)");
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();
});
});
});
+208 -18
View File
@@ -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
? `![${escapedAlt}](${src} "${escapedTitle}")`
: `![${escapedAlt}](${src})`;
});
}
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;
+37
View File
@@ -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 -1
View File
@@ -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>
);
+248
View File
@@ -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", () => {
+123 -4
View File
@@ -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();
});
});
+28 -5
View File
@@ -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);
}
+2
View File
@@ -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",
+67
View File
@@ -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);
});
});
+40
View File
@@ -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);
+98
View File
@@ -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();
});
});
+70
View File
@@ -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" });
}
+18
View File
@@ -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);
});
});
+3
View File
@@ -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);
});
});
+76
View File
@@ -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,
})),
});
}
+9 -1
View File
@@ -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", () => {
+12 -2
View File
@@ -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(
{
+28
View File
@@ -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);
}
+111
View File
@@ -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();
});
});
+10
View File
@@ -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;
};
}
+136
View File
@@ -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",
});
});
});
+52
View File
@@ -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 } : {}),
};
}
File diff suppressed because it is too large Load Diff