mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 04:00:38 +09:00
[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:
parent
5d1ed71779
commit
6e6f538630
41 changed files with 4141 additions and 590 deletions
|
|
@ -424,6 +424,192 @@ describe("heartbeat comment wake batching", () => {
|
||||||
}
|
}
|
||||||
}, 120_000);
|
}, 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 () => {
|
it("queues exactly one follow-up run when an issue-bound run exits without a comment", async () => {
|
||||||
const gateway = await createControlledGatewayServer();
|
const gateway = await createControlledGatewayServer();
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
|
|
@ -575,4 +761,118 @@ describe("heartbeat comment wake batching", () => {
|
||||||
}
|
}
|
||||||
}, 20_000);
|
}, 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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
191
server/src/__tests__/issue-comment-cancel-routes.test.ts
Normal file
191
server/src/__tests__/issue-comment-cancel-routes.test.ts
Normal file
|
|
@ -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 () => {
|
it("reopens closed issues via the PATCH comment path", async () => {
|
||||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
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 () => {
|
it("interrupts an active run before a combined comment update", async () => {
|
||||||
const issue = {
|
const issue = {
|
||||||
...makeIssue("todo"),
|
...makeIssue("todo"),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
function diffExecutionParticipants(
|
||||||
previousPolicy: NormalizedExecutionPolicy | null,
|
previousPolicy: NormalizedExecutionPolicy | null,
|
||||||
nextPolicy: NormalizedExecutionPolicy | null,
|
nextPolicy: NormalizedExecutionPolicy | null,
|
||||||
|
|
@ -444,6 +460,32 @@ export function issueRoutes(
|
||||||
return runToInterrupt?.status === "running" ? runToInterrupt : null;
|
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 }) {
|
async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) {
|
||||||
if (!issue.executionWorkspaceId) return null;
|
if (!issue.executionWorkspaceId) return null;
|
||||||
const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId);
|
const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId);
|
||||||
|
|
@ -1313,7 +1355,7 @@ export function issueRoutes(
|
||||||
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const isClosed = existing.status === "done" || existing.status === "cancelled";
|
const isClosed = isClosedIssueStatus(existing.status);
|
||||||
const existingRelations =
|
const existingRelations =
|
||||||
Array.isArray(req.body.blockedByIssueIds)
|
Array.isArray(req.body.blockedByIssueIds)
|
||||||
? await svc.getRelationSummaries(existing.id)
|
? await svc.getRelationSummaries(existing.id)
|
||||||
|
|
@ -1325,6 +1367,17 @@ export function issueRoutes(
|
||||||
hiddenAt: hiddenAtRaw,
|
hiddenAt: hiddenAtRaw,
|
||||||
...updateFields
|
...updateFields
|
||||||
} = req.body;
|
} = 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;
|
let interruptedRunId: string | null = null;
|
||||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
|
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
|
||||||
const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0;
|
const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0;
|
||||||
|
|
@ -1367,7 +1420,7 @@ export function issueRoutes(
|
||||||
if (hiddenAtRaw !== undefined) {
|
if (hiddenAtRaw !== undefined) {
|
||||||
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
|
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";
|
updateFields.status = "todo";
|
||||||
}
|
}
|
||||||
if (req.body.executionPolicy !== undefined) {
|
if (req.body.executionPolicy !== undefined) {
|
||||||
|
|
@ -1526,7 +1579,7 @@ export function issueRoutes(
|
||||||
const hasFieldChanges = Object.keys(previous).length > 0;
|
const hasFieldChanges = Object.keys(previous).length > 0;
|
||||||
const reopened =
|
const reopened =
|
||||||
commentBody &&
|
commentBody &&
|
||||||
reopenRequested === true &&
|
effectiveReopenRequested &&
|
||||||
isClosed &&
|
isClosed &&
|
||||||
previous.status !== undefined &&
|
previous.status !== undefined &&
|
||||||
issue.status === "todo";
|
issue.status === "todo";
|
||||||
|
|
@ -1748,7 +1801,7 @@ export function issueRoutes(
|
||||||
const selfComment = actorIsAgent && actor.actorId === assigneeId;
|
const selfComment = actorIsAgent && actor.actorId === assigneeId;
|
||||||
const skipAssigneeCommentWake = selfComment || isClosed;
|
const skipAssigneeCommentWake = selfComment || isClosed;
|
||||||
|
|
||||||
if (assigneeId && !assigneeChanged && !skipAssigneeCommentWake) {
|
if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) {
|
||||||
addWakeup(assigneeId, {
|
addWakeup(assigneeId, {
|
||||||
source: "automation",
|
source: "automation",
|
||||||
triggerDetail: "system",
|
triggerDetail: "system",
|
||||||
|
|
@ -2069,6 +2122,72 @@ export function issueRoutes(
|
||||||
res.json(comment);
|
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) => {
|
router.get("/issues/:id/feedback-votes", async (req, res) => {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const issue = await svc.getById(id);
|
const issue = await svc.getById(id);
|
||||||
|
|
@ -2167,13 +2286,21 @@ export function issueRoutes(
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const reopenRequested = req.body.reopen === true;
|
const reopenRequested = req.body.reopen === true;
|
||||||
const interruptRequested = req.body.interrupt === 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 reopened = false;
|
||||||
let reopenFromStatus: string | null = null;
|
let reopenFromStatus: string | null = null;
|
||||||
let interruptedRunId: string | null = null;
|
let interruptedRunId: string | null = null;
|
||||||
let currentIssue = issue;
|
let currentIssue = issue;
|
||||||
|
|
||||||
if (reopenRequested && isClosed) {
|
if (effectiveReopenRequested && isClosed) {
|
||||||
const reopenedIssue = await svc.update(id, { status: "todo" });
|
const reopenedIssue = await svc.update(id, { status: "todo" });
|
||||||
if (!reopenedIssue) {
|
if (!reopenedIssue) {
|
||||||
res.status(404).json({ error: "Issue not found" });
|
res.status(404).json({ error: "Issue not found" });
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import {
|
||||||
mergeHeartbeatRunResultJson,
|
mergeHeartbeatRunResultJson,
|
||||||
summarizeHeartbeatRunResultJson,
|
summarizeHeartbeatRunResultJson,
|
||||||
} from "./heartbeat-run-summary.js";
|
} from "./heartbeat-run-summary.js";
|
||||||
|
import { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceReadyComment,
|
buildWorkspaceReadyComment,
|
||||||
cleanupExecutionWorkspaceArtifacts,
|
cleanupExecutionWorkspaceArtifacts,
|
||||||
|
|
@ -3485,10 +3486,13 @@ export function heartbeatService(db: Db) {
|
||||||
});
|
});
|
||||||
if (issueId && outcome === "succeeded") {
|
if (issueId && outcome === "succeeded") {
|
||||||
try {
|
try {
|
||||||
|
const existingRunComment = await findRunIssueComment(finalizedRun.id, finalizedRun.companyId, issueId);
|
||||||
|
if (!existingRunComment) {
|
||||||
const issueComment = buildHeartbeatRunIssueComment(persistedResultJson);
|
const issueComment = buildHeartbeatRunIssueComment(persistedResultJson);
|
||||||
if (issueComment) {
|
if (issueComment) {
|
||||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
|
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"stderr",
|
||||||
|
|
@ -3632,22 +3636,40 @@ export function heartbeatService(db: Db) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) {
|
async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) {
|
||||||
const promotedRun = await db.transaction(async (tx) => {
|
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(
|
await tx.execute(
|
||||||
sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`,
|
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({
|
.select({
|
||||||
id: issues.id,
|
id: issues.id,
|
||||||
companyId: issues.companyId,
|
companyId: issues.companyId,
|
||||||
|
identifier: issues.identifier,
|
||||||
|
status: issues.status,
|
||||||
|
executionRunId: issues.executionRunId,
|
||||||
})
|
})
|
||||||
.from(issues)
|
.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);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
if (!issue) return;
|
if (!issue) return null;
|
||||||
|
if (issue.executionRunId && issue.executionRunId !== run.id) return null;
|
||||||
|
|
||||||
|
if (issue.executionRunId === run.id) {
|
||||||
await tx
|
await tx
|
||||||
.update(issues)
|
.update(issues)
|
||||||
.set({
|
.set({
|
||||||
|
|
@ -3657,6 +3679,7 @@ export function heartbeatService(db: Db) {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(issues.id, issue.id));
|
.where(eq(issues.id, issue.id));
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const deferred = await tx
|
const deferred = await tx
|
||||||
|
|
@ -3703,6 +3726,51 @@ export function heartbeatService(db: Db) {
|
||||||
const deferredPayload = parseObject(deferred.payload);
|
const deferredPayload = parseObject(deferred.payload);
|
||||||
const deferredContextSeed = parseObject(deferredPayload[DEFERRED_WAKE_CONTEXT_KEY]);
|
const deferredContextSeed = parseObject(deferredPayload[DEFERRED_WAKE_CONTEXT_KEY]);
|
||||||
const promotedContextSeed: Record<string, unknown> = { ...deferredContextSeed };
|
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 promotedReason = readNonEmptyString(deferred.reason) ?? "issue_execution_promoted";
|
||||||
const promotedSource =
|
const promotedSource =
|
||||||
(readNonEmptyString(deferred.source) as WakeupOptions["source"]) ?? "automation";
|
(readNonEmptyString(deferred.source) as WakeupOptions["source"]) ?? "automation";
|
||||||
|
|
@ -3764,12 +3832,20 @@ export function heartbeatService(db: Db) {
|
||||||
})
|
})
|
||||||
.where(eq(issues.id, issue.id));
|
.where(eq(issues.id, issue.id));
|
||||||
|
|
||||||
return newRun;
|
return {
|
||||||
|
run: newRun,
|
||||||
|
reopenedActivity,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const promotedRun = promotionResult?.run ?? null;
|
||||||
if (!promotedRun) return;
|
if (!promotedRun) return;
|
||||||
|
|
||||||
|
if (promotionResult?.reopenedActivity) {
|
||||||
|
await logActivity(db, promotionResult.reopenedActivity);
|
||||||
|
}
|
||||||
|
|
||||||
publishLiveEvent({
|
publishLiveEvent({
|
||||||
companyId: promotedRun.companyId,
|
companyId: promotedRun.companyId,
|
||||||
type: "heartbeat.run.queued",
|
type: "heartbeat.run.queued",
|
||||||
|
|
|
||||||
|
|
@ -2112,6 +2112,28 @@ export function issueService(db: Db) {
|
||||||
return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
|
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 (
|
addComment: async (
|
||||||
issueId: string,
|
issueId: string,
|
||||||
body: string,
|
body: string,
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@ export const issuesApi = {
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return api.get<IssueComment[]>(`/issues/${id}/comments${qs ? `?${qs}` : ""}`);
|
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`),
|
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
||||||
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
|
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
@ -126,6 +128,8 @@ export const issuesApi = {
|
||||||
...(interrupt === undefined ? {} : { interrupt }),
|
...(interrupt === undefined ? {} : { interrupt }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
cancelComment: (id: string, commentId: string) =>
|
||||||
|
api.delete<IssueComment>(`/issues/${id}/comments/${commentId}`),
|
||||||
listDocuments: (id: string) => api.get<IssueDocument[]>(`/issues/${id}/documents`),
|
listDocuments: (id: string) => api.get<IssueDocument[]>(`/issues/${id}/documents`),
|
||||||
getDocument: (id: string, key: string) => api.get<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
getDocument: (id: string, key: string) => api.get<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||||
upsertDocument: (id: string, key: string, data: UpsertIssueDocument) =>
|
upsertDocument: (id: string, key: string, data: UpsertIssueDocument) =>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BreadcrumbBar() {
|
export function BreadcrumbBar() {
|
||||||
const { breadcrumbs } = useBreadcrumbs();
|
const { breadcrumbs, mobileToolbar } = useBreadcrumbs();
|
||||||
const { toggleSidebar, isMobile } = useSidebar();
|
const { toggleSidebar, isMobile } = useSidebar();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
|
|
||||||
|
|
@ -45,6 +45,14 @@ export function BreadcrumbBar() {
|
||||||
|
|
||||||
const globalToolbarSlots = <GlobalToolbarPlugins context={globalToolbarSlotContext} />;
|
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) {
|
if (breadcrumbs.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
|
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,52 @@ describe("CommentThread", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides the reopen control and infers reopen for closed agent-assigned issues", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const onAdd = vi.fn(async () => {});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CommentThread
|
||||||
|
comments={[]}
|
||||||
|
issueStatus="done"
|
||||||
|
currentAssigneeValue="agent:agent-1"
|
||||||
|
onAdd={onAdd}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).not.toContain("Re-open");
|
||||||
|
|
||||||
|
const editor = container.querySelector('textarea[aria-label="Comment editor"]') as HTMLTextAreaElement | null;
|
||||||
|
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(element) => element.textContent === "Comment",
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
expect(editor).not.toBeNull();
|
||||||
|
expect(submitButton).toBeDefined();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLTextAreaElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
valueSetter?.call(editor, "Please pick this back up");
|
||||||
|
editor?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
submitButton?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onAdd).toHaveBeenCalledWith("Please pick this back up", true, undefined);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders linked approvals inline in the timeline", () => {
|
it("renders linked approvals inline in the timeline", () => {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
const agent: Agent = {
|
const agent: Agent = {
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,11 @@ function parseReassignment(target: string): CommentReassignment | null {
|
||||||
return 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 {
|
function humanizeValue(value: string | null): string {
|
||||||
if (!value) return "None";
|
if (!value) return "None";
|
||||||
return value.replace(/_/g, " ");
|
return value.replace(/_/g, " ");
|
||||||
|
|
@ -647,6 +652,7 @@ export function CommentThread({
|
||||||
pendingApprovalAction = null,
|
pendingApprovalAction = null,
|
||||||
onVote,
|
onVote,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
issueStatus,
|
||||||
agentMap,
|
agentMap,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
imageUploadHandler,
|
imageUploadHandler,
|
||||||
|
|
@ -663,7 +669,6 @@ export function CommentThread({
|
||||||
composerDisabledReason = null,
|
composerDisabledReason = null,
|
||||||
}: CommentThreadProps) {
|
}: CommentThreadProps) {
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [reopen, setReopen] = useState(true);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [attaching, setAttaching] = useState(false);
|
const [attaching, setAttaching] = useState(false);
|
||||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||||
|
|
@ -784,14 +789,17 @@ export function CommentThread({
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
|
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
|
||||||
|
const reopen = shouldImplicitlyReopenComment(
|
||||||
|
issueStatus,
|
||||||
|
hasReassignment ? reassignTarget : currentAssigneeValue,
|
||||||
|
) ? true : undefined;
|
||||||
const submittedBody = trimmed;
|
const submittedBody = trimmed;
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setBody("");
|
setBody("");
|
||||||
try {
|
try {
|
||||||
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
|
await onAdd(submittedBody, reopen, reassignment ?? undefined);
|
||||||
if (draftKey) clearDraft(draftKey);
|
if (draftKey) clearDraft(draftKey);
|
||||||
setReopen(true);
|
|
||||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||||
} catch {
|
} catch {
|
||||||
setBody((current) =>
|
setBody((current) =>
|
||||||
|
|
@ -935,15 +943,6 @@ export function CommentThread({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 && (
|
{enableReassign && reassignOptions.length > 0 && (
|
||||||
<InlineEntitySelector
|
<InlineEntitySelector
|
||||||
value={reassignTarget}
|
value={reassignTarget}
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,30 @@ import type { ReactNode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
import { IssueChatThread, canStopIssueChatRun, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
||||||
|
|
||||||
const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||||
markdownEditorFocusMock: vi.fn(),
|
markdownEditorFocusMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { appendMock } = vi.hoisted(() => ({
|
||||||
|
appendMock: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
const { threadMessagesMock } = vi.hoisted(() => ({
|
const { threadMessagesMock } = vi.hoisted(() => ({
|
||||||
threadMessagesMock: vi.fn(() => <div data-testid="thread-messages" />),
|
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", () => ({
|
vi.mock("@assistant-ui/react", () => ({
|
||||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
ThreadPrimitive: {
|
ThreadPrimitive: {
|
||||||
|
|
@ -32,7 +46,7 @@ vi.mock("@assistant-ui/react", () => ({
|
||||||
Content: () => null,
|
Content: () => null,
|
||||||
Parts: () => null,
|
Parts: () => null,
|
||||||
},
|
},
|
||||||
useAui: () => ({ thread: () => ({ append: vi.fn() }) }),
|
useAui: () => ({ thread: () => ({ append: appendMock }) }),
|
||||||
useAuiState: () => false,
|
useAuiState: () => false,
|
||||||
useMessage: () => ({
|
useMessage: () => ({
|
||||||
id: "message",
|
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", () => ({
|
vi.mock("./MarkdownBody", () => ({
|
||||||
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
}));
|
}));
|
||||||
|
|
@ -126,8 +150,12 @@ describe("IssueChatThread", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
container.remove();
|
container.remove();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
appendMock.mockReset();
|
||||||
markdownEditorFocusMock.mockReset();
|
markdownEditorFocusMock.mockReset();
|
||||||
threadMessagesMock.mockReset();
|
threadMessagesMock.mockReset();
|
||||||
|
captureComposerViewportSnapshotMock.mockClear();
|
||||||
|
restoreComposerViewportSnapshotMock.mockClear();
|
||||||
|
shouldPreserveComposerViewportMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops the count heading and does not use an internal scrollbox", () => {
|
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", () => {
|
it("exposes a composer focus handle that forwards to the editor", () => {
|
||||||
const root = createRoot(container);
|
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 scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||||
const requestAnimationFrameMock = vi
|
const requestAnimationFrameMock = vi
|
||||||
.spyOn(window, "requestAnimationFrame")
|
.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", () => {
|
it("folds chain-of-thought when the same message transitions from running to complete", () => {
|
||||||
expect(resolveAssistantMessageFoldedState({
|
expect(resolveAssistantMessageFoldedState({
|
||||||
messageId: "message-1",
|
messageId: "message-1",
|
||||||
|
|
@ -406,4 +645,20 @@ describe("IssueChatThread", () => {
|
||||||
previousIsFoldable: true,
|
previousIsFoldable: true,
|
||||||
})).toBe(false);
|
})).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the stop-run action for active run-linked messages even without embedded run status", () => {
|
||||||
|
expect(canStopIssueChatRun({
|
||||||
|
runId: "run-1",
|
||||||
|
runStatus: null,
|
||||||
|
activeRunIds: new Set(["run-1"]),
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the stop-run action for completed historical runs", () => {
|
||||||
|
expect(canStopIssueChatRun({
|
||||||
|
runId: "run-1",
|
||||||
|
runStatus: "cancelled",
|
||||||
|
activeRunIds: new Set<string>(),
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
|
@ -36,8 +37,10 @@ import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from
|
||||||
import {
|
import {
|
||||||
buildIssueChatMessages,
|
buildIssueChatMessages,
|
||||||
formatDurationWords,
|
formatDurationWords,
|
||||||
|
stabilizeThreadMessages,
|
||||||
type IssueChatComment,
|
type IssueChatComment,
|
||||||
type IssueChatLinkedRun,
|
type IssueChatLinkedRun,
|
||||||
|
type StableThreadMessageCacheEntry,
|
||||||
type IssueChatTranscriptEntry,
|
type IssueChatTranscriptEntry,
|
||||||
type SegmentTiming,
|
type SegmentTiming,
|
||||||
} from "../lib/issue-chat-messages";
|
} from "../lib/issue-chat-messages";
|
||||||
|
|
@ -65,6 +68,11 @@ import { Identity } from "./Identity";
|
||||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
|
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
|
||||||
|
import {
|
||||||
|
captureComposerViewportSnapshot,
|
||||||
|
restoreComposerViewportSnapshot,
|
||||||
|
shouldPreserveComposerViewport,
|
||||||
|
} from "../lib/issue-chat-scroll";
|
||||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import {
|
import {
|
||||||
|
|
@ -80,7 +88,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 {
|
interface IssueChatMessageContext {
|
||||||
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
||||||
|
|
@ -88,12 +96,16 @@ interface IssueChatMessageContext {
|
||||||
feedbackTermsUrl: string | null;
|
feedbackTermsUrl: string | null;
|
||||||
agentMap?: Map<string, Agent>;
|
agentMap?: Map<string, Agent>;
|
||||||
currentUserId?: string | null;
|
currentUserId?: string | null;
|
||||||
|
activeRunIds: ReadonlySet<string>;
|
||||||
onVote?: (
|
onVote?: (
|
||||||
commentId: string,
|
commentId: string,
|
||||||
vote: FeedbackVoteValue,
|
vote: FeedbackVoteValue,
|
||||||
options?: { allowSharing?: boolean; reason?: string },
|
options?: { allowSharing?: boolean; reason?: string },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
onStopRun?: (runId: string) => Promise<void>;
|
||||||
|
stoppingRunId?: string | null;
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
|
onCancelQueued?: (commentId: string) => void;
|
||||||
interruptingQueuedRunId?: string | null;
|
interruptingQueuedRunId?: string | null;
|
||||||
onImageClick?: (src: string) => void;
|
onImageClick?: (src: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +114,7 @@ const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||||
feedbackVoteByTargetId: new Map(),
|
feedbackVoteByTargetId: new Map(),
|
||||||
feedbackDataSharingPreference: "prompt",
|
feedbackDataSharingPreference: "prompt",
|
||||||
feedbackTermsUrl: null,
|
feedbackTermsUrl: null,
|
||||||
|
activeRunIds: new Set<string>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function resolveAssistantMessageFoldedState(args: {
|
export function resolveAssistantMessageFoldedState(args: {
|
||||||
|
|
@ -125,6 +138,17 @@ export function resolveAssistantMessageFoldedState(args: {
|
||||||
return currentFolded;
|
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(
|
function findCoTSegmentIndex(
|
||||||
messageParts: ReadonlyArray<{ type: string }>,
|
messageParts: ReadonlyArray<{ type: string }>,
|
||||||
cotParts: ReadonlyArray<{ type: string }>,
|
cotParts: ReadonlyArray<{ type: string }>,
|
||||||
|
|
@ -162,6 +186,7 @@ interface CommentReassignment {
|
||||||
|
|
||||||
export interface IssueChatComposerHandle {
|
export interface IssueChatComposerHandle {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
|
restoreDraft: (submittedBody: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IssueChatComposerProps {
|
interface IssueChatComposerProps {
|
||||||
|
|
@ -199,6 +224,7 @@ interface IssueChatThreadProps {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||||
onCancelRun?: () => Promise<void>;
|
onCancelRun?: () => Promise<void>;
|
||||||
|
onStopRun?: (runId: string) => Promise<void>;
|
||||||
imageUploadHandler?: (file: File) => Promise<string>;
|
imageUploadHandler?: (file: File) => Promise<string>;
|
||||||
onAttachImage?: (file: File) => Promise<void>;
|
onAttachImage?: (file: File) => Promise<void>;
|
||||||
draftKey?: string;
|
draftKey?: string;
|
||||||
|
|
@ -217,7 +243,9 @@ interface IssueChatThreadProps {
|
||||||
hasOutputForRun?: (runId: string) => boolean;
|
hasOutputForRun?: (runId: string) => boolean;
|
||||||
includeSucceededRunsWithoutOutput?: boolean;
|
includeSucceededRunsWithoutOutput?: boolean;
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
|
onCancelQueued?: (commentId: string) => void;
|
||||||
interruptingQueuedRunId?: string | null;
|
interruptingQueuedRunId?: string | null;
|
||||||
|
stoppingRunId?: string | null;
|
||||||
onImageClick?: (src: string) => void;
|
onImageClick?: (src: string) => void;
|
||||||
composerRef?: Ref<IssueChatComposerHandle>;
|
composerRef?: Ref<IssueChatComposerHandle>;
|
||||||
}
|
}
|
||||||
|
|
@ -412,6 +440,11 @@ function parseReassignment(target: string): PaperclipIssueRuntimeReassignment |
|
||||||
return null;
|
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;
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function commentDateLabel(date: Date | string | undefined): string {
|
function commentDateLabel(date: Date | string | undefined): string {
|
||||||
|
|
@ -873,10 +906,11 @@ function IssueChatToolPart({
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueChatUserMessage() {
|
function IssueChatUserMessage() {
|
||||||
const { onInterruptQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
|
const { onInterruptQueued, onCancelQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const custom = message.metadata.custom as Record<string, unknown>;
|
const custom = message.metadata.custom as Record<string, unknown>;
|
||||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
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 queued = custom.queueState === "queued" || custom.clientStatus === "queued";
|
||||||
const pending = custom.clientStatus === "pending";
|
const pending = custom.clientStatus === "pending";
|
||||||
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
|
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
|
||||||
|
|
@ -911,6 +945,16 @@ function IssueChatUserMessage() {
|
||||||
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -976,6 +1020,9 @@ function IssueChatAssistantMessage() {
|
||||||
feedbackTermsUrl,
|
feedbackTermsUrl,
|
||||||
onVote,
|
onVote,
|
||||||
agentMap,
|
agentMap,
|
||||||
|
activeRunIds,
|
||||||
|
onStopRun,
|
||||||
|
stoppingRunId,
|
||||||
} = useContext(IssueChatCtx);
|
} = useContext(IssueChatCtx);
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const custom = message.metadata.custom as Record<string, unknown>;
|
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 authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null;
|
||||||
const runId = typeof custom.runId === "string" ? custom.runId : null;
|
const runId = typeof custom.runId === "string" ? custom.runId : null;
|
||||||
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
||||||
|
const runStatus = typeof custom.runStatus === "string" ? custom.runStatus : null;
|
||||||
const agentId = authorAgentId ?? runAgentId;
|
const agentId = authorAgentId ?? runAgentId;
|
||||||
const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined;
|
const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined;
|
||||||
const commentId = typeof custom.commentId === "string" ? custom.commentId : null;
|
const commentId = typeof custom.commentId === "string" ? custom.commentId : null;
|
||||||
|
|
@ -997,6 +1045,7 @@ function IssueChatAssistantMessage() {
|
||||||
const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : "";
|
const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : "";
|
||||||
const isRunning = message.role === "assistant" && message.status?.type === "running";
|
const isRunning = message.role === "assistant" && message.status?.type === "running";
|
||||||
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
|
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 chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
|
||||||
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
|
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
|
||||||
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
||||||
|
|
@ -1162,6 +1211,18 @@ function IssueChatAssistantMessage() {
|
||||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||||
Copy message
|
Copy message
|
||||||
</DropdownMenuItem>
|
</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 ? (
|
{runHref ? (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={runHref} target="_blank" rel="noreferrer noopener">
|
<Link to={runHref} target="_blank" rel="noreferrer noopener">
|
||||||
|
|
@ -1557,7 +1618,6 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
}, forwardedRef) {
|
}, forwardedRef) {
|
||||||
const api = useAui();
|
const api = useAui();
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [attaching, setAttaching] = useState(false);
|
const [attaching, setAttaching] = useState(false);
|
||||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||||
|
|
@ -1567,6 +1627,23 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | 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(() => {
|
useEffect(() => {
|
||||||
if (!draftKey) return;
|
if (!draftKey) return;
|
||||||
setBody(loadDraft(draftKey));
|
setBody(loadDraft(draftKey));
|
||||||
|
|
@ -1591,12 +1668,15 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
}, [effectiveSuggestedAssigneeValue]);
|
}, [effectiveSuggestedAssigneeValue]);
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
focus: () => {
|
focus: focusComposer,
|
||||||
composerContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
restoreDraft: (submittedBody: string) => {
|
||||||
requestAnimationFrame(() => {
|
setBody((current) =>
|
||||||
window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" });
|
restoreSubmittedCommentDraft({
|
||||||
editorRef.current?.focus();
|
currentBody: current,
|
||||||
});
|
submittedBody,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
focusComposer();
|
||||||
},
|
},
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
|
|
@ -1606,12 +1686,17 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
|
|
||||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : undefined;
|
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : undefined;
|
||||||
|
const reopen = shouldImplicitlyReopenComment(
|
||||||
|
issueStatus,
|
||||||
|
hasReassignment ? reassignTarget : currentAssigneeValue,
|
||||||
|
) ? true : undefined;
|
||||||
const submittedBody = trimmed;
|
const submittedBody = trimmed;
|
||||||
|
const viewportSnapshot = captureComposerViewportSnapshot(composerContainerRef.current);
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setBody("");
|
setBody("");
|
||||||
try {
|
try {
|
||||||
await api.thread().append({
|
const appendPromise = api.thread().append({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: submittedBody }],
|
content: [{ type: "text", text: submittedBody }],
|
||||||
metadata: { custom: {} },
|
metadata: { custom: {} },
|
||||||
|
|
@ -1623,8 +1708,9 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
queueViewportRestore(viewportSnapshot);
|
||||||
|
await appendPromise;
|
||||||
if (draftKey) clearDraft(draftKey);
|
if (draftKey) clearDraft(draftKey);
|
||||||
setReopen(issueStatus === "done" || issueStatus === "cancelled");
|
|
||||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||||
} catch {
|
} catch {
|
||||||
setBody((current) =>
|
setBody((current) =>
|
||||||
|
|
@ -1635,6 +1721,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
queueViewportRestore(viewportSnapshot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1707,16 +1794,6 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{enableReassign && reassignOptions.length > 0 ? (
|
||||||
<InlineEntitySelector
|
<InlineEntitySelector
|
||||||
value={reassignTarget}
|
value={reassignTarget}
|
||||||
|
|
@ -1781,6 +1858,7 @@ export function IssueChatThread({
|
||||||
onVote,
|
onVote,
|
||||||
onAdd,
|
onAdd,
|
||||||
onCancelRun,
|
onCancelRun,
|
||||||
|
onStopRun,
|
||||||
imageUploadHandler,
|
imageUploadHandler,
|
||||||
onAttachImage,
|
onAttachImage,
|
||||||
draftKey,
|
draftKey,
|
||||||
|
|
@ -1799,13 +1877,18 @@ export function IssueChatThread({
|
||||||
hasOutputForRun: hasOutputForRunOverride,
|
hasOutputForRun: hasOutputForRunOverride,
|
||||||
includeSucceededRunsWithoutOutput = false,
|
includeSucceededRunsWithoutOutput = false,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
|
onCancelQueued,
|
||||||
interruptingQueuedRunId = null,
|
interruptingQueuedRunId = null,
|
||||||
|
stoppingRunId = null,
|
||||||
onImageClick,
|
onImageClick,
|
||||||
composerRef,
|
composerRef,
|
||||||
}: IssueChatThreadProps) {
|
}: IssueChatThreadProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const hasScrolledRef = useRef(false);
|
const hasScrolledRef = useRef(false);
|
||||||
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
|
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 displayLiveRuns = useMemo(() => {
|
||||||
const deduped = new Map<string, LiveRunForIssue>();
|
const deduped = new Map<string, LiveRunForIssue>();
|
||||||
for (const run of liveRuns) {
|
for (const run of liveRuns) {
|
||||||
|
|
@ -1834,14 +1917,22 @@ export function IssueChatThread({
|
||||||
activeRun,
|
activeRun,
|
||||||
});
|
});
|
||||||
}, [activeRun, displayLiveRuns, linkedRuns]);
|
}, [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({
|
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||||
runs: enableLiveTranscriptPolling ? transcriptRuns : [],
|
runs: enableLiveTranscriptPolling ? transcriptRuns : [],
|
||||||
companyId,
|
companyId,
|
||||||
});
|
});
|
||||||
const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun;
|
const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun;
|
||||||
const resolvedHasOutputForRun = hasOutputForRunOverride ?? hasOutputForRun;
|
const resolvedHasOutputForRun = hasOutputForRunOverride ?? hasOutputForRun;
|
||||||
|
const rawMessages = useMemo(
|
||||||
const messages = useMemo(
|
|
||||||
() =>
|
() =>
|
||||||
buildIssueChatMessages({
|
buildIssueChatMessages({
|
||||||
comments,
|
comments,
|
||||||
|
|
@ -1872,6 +1963,18 @@ export function IssueChatThread({
|
||||||
currentUserId,
|
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 isRunning = displayLiveRuns.some((run) => run.status === "queued" || run.status === "running");
|
||||||
const feedbackVoteByTargetId = useMemo(() => {
|
const feedbackVoteByTargetId = useMemo(() => {
|
||||||
|
|
@ -1890,6 +1993,19 @@ export function IssueChatThread({
|
||||||
onCancel: onCancelRun,
|
onCancel: onCancelRun,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const composerElement = composerViewportAnchorRef.current;
|
||||||
|
if (preserveComposerViewportRef.current) {
|
||||||
|
restoreComposerViewportSnapshot(
|
||||||
|
composerViewportSnapshotRef.current,
|
||||||
|
composerElement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
composerViewportSnapshotRef.current = captureComposerViewportSnapshot(composerElement);
|
||||||
|
preserveComposerViewportRef.current = shouldPreserveComposerViewport(composerElement);
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = location.hash;
|
const hash = location.hash;
|
||||||
if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return;
|
if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return;
|
||||||
|
|
@ -1912,8 +2028,12 @@ export function IssueChatThread({
|
||||||
feedbackTermsUrl,
|
feedbackTermsUrl,
|
||||||
agentMap,
|
agentMap,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
activeRunIds,
|
||||||
onVote,
|
onVote,
|
||||||
|
onStopRun,
|
||||||
|
stoppingRunId,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
|
onCancelQueued,
|
||||||
interruptingQueuedRunId,
|
interruptingQueuedRunId,
|
||||||
onImageClick,
|
onImageClick,
|
||||||
}),
|
}),
|
||||||
|
|
@ -1923,8 +2043,12 @@ export function IssueChatThread({
|
||||||
feedbackTermsUrl,
|
feedbackTermsUrl,
|
||||||
agentMap,
|
agentMap,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
activeRunIds,
|
||||||
onVote,
|
onVote,
|
||||||
|
onStopRun,
|
||||||
|
stoppingRunId,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
|
onCancelQueued,
|
||||||
interruptingQueuedRunId,
|
interruptingQueuedRunId,
|
||||||
onImageClick,
|
onImageClick,
|
||||||
],
|
],
|
||||||
|
|
@ -1990,6 +2114,7 @@ export function IssueChatThread({
|
||||||
</IssueChatErrorBoundary>
|
</IssueChatErrorBoundary>
|
||||||
|
|
||||||
{showComposer ? (
|
{showComposer ? (
|
||||||
|
<div ref={composerViewportAnchorRef}>
|
||||||
<IssueChatComposer
|
<IssueChatComposer
|
||||||
ref={composerRef}
|
ref={composerRef}
|
||||||
onImageUpload={imageUploadHandler}
|
onImageUpload={imageUploadHandler}
|
||||||
|
|
@ -2004,6 +2129,7 @@ export function IssueChatThread({
|
||||||
composerDisabledReason={composerDisabledReason}
|
composerDisabledReason={composerDisabledReason}
|
||||||
issueStatus={issueStatus}
|
issueStatus={issueStatus}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</IssueChatCtx.Provider>
|
</IssueChatCtx.Provider>
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,79 @@ describe("IssuesList", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps server-side search scoped to the provided parent issue filters", async () => {
|
||||||
|
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
|
||||||
|
const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" });
|
||||||
|
|
||||||
|
mockIssuesApi.list.mockResolvedValue([serverIssue]);
|
||||||
|
|
||||||
|
const { root } = renderWithQueryClient(
|
||||||
|
<IssuesList
|
||||||
|
issues={[localIssue]}
|
||||||
|
agents={[]}
|
||||||
|
projects={[]}
|
||||||
|
viewStateKey="paperclip:test-issues"
|
||||||
|
initialSearch="server"
|
||||||
|
searchFilters={{ parentId: "parent-1" }}
|
||||||
|
onUpdateIssue={() => undefined}
|
||||||
|
/>,
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForAssertion(() => {
|
||||||
|
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
|
||||||
|
q: "server",
|
||||||
|
projectId: undefined,
|
||||||
|
parentId: "parent-1",
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain("Server result");
|
||||||
|
expect(container.textContent).not.toContain("Local issue");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the supplied create defaults and label for sub-issue lists", async () => {
|
||||||
|
const { root } = renderWithQueryClient(
|
||||||
|
<IssuesList
|
||||||
|
issues={[createIssue()]}
|
||||||
|
agents={[]}
|
||||||
|
projects={[]}
|
||||||
|
viewStateKey="paperclip:test-issues"
|
||||||
|
baseCreateIssueDefaults={{ parentId: "parent-1", projectId: "project-1" }}
|
||||||
|
createIssueLabel="Sub-issue"
|
||||||
|
onUpdateIssue={() => undefined}
|
||||||
|
/>,
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForAssertion(() => {
|
||||||
|
const button = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(candidate) => candidate.textContent?.includes("New Sub-issue"),
|
||||||
|
);
|
||||||
|
expect(button).not.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const button = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(candidate) => candidate.textContent?.includes("New Sub-issue"),
|
||||||
|
);
|
||||||
|
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dialogState.openNewIssue).toHaveBeenCalledWith({
|
||||||
|
parentId: "parent-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("debounces search updates so typing does not notify the page on every keystroke", async () => {
|
it("debounces search updates so typing does not notify the page on every keystroke", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,10 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
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 { KanbanBoard } from "./KanbanBoard";
|
||||||
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||||
|
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
|
||||||
import type { Issue, Project } from "@paperclipai/shared";
|
import type { Issue, Project } from "@paperclipai/shared";
|
||||||
const ISSUE_SEARCH_DEBOUNCE_MS = 150;
|
const ISSUE_SEARCH_DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
|
|
@ -63,6 +64,7 @@ export type IssueViewState = IssueFilterState & {
|
||||||
sortDir: "asc" | "desc";
|
sortDir: "asc" | "desc";
|
||||||
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
|
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
|
||||||
viewMode: "list" | "board";
|
viewMode: "list" | "board";
|
||||||
|
nestingEnabled: boolean;
|
||||||
collapsedGroups: string[];
|
collapsedGroups: string[];
|
||||||
collapsedParents: string[];
|
collapsedParents: string[];
|
||||||
};
|
};
|
||||||
|
|
@ -73,6 +75,7 @@ const defaultViewState: IssueViewState = {
|
||||||
sortDir: "desc",
|
sortDir: "desc",
|
||||||
groupBy: "none",
|
groupBy: "none",
|
||||||
viewMode: "list",
|
viewMode: "list",
|
||||||
|
nestingEnabled: true,
|
||||||
collapsedGroups: [],
|
collapsedGroups: [],
|
||||||
collapsedParents: [],
|
collapsedParents: [],
|
||||||
};
|
};
|
||||||
|
|
@ -118,6 +121,7 @@ interface Agent {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectOption = Pick<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
|
type ProjectOption = Pick<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
|
||||||
|
type IssueListRequestFilters = NonNullable<Parameters<typeof issuesApi.list>[1]>;
|
||||||
|
|
||||||
interface IssuesListProps {
|
interface IssuesListProps {
|
||||||
issues: Issue[];
|
issues: Issue[];
|
||||||
|
|
@ -131,9 +135,9 @@ interface IssuesListProps {
|
||||||
issueLinkState?: unknown;
|
issueLinkState?: unknown;
|
||||||
initialAssignees?: string[];
|
initialAssignees?: string[];
|
||||||
initialSearch?: string;
|
initialSearch?: string;
|
||||||
searchFilters?: {
|
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
|
||||||
participantAgentId?: string;
|
baseCreateIssueDefaults?: Record<string, unknown>;
|
||||||
};
|
createIssueLabel?: string;
|
||||||
enableRoutineVisibilityFilter?: boolean;
|
enableRoutineVisibilityFilter?: boolean;
|
||||||
onSearchChange?: (search: string) => void;
|
onSearchChange?: (search: string) => void;
|
||||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||||
|
|
@ -214,6 +218,8 @@ export function IssuesList({
|
||||||
initialAssignees,
|
initialAssignees,
|
||||||
initialSearch,
|
initialSearch,
|
||||||
searchFilters,
|
searchFilters,
|
||||||
|
baseCreateIssueDefaults,
|
||||||
|
createIssueLabel,
|
||||||
enableRoutineVisibilityFilter = false,
|
enableRoutineVisibilityFilter = false,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onUpdateIssue,
|
onUpdateIssue,
|
||||||
|
|
@ -484,8 +490,8 @@ export function IssuesList({
|
||||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
|
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
|
||||||
|
|
||||||
const newIssueDefaults = useCallback((groupKey?: string) => {
|
const newIssueDefaults = useCallback((groupKey?: string) => {
|
||||||
const defaults: Record<string, string> = {};
|
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
|
||||||
if (projectId) defaults.projectId = projectId;
|
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
|
||||||
if (groupKey) {
|
if (groupKey) {
|
||||||
if (viewState.groupBy === "status") defaults.status = groupKey;
|
if (viewState.groupBy === "status") defaults.status = groupKey;
|
||||||
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
|
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
|
||||||
|
|
@ -494,11 +500,19 @@ export function IssuesList({
|
||||||
else defaults.assigneeAgentId = groupKey;
|
else defaults.assigneeAgentId = groupKey;
|
||||||
}
|
}
|
||||||
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
|
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;
|
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) => {
|
const filterToWorkspace = useCallback((workspaceId: string) => {
|
||||||
updateView({ workspaces: [workspaceId] });
|
updateView({ workspaces: [workspaceId] });
|
||||||
|
|
@ -530,9 +544,9 @@ export function IssuesList({
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between gap-2 sm:gap-3">
|
<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">
|
<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" />
|
<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>
|
</Button>
|
||||||
<IssueSearchInput
|
<IssueSearchInput
|
||||||
value={issueSearch}
|
value={issueSearch}
|
||||||
|
|
@ -562,6 +576,19 @@ export function IssuesList({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<IssueColumnPicker
|
||||||
availableColumns={availableIssueColumns}
|
availableColumns={availableIssueColumns}
|
||||||
visibleColumnSet={visibleIssueColumnSet}
|
visibleColumnSet={visibleIssueColumnSet}
|
||||||
|
|
@ -670,8 +697,8 @@ export function IssuesList({
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={CircleDot}
|
icon={CircleDot}
|
||||||
message="No issues match the current filters or search."
|
message="No issues match the current filters or search."
|
||||||
action="Create Issue"
|
action={createActionLabel}
|
||||||
onAction={() => openNewIssue(newIssueDefaults())}
|
onAction={() => openCreateIssueDialog()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -707,7 +734,7 @@ export function IssuesList({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
className="ml-auto text-muted-foreground"
|
className="ml-auto text-muted-foreground"
|
||||||
onClick={() => openNewIssue(newIssueDefaults(group.key))}
|
onClick={() => openCreateIssueDialog(group.key)}
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -715,7 +742,9 @@ export function IssuesList({
|
||||||
)}
|
)}
|
||||||
<CollapsibleContent>
|
<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 renderIssueRow = (issue: Issue, depth: number) => {
|
||||||
const children = childMap.get(issue.id) ?? [];
|
const children = childMap.get(issue.id) ?? [];
|
||||||
|
|
@ -817,15 +846,15 @@ export function IssuesList({
|
||||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||||
) : issue.assigneeUserId ? (
|
) : issue.assigneeUserId ? (
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
<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">
|
<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 w-3" />
|
<User className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
<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">
|
<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 w-3" />
|
<User className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
Assignee
|
Assignee
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,20 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<");
|
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", () => {
|
it("linkifies issue identifiers inside inline code spans", () => {
|
||||||
const html = renderMarkdown("Reference `PAP-1271` here.", [
|
const html = renderMarkdown("Reference `PAP-1271` here.", [
|
||||||
{ identifier: "PAP-1271", status: "done" },
|
{ identifier: "PAP-1271", status: "done" },
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { buildSkillMentionHref } from "@paperclipai/shared";
|
import { buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
computeMentionMenuPosition,
|
computeMentionMenuPosition,
|
||||||
findClosestAutocompleteAnchor,
|
findClosestAutocompleteAnchor,
|
||||||
|
|
@ -16,6 +16,9 @@ import {
|
||||||
|
|
||||||
const mdxEditorMockState = vi.hoisted(() => ({
|
const mdxEditorMockState = vi.hoisted(() => ({
|
||||||
emitMountEmptyReset: false,
|
emitMountEmptyReset: false,
|
||||||
|
emitMountParseError: false,
|
||||||
|
emitMountSilentEmptyState: false,
|
||||||
|
markdownValues: [] as string[],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@mdxeditor/editor", async () => {
|
vi.mock("@mdxeditor/editor", async () => {
|
||||||
|
|
@ -36,19 +39,29 @@ vi.mock("@mdxeditor/editor", async () => {
|
||||||
markdown,
|
markdown,
|
||||||
placeholder,
|
placeholder,
|
||||||
onChange,
|
onChange,
|
||||||
|
onError,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
markdown: string;
|
markdown: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
onError?: (error: unknown) => void;
|
||||||
|
className?: string;
|
||||||
},
|
},
|
||||||
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
|
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
|
||||||
) {
|
) {
|
||||||
|
mdxEditorMockState.markdownValues.push(markdown);
|
||||||
const [content, setContent] = React.useState(markdown);
|
const [content, setContent] = React.useState(markdown);
|
||||||
|
const editableRef = React.useRef<HTMLDivElement>(null);
|
||||||
const handle = React.useMemo(() => ({
|
const handle = React.useMemo(() => ({
|
||||||
setMarkdown: (value: string) => setContent(value),
|
setMarkdown: (value: string) => setContent(value),
|
||||||
focus: () => {},
|
focus: () => editableRef.current?.focus(),
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setContent(markdown);
|
||||||
|
}, [markdown]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setForwardedRef(forwardedRef, null);
|
setForwardedRef(forwardedRef, null);
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
|
|
@ -57,6 +70,16 @@ vi.mock("@mdxeditor/editor", async () => {
|
||||||
setContent("");
|
setContent("");
|
||||||
onChange?.("");
|
onChange?.("");
|
||||||
}
|
}
|
||||||
|
if (mdxEditorMockState.emitMountSilentEmptyState) {
|
||||||
|
setContent("");
|
||||||
|
}
|
||||||
|
if (mdxEditorMockState.emitMountParseError) {
|
||||||
|
setContent("");
|
||||||
|
onError?.({
|
||||||
|
error: "Unsupported markdown syntax",
|
||||||
|
source: markdown,
|
||||||
|
});
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timer);
|
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 {
|
return {
|
||||||
|
|
@ -105,16 +138,33 @@ async function flush() {
|
||||||
|
|
||||||
describe("MarkdownEditor", () => {
|
describe("MarkdownEditor", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
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(() => {
|
afterEach(() => {
|
||||||
container.remove();
|
container.remove();
|
||||||
|
Range.prototype.getBoundingClientRect = originalRangeRect;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mdxEditorMockState.emitMountEmptyReset = false;
|
mdxEditorMockState.emitMountEmptyReset = false;
|
||||||
|
mdxEditorMockState.emitMountParseError = false;
|
||||||
|
mdxEditorMockState.emitMountSilentEmptyState = false;
|
||||||
|
mdxEditorMockState.markdownValues = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies async external value updates once the editor ref becomes ready", async () => {
|
it("applies async external value updates once the editor ref becomes ready", async () => {
|
||||||
|
|
@ -172,6 +222,94 @@ describe("MarkdownEditor", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("converts advisory-style html image tags to markdown image syntax before mounting the editor", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MarkdownEditor
|
||||||
|
value={`Before\n\n<img width="10" height="10" alt="image" src="https://example.com/test.png" />\n\nAfter`}
|
||||||
|
onChange={() => {}}
|
||||||
|
placeholder="Markdown body"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
expect(mdxEditorMockState.markdownValues.at(-1)).toContain("");
|
||||||
|
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
|
||||||
|
expect(container.textContent).toContain("Before");
|
||||||
|
expect(container.textContent).toContain("After");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a raw textarea when the rich parser rejects the markdown", async () => {
|
||||||
|
mdxEditorMockState.emitMountParseError = true;
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MarkdownEditor
|
||||||
|
value="Affected versions: <= v0.3.1"
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Markdown body"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(container.querySelector("textarea")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
const textarea = container.querySelector("textarea");
|
||||||
|
expect(textarea).not.toBeNull();
|
||||||
|
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
|
||||||
|
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
|
||||||
|
expect(handleChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a raw textarea when the rich editor mounts into the placeholder without callbacks", async () => {
|
||||||
|
mdxEditorMockState.emitMountSilentEmptyState = true;
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MarkdownEditor
|
||||||
|
value="Affected versions: <= v0.3.1"
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(container.querySelector("textarea")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
const textarea = container.querySelector("textarea");
|
||||||
|
expect(textarea).not.toBeNull();
|
||||||
|
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
|
||||||
|
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
|
||||||
|
expect(handleChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
|
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
|
||||||
expect(
|
expect(
|
||||||
computeMentionMenuPosition(
|
computeMentionMenuPosition(
|
||||||
|
|
@ -312,4 +450,64 @@ describe("MarkdownEditor", () => {
|
||||||
|
|
||||||
editable.remove();
|
editable.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts mention selection from touchstart taps", async () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MarkdownEditor
|
||||||
|
value="@Pap"
|
||||||
|
onChange={handleChange}
|
||||||
|
mentions={[
|
||||||
|
{
|
||||||
|
id: "project:project-123",
|
||||||
|
kind: "project",
|
||||||
|
name: "Paperclip App",
|
||||||
|
projectId: "project-123",
|
||||||
|
projectColor: "#336699",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const editable = container.querySelector('[contenteditable="true"]');
|
||||||
|
expect(editable).not.toBeNull();
|
||||||
|
|
||||||
|
const textNode = editable?.firstChild;
|
||||||
|
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(textNode!, "@Pap".length);
|
||||||
|
range.collapse(true);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event("selectionchange"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const option = Array.from(document.body.querySelectorAll('button[type="button"]'))
|
||||||
|
.find((node) => node.textContent?.includes("Paperclip App"));
|
||||||
|
expect(option).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
option?.dispatchEvent(new Event("touchstart", { bubbles: true, cancelable: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalledWith(
|
||||||
|
`[@Paperclip App](${buildProjectMentionHref("project-123", "#336699")}) `,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
|
type MouseEvent as ReactMouseEvent,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
type TouchEvent as ReactTouchEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
|
|
@ -75,10 +78,76 @@ export interface MarkdownEditorRef {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readHtmlAttribute(attrs: string, name: string): string | null {
|
||||||
|
const match = new RegExp(`${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attrs);
|
||||||
|
return match?.[2] ?? match?.[3] ?? match?.[4] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertHtmlImagesToMarkdown(text: string): string {
|
||||||
|
return text.replace(/<img\b([^>]*?)\/?>/gi, (tag, attrs: string) => {
|
||||||
|
const src = readHtmlAttribute(attrs, "src");
|
||||||
|
if (!src) return tag;
|
||||||
|
const alt = readHtmlAttribute(attrs, "alt") ?? "image";
|
||||||
|
const title = readHtmlAttribute(attrs, "title");
|
||||||
|
const escapedAlt = alt.replace(/[[\]]/g, "\\$&");
|
||||||
|
const escapedTitle = title?.replace(/"/g, '\\"');
|
||||||
|
return escapedTitle
|
||||||
|
? ``
|
||||||
|
: ``;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareMarkdownForEditor(value: string): string {
|
||||||
|
const normalizedLineEndings = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
return convertHtmlImagesToMarkdown(normalizedLineEndings);
|
||||||
|
}
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
function escapeRegExp(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
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 {
|
function isSafeMarkdownLinkUrl(url: string): boolean {
|
||||||
const trimmed = url.trim();
|
const trimmed = url.trim();
|
||||||
if (!trimmed) return true;
|
if (!trimmed) return true;
|
||||||
|
|
@ -417,12 +486,14 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
mentions,
|
mentions,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: MarkdownEditorProps, forwardedRef) {
|
}: MarkdownEditorProps, forwardedRef) {
|
||||||
|
const editorValue = useMemo(() => prepareMarkdownForEditor(value), [value]);
|
||||||
const { slashCommands } = useEditorAutocomplete();
|
const { slashCommands } = useEditorAutocomplete();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const ref = useRef<MDXEditorMethods>(null);
|
const ref = useRef<MDXEditorMethods>(null);
|
||||||
const valueRef = useRef(value);
|
const fallbackTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
valueRef.current = value;
|
const valueRef = useRef(editorValue);
|
||||||
const latestValueRef = useRef(value);
|
valueRef.current = editorValue;
|
||||||
|
const latestValueRef = useRef(editorValue);
|
||||||
const initialChildOnChangeRef = useRef(true);
|
const initialChildOnChangeRef = useRef(true);
|
||||||
/**
|
/**
|
||||||
* After imperative `setMarkdown` (prop sync, mentions, image upload), MDXEditor may emit `onChange`
|
* 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 echoIgnoreMarkdownRef = useRef<string | null>(null);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [richEditorError, setRichEditorError] = useState<string | null>(null);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
|
|
||||||
// Stable ref for imageUploadHandler so plugins don't recreate on every render
|
// 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 mentionStateRef = useRef<MentionState | null>(null);
|
||||||
const [mentionIndex, setMentionIndex] = useState(0);
|
const [mentionIndex, setMentionIndex] = useState(0);
|
||||||
const skillEnterArmedRef = useRef(false);
|
const skillEnterArmedRef = useRef(false);
|
||||||
|
const autocompleteSelectionHandledRef = useRef(false);
|
||||||
const mentionActive = mentionState !== null && (
|
const mentionActive = mentionState !== null && (
|
||||||
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|
||||||
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
|
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
|
||||||
|
|
@ -491,9 +564,59 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
|
if (richEditorError) {
|
||||||
|
fallbackTextareaRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
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
|
// Whether the image plugin should be included (boolean is stable across renders
|
||||||
// as long as the handler presence doesn't toggle)
|
// as long as the handler presence doesn't toggle)
|
||||||
|
|
@ -558,15 +681,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
}, [hasImageUpload]);
|
}, [hasImageUpload]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== latestValueRef.current) {
|
if (editorValue !== latestValueRef.current) {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
// Pair with onChange echo suppression (echoIgnoreMarkdownRef).
|
// Pair with onChange echo suppression (echoIgnoreMarkdownRef).
|
||||||
echoIgnoreMarkdownRef.current = value;
|
echoIgnoreMarkdownRef.current = editorValue;
|
||||||
ref.current.setMarkdown(value);
|
ref.current.setMarkdown(editorValue);
|
||||||
latestValueRef.current = value;
|
latestValueRef.current = editorValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [editorValue]);
|
||||||
|
|
||||||
const decorateProjectMentions = useCallback(() => {
|
const decorateProjectMentions = useCallback(() => {
|
||||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||||
|
|
@ -676,6 +799,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
};
|
};
|
||||||
}, [checkMention, mentionActive]);
|
}, [checkMention, mentionActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mentionActive) return;
|
||||||
|
autocompleteSelectionHandledRef.current = false;
|
||||||
|
}, [mentionActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||||
if (!editable) return;
|
if (!editable) return;
|
||||||
|
|
@ -696,7 +824,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
// Read from ref to avoid stale-closure issues (selectionchange can
|
// Read from ref to avoid stale-closure issues (selectionchange can
|
||||||
// update state between the last render and this callback firing).
|
// update state between the last render and this callback firing).
|
||||||
const state = mentionStateRef.current;
|
const state = mentionStateRef.current;
|
||||||
if (!state) return;
|
if (!state) return false;
|
||||||
const current = latestValueRef.current;
|
const current = latestValueRef.current;
|
||||||
const next = applyMention(current, state, option);
|
const next = applyMention(current, state, option);
|
||||||
if (next !== current) {
|
if (next !== current) {
|
||||||
|
|
@ -729,10 +857,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
mentionStateRef.current = null;
|
mentionStateRef.current = null;
|
||||||
skillEnterArmedRef.current = false;
|
skillEnterArmedRef.current = false;
|
||||||
setMentionState(null);
|
setMentionState(null);
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
[decorateProjectMentions, onChange],
|
[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>) {
|
function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
|
||||||
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
||||||
}
|
}
|
||||||
|
|
@ -761,6 +903,52 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
)
|
)
|
||||||
: null;
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|
@ -868,7 +1056,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
>
|
>
|
||||||
<MDXEditor
|
<MDXEditor
|
||||||
ref={setEditorRef}
|
ref={setEditorRef}
|
||||||
markdown={value}
|
markdown={editorValue}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
const echo = echoIgnoreMarkdownRef.current;
|
const echo = echoIgnoreMarkdownRef.current;
|
||||||
|
|
@ -883,9 +1071,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
|
|
||||||
if (initialChildOnChangeRef.current) {
|
if (initialChildOnChangeRef.current) {
|
||||||
initialChildOnChangeRef.current = false;
|
initialChildOnChangeRef.current = false;
|
||||||
if (next === "" && value !== "") {
|
if (next === "" && editorValue !== "") {
|
||||||
echoIgnoreMarkdownRef.current = value;
|
echoIgnoreMarkdownRef.current = editorValue;
|
||||||
ref.current?.setMarkdown(value);
|
ref.current?.setMarkdown(editorValue);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -893,6 +1081,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
onChange(next);
|
onChange(next);
|
||||||
}}
|
}}
|
||||||
onBlur={() => onBlur?.()}
|
onBlur={() => onBlur?.()}
|
||||||
|
onError={(payload) => {
|
||||||
|
setRichEditorError(payload.error);
|
||||||
|
}}
|
||||||
className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")}
|
className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")}
|
||||||
contentEditableClassName={cn(
|
contentEditableClassName={cn(
|
||||||
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
|
"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",
|
"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",
|
i === mentionIndex && "bg-accent",
|
||||||
)}
|
)}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => handleAutocompletePress(e, option)}
|
||||||
e.preventDefault(); // prevent blur
|
onMouseDown={(e) => handleAutocompletePress(e, option)}
|
||||||
selectMention(option);
|
onTouchStart={(e) => handleAutocompletePress(e, option)}
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (mentionStateRef.current?.trigger === "skill") {
|
if (mentionStateRef.current?.trigger === "skill") {
|
||||||
skillEnterArmedRef.current = true;
|
skillEnterArmedRef.current = true;
|
||||||
|
|
|
||||||
|
|
@ -372,6 +372,43 @@ describe("NewIssueDialog", () => {
|
||||||
act(() => root.unmount());
|
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 () => {
|
it("keeps the mobile dialog bounded with an internal flexible scroll region", async () => {
|
||||||
const { root } = renderDialog(container);
|
const { root } = renderDialog(container);
|
||||||
await flush();
|
await flush();
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,24 @@ export interface Breadcrumb {
|
||||||
interface BreadcrumbContextValue {
|
interface BreadcrumbContextValue {
|
||||||
breadcrumbs: Breadcrumb[];
|
breadcrumbs: Breadcrumb[];
|
||||||
setBreadcrumbs: (crumbs: Breadcrumb[]) => void;
|
setBreadcrumbs: (crumbs: Breadcrumb[]) => void;
|
||||||
|
mobileToolbar: ReactNode | null;
|
||||||
|
setMobileToolbar: (node: ReactNode | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
|
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
|
||||||
|
|
||||||
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||||
const [breadcrumbs, setBreadcrumbsState] = useState<Breadcrumb[]>([]);
|
const [breadcrumbs, setBreadcrumbsState] = useState<Breadcrumb[]>([]);
|
||||||
|
const [mobileToolbar, setMobileToolbarState] = useState<ReactNode | null>(null);
|
||||||
|
|
||||||
const setBreadcrumbs = useCallback((crumbs: Breadcrumb[]) => {
|
const setBreadcrumbs = useCallback((crumbs: Breadcrumb[]) => {
|
||||||
setBreadcrumbsState(crumbs);
|
setBreadcrumbsState(crumbs);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setMobileToolbar = useCallback((node: ReactNode | null) => {
|
||||||
|
setMobileToolbarState(node);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (breadcrumbs.length === 0) {
|
if (breadcrumbs.length === 0) {
|
||||||
document.title = "Paperclip";
|
document.title = "Paperclip";
|
||||||
|
|
@ -29,7 +36,7 @@ export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||||
}, [breadcrumbs]);
|
}, [breadcrumbs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BreadcrumbContext.Provider value={{ breadcrumbs, setBreadcrumbs }}>
|
<BreadcrumbContext.Provider value={{ breadcrumbs, setBreadcrumbs, mobileToolbar, setMobileToolbar }}>
|
||||||
{children}
|
{children}
|
||||||
</BreadcrumbContext.Provider>
|
</BreadcrumbContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
// @vitest-environment node
|
// @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 { describe, expect, it, vi } from "vitest";
|
||||||
import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
@ -163,6 +173,244 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||||
refetchType: "inactive",
|
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", () => {
|
describe("LiveUpdatesProvider visible issue toast suppression", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query";
|
||||||
import type { Agent, Issue, LiveEvent } from "@paperclipai/shared";
|
import type { Agent, Issue, IssueComment, LiveEvent } from "@paperclipai/shared";
|
||||||
import type { RunForIssue } from "../api/activity";
|
import type { RunForIssue } from "../api/activity";
|
||||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||||
|
import { issuesApi } from "../api/issues";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
import { useCompany } from "./CompanyContext";
|
import { useCompany } from "./CompanyContext";
|
||||||
import type { ToastInput } from "./ToastContext";
|
import type { ToastInput } from "./ToastContext";
|
||||||
import { useToast } from "./ToastContext";
|
import { useToast } from "./ToastContext";
|
||||||
|
import { upsertIssueCommentInPages } from "../lib/optimistic-issue-comments";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { toCompanyRelativePath } from "../lib/company-routes";
|
import { toCompanyRelativePath } from "../lib/company-routes";
|
||||||
import { useLocation } from "../lib/router";
|
import { useLocation } from "../lib/router";
|
||||||
|
|
@ -83,6 +85,7 @@ interface VisibleRouteOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VisibleIssueRouteContext {
|
interface VisibleIssueRouteContext {
|
||||||
|
routeIssueRef: string;
|
||||||
issueRefs: Set<string>;
|
issueRefs: Set<string>;
|
||||||
assigneeAgentId: string | null;
|
assigneeAgentId: string | null;
|
||||||
runIds: Set<string>;
|
runIds: Set<string>;
|
||||||
|
|
@ -189,6 +192,7 @@ function resolveVisibleIssueRouteContext(
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
routeIssueRef: issueRef,
|
||||||
issueRefs,
|
issueRefs,
|
||||||
assigneeAgentId: issue?.assigneeAgentId ?? null,
|
assigneeAgentId: issue?.assigneeAgentId ?? null,
|
||||||
runIds,
|
runIds,
|
||||||
|
|
@ -254,6 +258,95 @@ function shouldSuppressAgentStatusToastForVisibleIssue(
|
||||||
return !!agentId && agentId === context.assigneeAgentId;
|
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 ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
|
||||||
const AGENT_TOAST_STATUSES = new Set(["error"]);
|
const AGENT_TOAST_STATUSES = new Set(["error"]);
|
||||||
const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]);
|
const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]);
|
||||||
|
|
@ -481,6 +574,7 @@ function invalidateActivityQueries(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
currentActor: { userId: string | null; agentId: string | null },
|
currentActor: { userId: string | null; agentId: string | null },
|
||||||
|
options?: { pathname?: string; isForegrounded?: boolean },
|
||||||
) {
|
) {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
||||||
|
|
@ -504,9 +598,28 @@ function invalidateActivityQueries(
|
||||||
(action === "issue.updated" && readString(details?.source) === "comment")) &&
|
(action === "issue.updated" && readString(details?.source) === "comment")) &&
|
||||||
((actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) ||
|
((actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) ||
|
||||||
(actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId));
|
(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);
|
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
||||||
for (const ref of issueRefs) {
|
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.detail(ref), ...invalidationOptions });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), ...invalidationOptions });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), ...invalidationOptions });
|
||||||
if (action === "issue.comment_added") {
|
if (action === "issue.comment_added") {
|
||||||
|
|
@ -655,7 +768,10 @@ function handleLiveEvent(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "activity.logged") {
|
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 action = readString(payload.action);
|
||||||
const toast =
|
const toast =
|
||||||
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
|
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
|
||||||
|
|
@ -712,8 +828,11 @@ export const __liveUpdatesTestUtils = {
|
||||||
buildAgentStatusToast,
|
buildAgentStatusToast,
|
||||||
buildRunStatusToast,
|
buildRunStatusToast,
|
||||||
closeSocketQuietly,
|
closeSocketQuietly,
|
||||||
|
hydrateVisibleIssueComment,
|
||||||
invalidateActivityQueries,
|
invalidateActivityQueries,
|
||||||
resolveLiveCompanyId,
|
resolveLiveCompanyId,
|
||||||
|
shouldDeferIssueRefetchForVisibleAgentActivity,
|
||||||
|
shouldDeferVisibleIssueCommentActivity,
|
||||||
shouldSuppressActivityToastForVisibleIssue,
|
shouldSuppressActivityToastForVisibleIssue,
|
||||||
shouldSuppressRunStatusToastForVisibleIssue,
|
shouldSuppressRunStatusToastForVisibleIssue,
|
||||||
shouldSuppressAgentStatusToastForVisibleIssue,
|
shouldSuppressAgentStatusToastForVisibleIssue,
|
||||||
|
|
|
||||||
179
ui/src/hooks/usePaperclipIssueRuntime.test.tsx
Normal file
179
ui/src/hooks/usePaperclipIssueRuntime.test.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AppendMessage, ExternalStoreAdapter, ThreadMessage } from "@assistant-ui/react";
|
||||||
|
import { usePaperclipIssueRuntime } from "./usePaperclipIssueRuntime";
|
||||||
|
|
||||||
|
const { useExternalStoreRuntimeMock } = vi.hoisted(() => ({
|
||||||
|
useExternalStoreRuntimeMock: vi.fn(() => ({ kind: "runtime" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@assistant-ui/react", () => ({
|
||||||
|
useExternalStoreRuntime: useExternalStoreRuntimeMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function HookHarness({
|
||||||
|
messages,
|
||||||
|
isRunning,
|
||||||
|
onSend,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
messages: readonly ThreadMessage[];
|
||||||
|
isRunning: boolean;
|
||||||
|
onSend: (options: { body: string; reopen?: boolean; reassignment?: { assigneeAgentId: string | null; assigneeUserId: string | null } }) => Promise<void>;
|
||||||
|
onCancel?: (() => Promise<void>) | undefined;
|
||||||
|
}) {
|
||||||
|
usePaperclipIssueRuntime({
|
||||||
|
messages,
|
||||||
|
isRunning,
|
||||||
|
onSend,
|
||||||
|
onCancel,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAppendMessage(body: string): AppendMessage {
|
||||||
|
return {
|
||||||
|
createdAt: new Date("2026-04-11T14:00:02.000Z"),
|
||||||
|
parentId: null,
|
||||||
|
role: "user",
|
||||||
|
sourceId: null,
|
||||||
|
content: [{ type: "text", text: body }],
|
||||||
|
metadata: { custom: {} },
|
||||||
|
attachments: [],
|
||||||
|
runConfig: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUserMessage(id: string, text: string): ThreadMessage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
metadata: { custom: {} },
|
||||||
|
attachments: [],
|
||||||
|
createdAt: new Date("2026-04-11T14:00:00.000Z"),
|
||||||
|
} as unknown as ThreadMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAssistantMessage(id: string, text: string): ThreadMessage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
metadata: { custom: {} },
|
||||||
|
status: { type: "complete", reason: "stop" },
|
||||||
|
createdAt: new Date("2026-04-11T14:00:01.000Z"),
|
||||||
|
} as unknown as ThreadMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("usePaperclipIssueRuntime", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
useExternalStoreRuntimeMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the external-store adapter stable across unrelated rerenders", async () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const messages: ThreadMessage[] = [createUserMessage("message-1", "hello")];
|
||||||
|
const firstOnSend = vi.fn(async () => {});
|
||||||
|
const secondOnSend = vi.fn(async () => {});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<HookHarness
|
||||||
|
messages={messages}
|
||||||
|
isRunning={false}
|
||||||
|
onSend={firstOnSend}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtimeCalls = useExternalStoreRuntimeMock.mock.calls as unknown as Array<
|
||||||
|
[ExternalStoreAdapter<ThreadMessage>]
|
||||||
|
>;
|
||||||
|
expect(runtimeCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const firstAdapter = runtimeCalls[0]![0];
|
||||||
|
expect(firstAdapter).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<HookHarness
|
||||||
|
messages={messages}
|
||||||
|
isRunning={false}
|
||||||
|
onSend={secondOnSend}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtimeCalls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const secondAdapter = runtimeCalls[1]![0];
|
||||||
|
expect(secondAdapter).toBe(firstAdapter);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await secondAdapter.onNew?.(createAppendMessage("latest callback"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstOnSend).not.toHaveBeenCalled();
|
||||||
|
expect(secondOnSend).toHaveBeenCalledWith({
|
||||||
|
body: "latest callback",
|
||||||
|
reopen: undefined,
|
||||||
|
reassignment: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds the adapter when thread data changes", () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const onSend = vi.fn(async () => {});
|
||||||
|
const firstMessages: ThreadMessage[] = [createUserMessage("message-1", "hello")];
|
||||||
|
const secondMessages: ThreadMessage[] = [...firstMessages, createAssistantMessage("message-2", "world")];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<HookHarness
|
||||||
|
messages={firstMessages}
|
||||||
|
isRunning={false}
|
||||||
|
onSend={onSend}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtimeCalls = useExternalStoreRuntimeMock.mock.calls as unknown as Array<
|
||||||
|
[ExternalStoreAdapter<ThreadMessage>]
|
||||||
|
>;
|
||||||
|
expect(runtimeCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const firstAdapter = runtimeCalls[0]![0];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<HookHarness
|
||||||
|
messages={secondMessages}
|
||||||
|
isRunning={false}
|
||||||
|
onSend={onSend}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtimeCalls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const secondAdapter = runtimeCalls[1]![0];
|
||||||
|
expect(secondAdapter).not.toBe(firstAdapter);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import { useExternalStoreRuntime, type ThreadMessage, type AppendMessage } from "@assistant-ui/react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import {
|
||||||
|
useExternalStoreRuntime,
|
||||||
|
type ThreadMessage,
|
||||||
|
type AppendMessage,
|
||||||
|
type ExternalStoreAdapter,
|
||||||
|
} from "@assistant-ui/react";
|
||||||
|
|
||||||
export interface PaperclipIssueRuntimeReassignment {
|
export interface PaperclipIssueRuntimeReassignment {
|
||||||
assigneeAgentId: string | null;
|
assigneeAgentId: string | null;
|
||||||
|
|
@ -37,7 +43,18 @@ export function usePaperclipIssueRuntime({
|
||||||
onSend,
|
onSend,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: UsePaperclipIssueRuntimeOptions) {
|
}: 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,
|
messages,
|
||||||
isRunning,
|
isRunning,
|
||||||
onNew: async (message) => {
|
onNew: async (message) => {
|
||||||
|
|
@ -57,12 +74,18 @@ export function usePaperclipIssueRuntime({
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
await onSend({
|
await onSendRef.current({
|
||||||
body,
|
body,
|
||||||
reopen: custom?.reopen === true ? true : undefined,
|
reopen: custom?.reopen === true ? true : undefined,
|
||||||
reassignment,
|
reassignment,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
...(onCancel ? { onCancel } : {}),
|
...(onCancel ? {
|
||||||
});
|
onCancel: async () => {
|
||||||
|
await onCancelRef.current?.();
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
}), [messages, isRunning, !!onCancel]);
|
||||||
|
|
||||||
|
return useExternalStoreRuntime(adapter);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
||||||
"issue.checked_out": "checked out",
|
"issue.checked_out": "checked out",
|
||||||
"issue.released": "released",
|
"issue.released": "released",
|
||||||
"issue.comment_added": "commented on",
|
"issue.comment_added": "commented on",
|
||||||
|
"issue.comment_cancelled": "cancelled a queued comment on",
|
||||||
"issue.attachment_added": "attached file to",
|
"issue.attachment_added": "attached file to",
|
||||||
"issue.attachment_removed": "removed attachment from",
|
"issue.attachment_removed": "removed attachment from",
|
||||||
"issue.document_created": "created document for",
|
"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.checked_out": "checked out the issue",
|
||||||
"issue.released": "released the issue",
|
"issue.released": "released the issue",
|
||||||
"issue.comment_added": "added a comment",
|
"issue.comment_added": "added a comment",
|
||||||
|
"issue.comment_cancelled": "cancelled a queued comment",
|
||||||
"issue.feedback_vote_saved": "saved feedback on an AI output",
|
"issue.feedback_vote_saved": "saved feedback on an AI output",
|
||||||
"issue.attachment_added": "added an attachment",
|
"issue.attachment_added": "added an attachment",
|
||||||
"issue.attachment_removed": "removed an attachment",
|
"issue.attachment_removed": "removed an attachment",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { Agent } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
buildAssistantPartsFromTranscript,
|
buildAssistantPartsFromTranscript,
|
||||||
buildIssueChatMessages,
|
buildIssueChatMessages,
|
||||||
|
stabilizeThreadMessages,
|
||||||
type IssueChatComment,
|
type IssueChatComment,
|
||||||
type IssueChatLinkedRun,
|
type IssueChatLinkedRun,
|
||||||
} from "./issue-chat-messages";
|
} from "./issue-chat-messages";
|
||||||
|
|
@ -527,3 +528,69 @@ describe("buildIssueChatMessages", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("stabilizeThreadMessages", () => {
|
||||||
|
it("reuses unchanged message objects across rebuilds", () => {
|
||||||
|
const firstPass = buildIssueChatMessages({
|
||||||
|
comments: [createComment()],
|
||||||
|
timelineEvents: [],
|
||||||
|
linkedRuns: [],
|
||||||
|
liveRuns: [],
|
||||||
|
currentUserId: "user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstStable = stabilizeThreadMessages(firstPass, [], new Map());
|
||||||
|
const secondPass = buildIssueChatMessages({
|
||||||
|
comments: [
|
||||||
|
createComment(),
|
||||||
|
createComment({
|
||||||
|
id: "comment-2",
|
||||||
|
body: "New message",
|
||||||
|
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
timelineEvents: [],
|
||||||
|
linkedRuns: [],
|
||||||
|
liveRuns: [],
|
||||||
|
currentUserId: "user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondStable = stabilizeThreadMessages(
|
||||||
|
secondPass,
|
||||||
|
firstStable.messages,
|
||||||
|
firstStable.cache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(secondStable.messages).toHaveLength(2);
|
||||||
|
expect(secondStable.messages[0]).toBe(firstStable.messages[0]);
|
||||||
|
expect(secondStable.messages[1]?.id).toBe("comment-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses the previous array when nothing semantically changed", () => {
|
||||||
|
const firstPass = buildIssueChatMessages({
|
||||||
|
comments: [createComment()],
|
||||||
|
timelineEvents: [],
|
||||||
|
linkedRuns: [],
|
||||||
|
liveRuns: [],
|
||||||
|
currentUserId: "user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstStable = stabilizeThreadMessages(firstPass, [], new Map());
|
||||||
|
const secondPass = buildIssueChatMessages({
|
||||||
|
comments: [createComment()],
|
||||||
|
timelineEvents: [],
|
||||||
|
linkedRuns: [],
|
||||||
|
liveRuns: [],
|
||||||
|
currentUserId: "user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondStable = stabilizeThreadMessages(
|
||||||
|
secondPass,
|
||||||
|
firstStable.messages,
|
||||||
|
firstStable.cache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(secondStable.messages).toBe(firstStable.messages);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,11 @@ type MessageWithOrder = {
|
||||||
message: ThreadMessage;
|
message: ThreadMessage;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface StableThreadMessageCacheEntry {
|
||||||
|
fingerprint: string;
|
||||||
|
message: ThreadMessage;
|
||||||
|
}
|
||||||
|
|
||||||
function toDate(value: Date | string | null | undefined) {
|
function toDate(value: Date | string | null | undefined) {
|
||||||
return value instanceof Date ? value : new Date(value ?? Date.now());
|
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();
|
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[]) {
|
function sortByCreated<T extends { createdAt: Date | string; id: string }>(items: readonly T[]) {
|
||||||
return [...items].sort((a, b) => {
|
return [...items].sort((a, b) => {
|
||||||
const diff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
|
const diff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
|
||||||
|
|
|
||||||
98
ui/src/lib/issue-chat-scroll.test.ts
Normal file
98
ui/src/lib/issue-chat-scroll.test.ts
Normal 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
ui/src/lib/issue-chat-scroll.ts
Normal file
70
ui/src/lib/issue-chat-scroll.ts
Normal 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
ui/src/lib/issue-detail-subissues.test.ts
Normal file
18
ui/src/lib/issue-detail-subissues.test.ts
Normal 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
ui/src/lib/issue-detail-subissues.ts
Normal file
3
ui/src/lib/issue-detail-subissues.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function shouldRenderRichSubIssuesSection(childIssuesLoading: boolean, childIssueCount: number): boolean {
|
||||||
|
return childIssuesLoading || childIssueCount > 0;
|
||||||
|
}
|
||||||
47
ui/src/lib/issue-properties-panel-key.test.ts
Normal file
47
ui/src/lib/issue-properties-panel-key.test.ts
Normal file
|
|
@ -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
ui/src/lib/issue-properties-panel-key.ts
Normal file
76
ui/src/lib/issue-properties-panel-key.ts
Normal 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,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ describe("issue-reference", () => {
|
||||||
expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179");
|
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({
|
expect(parseIssueReferenceFromHref("pap-1271")).toEqual({
|
||||||
issuePathId: "PAP-1271",
|
issuePathId: "PAP-1271",
|
||||||
href: "/issues/PAP-1271",
|
href: "/issues/PAP-1271",
|
||||||
|
|
@ -20,6 +20,14 @@ describe("issue-reference", () => {
|
||||||
issuePathId: "PAP-1179",
|
issuePathId: "PAP-1179",
|
||||||
href: "/issues/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", () => {
|
it("normalizes exact inline-code-like issue identifiers", () => {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ type MarkdownNode = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]+-\d+$/i;
|
const 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 {
|
export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null {
|
||||||
if (!pathOrUrl) return null;
|
if (!pathOrUrl) return null;
|
||||||
|
|
@ -29,6 +30,16 @@ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined):
|
||||||
|
|
||||||
export function parseIssueReferenceFromHref(href: string | null | undefined) {
|
export function parseIssueReferenceFromHref(href: string | null | undefined) {
|
||||||
if (!href) return null;
|
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);
|
const pathId = parseIssuePathIdFromPath(href);
|
||||||
if (pathId) {
|
if (pathId) {
|
||||||
return {
|
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;
|
if (!BARE_ISSUE_IDENTIFIER_RE.test(trimmed)) return null;
|
||||||
const normalized = trimmed.toUpperCase();
|
const normalized = trimmed.toUpperCase();
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
isQueuedIssueComment,
|
isQueuedIssueComment,
|
||||||
matchesIssueRef,
|
matchesIssueRef,
|
||||||
mergeIssueComments,
|
mergeIssueComments,
|
||||||
|
removeIssueCommentFromPages,
|
||||||
|
takeOptimisticIssueComment,
|
||||||
upsertIssueComment,
|
upsertIssueComment,
|
||||||
upsertIssueCommentInPages,
|
upsertIssueCommentInPages,
|
||||||
} from "./optimistic-issue-comments";
|
} from "./optimistic-issue-comments";
|
||||||
|
|
@ -101,6 +103,30 @@ describe("optimistic issue comments", () => {
|
||||||
expect(merged.map((comment) => comment.id)).toEqual(["optimistic-1", "comment-2"]);
|
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", () => {
|
it("upserts confirmed comments without creating duplicates", () => {
|
||||||
const next = upsertIssueComment(
|
const next = upsertIssueComment(
|
||||||
[
|
[
|
||||||
|
|
@ -250,6 +276,52 @@ describe("optimistic issue comments", () => {
|
||||||
expect(nextPages[1]?.map((comment) => comment.id)).toEqual(["comment-1"]);
|
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", () => {
|
it("applies optimistic reopen and reassignment updates to the issue cache", () => {
|
||||||
const next = applyOptimisticIssueCommentUpdate(
|
const next = applyOptimisticIssueCommentUpdate(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,21 @@ export function mergeIssueComments(
|
||||||
return sortIssueComments(merged);
|
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(
|
export function flattenIssueCommentPages(
|
||||||
pages: ReadonlyArray<ReadonlyArray<IssueComment>> | undefined,
|
pages: ReadonlyArray<ReadonlyArray<IssueComment>> | undefined,
|
||||||
): IssueComment[] {
|
): IssueComment[] {
|
||||||
|
|
@ -254,3 +269,16 @@ export function upsertIssueCommentInPages(
|
||||||
nextPages[0] = sortIssueCommentsDesc([...nextPages[0]!, nextComment]);
|
nextPages[0] = sortIssueCommentsDesc([...nextPages[0]!, nextComment]);
|
||||||
return nextPages;
|
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
ui/src/lib/query-placeholder-data.test.tsx
Normal file
111
ui/src/lib/query-placeholder-data.test.tsx
Normal 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
ui/src/lib/query-placeholder-data.ts
Normal file
10
ui/src/lib/query-placeholder-data.ts
Normal 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
ui/src/lib/subIssueDefaults.test.ts
Normal file
136
ui/src/lib/subIssueDefaults.test.ts
Normal 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
ui/src/lib/subIssueDefaults.ts
Normal file
52
ui/src/lib/subIssueDefaults.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue