diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 3626c2f5..dca9c8f7 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -424,6 +424,192 @@ describe("heartbeat comment wake batching", () => { } }, 120_000); + it("promotes deferred comment wakes after the active run closes the issue", async () => { + const gateway = await createControlledGatewayServer(); + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Gateway Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Reopen after deferred comment", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const comment1 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "First comment", + }) + .returning() + .then((rows) => rows[0]); + + const firstRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment1.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment1.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(firstRun).not.toBeNull(); + await waitFor(async () => { + const run = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, firstRun!.id)) + .then((rows) => rows[0] ?? null); + return run?.status === "running"; + }); + + const comment2 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "Please handle this follow-up after you finish", + }) + .returning() + .then((rows) => rows[0]); + + const deferredRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment2.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment2.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(deferredRun).toBeNull(); + + await waitFor(async () => { + const deferred = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + return Boolean(deferred); + }); + + await db + .update(issues) + .set({ + status: "done", + completedAt: new Date(), + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, issueId)); + + gateway.releaseFirstWait(); + + await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000); + await waitFor(async () => { + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + return runs.length === 2 && runs.every((run) => run.status === "succeeded"); + }, 90_000); + + const reopenedIssue = await db + .select({ + status: issues.status, + completedAt: issues.completedAt, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + + expect(reopenedIssue).toMatchObject({ + status: "in_progress", + completedAt: null, + }); + + const secondPayload = gateway.getAgentPayloads()[1] ?? {}; + expect(secondPayload.paperclip).toMatchObject({ + wake: { + reason: "issue_commented", + commentIds: [comment2.id], + latestCommentId: comment2.id, + issue: { + id: issueId, + identifier: `${issuePrefix}-1`, + title: "Reopen after deferred comment", + status: "in_progress", + priority: "medium", + }, + }, + }); + expect(String(secondPayload.message ?? "")).toContain("Please handle this follow-up after you finish"); + } finally { + gateway.releaseFirstWait(); + await gateway.close(); + } + }, 120_000); + it("queues exactly one follow-up run when an issue-bound run exits without a comment", async () => { const gateway = await createControlledGatewayServer(); const companyId = randomUUID(); @@ -575,4 +761,118 @@ describe("heartbeat comment wake batching", () => { } }, 20_000); + it("treats the automatic run summary as fallback-only when the run already posted a comment", async () => { + const gateway = await createControlledGatewayServer(); + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Gateway Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Use existing comment", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const firstRun = await heartbeat.wakeup(agentId, { + source: "assignment", + triggerDetail: "system", + reason: "issue_assigned", + payload: { issueId }, + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_assigned", + }, + requestedByActorType: "system", + requestedByActorId: null, + }); + + expect(firstRun).not.toBeNull(); + await waitFor(() => gateway.getAgentPayloads().length === 1); + + await db.insert(issueComments).values({ + companyId, + issueId, + authorAgentId: agentId, + authorUserId: null, + createdByRunId: firstRun!.id, + body: "Manual completion comment from the run.", + }); + + gateway.releaseFirstWait(); + + await waitFor(async () => { + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + return runs.length === 1 && runs[0]?.status === "succeeded" && runs[0]?.issueCommentStatus === "satisfied"; + }); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + + expect(runs).toHaveLength(1); + expect(runs[0]?.issueCommentStatus).toBe("satisfied"); + expect(runs[0]?.issueCommentSatisfiedByCommentId).not.toBeNull(); + + const comments = await db + .select() + .from(issueComments) + .where(eq(issueComments.issueId, issueId)) + .orderBy(asc(issueComments.createdAt)); + + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toBe("Manual completion comment from the run."); + expect(comments[0]?.createdByRunId).toBe(firstRun?.id); + + const wakeups = await db + .select() + .from(agentWakeupRequests) + .where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId))); + + expect(wakeups).toHaveLength(1); + } finally { + gateway.releaseFirstWait(); + await gateway.close(); + } + }, 20_000); }); diff --git a/server/src/__tests__/issue-comment-cancel-routes.test.ts b/server/src/__tests__/issue-comment-cancel-routes.test.ts new file mode 100644 index 00000000..ca070233 --- /dev/null +++ b/server/src/__tests__/issue-comment-cancel-routes.test.ts @@ -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) { + 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 = {}) { + 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(); + }); +}); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 78b75d3a..3774a585 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -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) => ({ + ...makeIssue("done"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "hello", assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + status: "todo", + actorAgentId: null, + actorUserId: "local-board", + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.updated", + details: expect.objectContaining({ + reopened: true, + reopenedFrom: "done", + status: "todo", + }), + }), + ); + }); + it("reopens closed issues via the PATCH comment path", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("done")); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ @@ -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) => ({ + ...makeIssue("done"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "hello" }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { status: "todo" }, + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_reopened_via_comment", + payload: expect.objectContaining({ + reopenedFrom: "done", + }), + }), + ); + }); + + it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => { + mockIssueService.getById.mockResolvedValue({ + ...makeIssue("done"), + assigneeAgentId: null, + assigneeUserId: "local-board", + }); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "hello" }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + it("interrupts an active run before a combined comment update", async () => { const issue = { ...makeIssue("todo"), diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 6ffd524e..0d9c6876 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -142,6 +142,22 @@ function summarizeExecutionParticipants( ); } +function isClosedIssueStatus(status: string | null | undefined): status is "done" | "cancelled" { + return status === "done" || status === "cancelled"; +} + +function shouldImplicitlyReopenCommentForAgent(input: { + issueStatus: string | null | undefined; + assigneeAgentId: string | null | undefined; + actorType: "agent" | "user"; + actorId: string; +}) { + if (!isClosedIssueStatus(input.issueStatus)) return false; + if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false; + if (input.actorType === "agent" && input.actorId === input.assigneeAgentId) return false; + return true; +} + function diffExecutionParticipants( previousPolicy: NormalizedExecutionPolicy | null, nextPolicy: NormalizedExecutionPolicy | null, @@ -444,6 +460,32 @@ export function issueRoutes( return runToInterrupt?.status === "running" ? runToInterrupt : null; } + function toValidTimestamp(value: Date | string | null | undefined) { + if (!value) return null; + const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : null; + } + + function isQueuedIssueCommentForActiveRun(params: { + comment: { + authorAgentId?: string | null; + createdAt?: Date | string | null; + }; + activeRun: { + agentId?: string | null; + startedAt?: Date | string | null; + createdAt?: Date | string | null; + }; + }) { + const activeRunStartedAtMs = + toValidTimestamp(params.activeRun.startedAt) ?? toValidTimestamp(params.activeRun.createdAt); + const commentCreatedAtMs = toValidTimestamp(params.comment.createdAt); + + if (activeRunStartedAtMs === null || commentCreatedAtMs === null) return false; + if (params.comment.authorAgentId && params.comment.authorAgentId === params.activeRun.agentId) return false; + return commentCreatedAtMs >= activeRunStartedAtMs; + } + async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) { if (!issue.executionWorkspaceId) return null; const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId); @@ -1313,7 +1355,7 @@ export function issueRoutes( if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; const actor = getActorInfo(req); - const isClosed = existing.status === "done" || existing.status === "cancelled"; + const isClosed = isClosedIssueStatus(existing.status); const existingRelations = Array.isArray(req.body.blockedByIssueIds) ? await svc.getRelationSummaries(existing.id) @@ -1325,6 +1367,17 @@ export function issueRoutes( hiddenAt: hiddenAtRaw, ...updateFields } = req.body; + const requestedAssigneeAgentId = + req.body.assigneeAgentId === undefined ? existing.assigneeAgentId : (req.body.assigneeAgentId as string | null); + const effectiveReopenRequested = + reopenRequested || + (!!commentBody && + shouldImplicitlyReopenCommentForAgent({ + issueStatus: existing.status, + assigneeAgentId: requestedAssigneeAgentId, + actorType: actor.actorType, + actorId: actor.actorId, + })); let interruptedRunId: string | null = null; const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing); const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0; @@ -1367,7 +1420,7 @@ export function issueRoutes( if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } - if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) { + if (commentBody && effectiveReopenRequested && isClosed && updateFields.status === undefined) { updateFields.status = "todo"; } if (req.body.executionPolicy !== undefined) { @@ -1526,7 +1579,7 @@ export function issueRoutes( const hasFieldChanges = Object.keys(previous).length > 0; const reopened = commentBody && - reopenRequested === true && + effectiveReopenRequested && isClosed && previous.status !== undefined && issue.status === "todo"; @@ -1748,7 +1801,7 @@ export function issueRoutes( const selfComment = actorIsAgent && actor.actorId === assigneeId; const skipAssigneeCommentWake = selfComment || isClosed; - if (assigneeId && !assigneeChanged && !skipAssigneeCommentWake) { + if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) { addWakeup(assigneeId, { source: "automation", triggerDetail: "system", @@ -2069,6 +2122,72 @@ export function issueRoutes( res.json(comment); }); + router.delete("/issues/:id/comments/:commentId", async (req, res) => { + const id = req.params.id as string; + const commentId = req.params.commentId as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return; + + const comment = await svc.getComment(commentId); + if (!comment || comment.issueId !== id) { + res.status(404).json({ error: "Comment not found" }); + return; + } + + const actor = getActorInfo(req); + const actorOwnsComment = + actor.actorType === "agent" + ? comment.authorAgentId === actor.agentId + : comment.authorUserId === actor.actorId; + if (!actorOwnsComment) { + res.status(403).json({ error: "Only the comment author can cancel queued comments" }); + return; + } + + const activeRun = await resolveActiveIssueRun(issue); + if (!activeRun) { + res.status(409).json({ error: "Queued comment can no longer be canceled" }); + return; + } + + if (!isQueuedIssueCommentForActiveRun({ comment, activeRun })) { + res.status(409).json({ error: "Only queued comments can be canceled" }); + return; + } + + const removed = await svc.removeComment(commentId); + if (!removed) { + res.status(404).json({ error: "Comment not found" }); + return; + } + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.comment_cancelled", + entityType: "issue", + entityId: issue.id, + details: { + commentId: removed.id, + bodySnippet: removed.body.slice(0, 120), + identifier: issue.identifier, + issueTitle: issue.title, + source: "queue_cancel", + queueTargetRunId: activeRun.id, + }, + }); + + res.json(removed); + }); + router.get("/issues/:id/feedback-votes", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); @@ -2167,13 +2286,21 @@ export function issueRoutes( const actor = getActorInfo(req); const reopenRequested = req.body.reopen === true; const interruptRequested = req.body.interrupt === true; - const isClosed = issue.status === "done" || issue.status === "cancelled"; + const isClosed = isClosedIssueStatus(issue.status); + const effectiveReopenRequested = + reopenRequested || + shouldImplicitlyReopenCommentForAgent({ + issueStatus: issue.status, + assigneeAgentId: issue.assigneeAgentId, + actorType: actor.actorType, + actorId: actor.actorId, + }); let reopened = false; let reopenFromStatus: string | null = null; let interruptedRunId: string | null = null; let currentIssue = issue; - if (reopenRequested && isClosed) { + if (effectiveReopenRequested && isClosed) { const reopenedIssue = await svc.update(id, { status: "todo" }); if (!reopenedIssue) { res.status(404).json({ error: "Issue not found" }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index e0e0a317..cf621783 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -37,6 +37,7 @@ import { mergeHeartbeatRunResultJson, summarizeHeartbeatRunResultJson, } from "./heartbeat-run-summary.js"; +import { logActivity, type LogActivityInput } from "./activity-log.js"; import { buildWorkspaceReadyComment, cleanupExecutionWorkspaceArtifacts, @@ -3485,9 +3486,12 @@ export function heartbeatService(db: Db) { }); if (issueId && outcome === "succeeded") { try { - const issueComment = buildHeartbeatRunIssueComment(persistedResultJson); - if (issueComment) { - await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id }); + const existingRunComment = await findRunIssueComment(finalizedRun.id, finalizedRun.companyId, issueId); + if (!existingRunComment) { + const issueComment = buildHeartbeatRunIssueComment(persistedResultJson); + if (issueComment) { + await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id }); + } } } catch (err) { await onLog( @@ -3632,31 +3636,50 @@ export function heartbeatService(db: Db) { } async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) { - const promotedRun = await db.transaction(async (tx) => { - await tx.execute( - sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`, - ); + const runContext = parseObject(run.contextSnapshot); + const contextIssueId = readNonEmptyString(runContext.issueId); + const promotionResult = await db.transaction(async (tx) => { + if (contextIssueId) { + await tx.execute( + sql`select id from issues where company_id = ${run.companyId} and id = ${contextIssueId} for update`, + ); + } else { + await tx.execute( + sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`, + ); + } - const issue = await tx + let issue = await tx .select({ id: issues.id, companyId: issues.companyId, + identifier: issues.identifier, + status: issues.status, + executionRunId: issues.executionRunId, }) .from(issues) - .where(and(eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id))) + .where( + and( + eq(issues.companyId, run.companyId), + contextIssueId ? eq(issues.id, contextIssueId) : eq(issues.executionRunId, run.id), + ), + ) .then((rows) => rows[0] ?? null); - if (!issue) return; + if (!issue) return null; + if (issue.executionRunId && issue.executionRunId !== run.id) return null; - await tx - .update(issues) - .set({ - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: new Date(), - }) - .where(eq(issues.id, issue.id)); + if (issue.executionRunId === run.id) { + await tx + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, issue.id)); + } while (true) { const deferred = await tx @@ -3703,6 +3726,51 @@ export function heartbeatService(db: Db) { const deferredPayload = parseObject(deferred.payload); const deferredContextSeed = parseObject(deferredPayload[DEFERRED_WAKE_CONTEXT_KEY]); const promotedContextSeed: Record = { ...deferredContextSeed }; + const deferredCommentIds = extractWakeCommentIds(deferredContextSeed); + const shouldReopenDeferredCommentWake = + deferredCommentIds.length > 0 && (issue.status === "done" || issue.status === "cancelled"); + let reopenedActivity: LogActivityInput | null = null; + + if (shouldReopenDeferredCommentWake) { + const reopenedFromStatus = issue.status; + const reopenedIssue = await issuesSvc.update( + issue.id, + { + status: "todo", + executionState: null, + }, + tx, + ); + if (reopenedIssue) { + issue = { + ...issue, + identifier: reopenedIssue.identifier, + status: reopenedIssue.status, + executionRunId: reopenedIssue.executionRunId, + }; + if (!readNonEmptyString(promotedContextSeed.reopenedFrom)) { + promotedContextSeed.reopenedFrom = reopenedFromStatus; + } + reopenedActivity = { + companyId: issue.companyId, + actorType: "system", + actorId: "heartbeat", + agentId: deferred.agentId, + runId: run.id, + action: "issue.updated", + entityType: "issue", + entityId: issue.id, + details: { + status: "todo", + reopened: true, + reopenedFrom: reopenedFromStatus, + source: "deferred_comment_wake", + identifier: issue.identifier, + }, + }; + } + } + const promotedReason = readNonEmptyString(deferred.reason) ?? "issue_execution_promoted"; const promotedSource = (readNonEmptyString(deferred.source) as WakeupOptions["source"]) ?? "automation"; @@ -3764,12 +3832,20 @@ export function heartbeatService(db: Db) { }) .where(eq(issues.id, issue.id)); - return newRun; + return { + run: newRun, + reopenedActivity, + }; } }); + const promotedRun = promotionResult?.run ?? null; if (!promotedRun) return; + if (promotionResult?.reopenedActivity) { + await logActivity(db, promotionResult.reopenedActivity); + } + publishLiveEvent({ companyId: promotedRun.companyId, type: "heartbeat.run.queued", diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index f7ac19da..db8eb39a 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -2112,6 +2112,28 @@ export function issueService(db: Db) { return comment ? redactIssueComment(comment, censorUsernameInLogs) : null; })), + removeComment: async (commentId: string) => { + const currentUserRedactionOptions = { + enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, + }; + + return db.transaction(async (tx) => { + const [comment] = await tx + .delete(issueComments) + .where(eq(issueComments.id, commentId)) + .returning(); + + if (!comment) return null; + + await tx + .update(issues) + .set({ updatedAt: new Date() }) + .where(eq(issues.id, comment.issueId)); + + return redactIssueComment(comment, currentUserRedactionOptions.enabled); + }); + }, + addComment: async ( issueId: string, body: string, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index bd604af9..e0d8ec05 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -97,6 +97,8 @@ export const issuesApi = { const qs = params.toString(); return api.get(`/issues/${id}/comments${qs ? `?${qs}` : ""}`); }, + getComment: (id: string, commentId: string) => + api.get(`/issues/${id}/comments/${commentId}`), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), listFeedbackTraces: (id: string, filters?: Record) => { const params = new URLSearchParams(); @@ -126,6 +128,8 @@ export const issuesApi = { ...(interrupt === undefined ? {} : { interrupt }), }, ), + cancelComment: (id: string, commentId: string) => + api.delete(`/issues/${id}/comments/${commentId}`), listDocuments: (id: string) => api.get(`/issues/${id}/documents`), getDocument: (id: string, key: string) => api.get(`/issues/${id}/documents/${encodeURIComponent(key)}`), upsertDocument: (id: string, key: string, data: UpsertIssueDocument) => diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index a4d1462a..98319075 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -31,7 +31,7 @@ function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) { } export function BreadcrumbBar() { - const { breadcrumbs } = useBreadcrumbs(); + const { breadcrumbs, mobileToolbar } = useBreadcrumbs(); const { toggleSidebar, isMobile } = useSidebar(); const { selectedCompanyId, selectedCompany } = useCompany(); @@ -45,6 +45,14 @@ export function BreadcrumbBar() { const globalToolbarSlots = ; + if (isMobile && mobileToolbar) { + return ( +
+ {mobileToolbar} +
+ ); + } + if (breadcrumbs.length === 0) { return (
diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 0fa73210..860ea0ad 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -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( + + + , + ); + }); + + expect(container.textContent).not.toContain("Re-open"); + + const editor = container.querySelector('textarea[aria-label="Comment editor"]') as HTMLTextAreaElement | null; + const submitButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent === "Comment", + ) as HTMLButtonElement | undefined; + expect(editor).not.toBeNull(); + expect(submitButton).toBeDefined(); + + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value", + )?.set; + valueSetter?.call(editor, "Please pick this back up"); + editor?.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + submitButton?.click(); + }); + + expect(onAdd).toHaveBeenCalledWith("Please pick this back up", true, undefined); + + act(() => { + root.unmount(); + }); + }); + it("renders linked approvals inline in the timeline", () => { const root = createRoot(container); const agent: Agent = { diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index f4ad17b5..06fe7681 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -134,6 +134,11 @@ function parseReassignment(target: string): CommentReassignment | null { return null; } +function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) { + const isClosed = issueStatus === "done" || issueStatus === "cancelled"; + return isClosed && assigneeValue.startsWith("agent:"); +} + function humanizeValue(value: string | null): string { if (!value) return "None"; return value.replace(/_/g, " "); @@ -647,6 +652,7 @@ export function CommentThread({ pendingApprovalAction = null, onVote, onAdd, + issueStatus, agentMap, currentUserId, imageUploadHandler, @@ -663,7 +669,6 @@ export function CommentThread({ composerDisabledReason = null, }: CommentThreadProps) { const [body, setBody] = useState(""); - const [reopen, setReopen] = useState(true); const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; @@ -784,14 +789,17 @@ export function CommentThread({ if (!trimmed) return; const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; + const reopen = shouldImplicitlyReopenComment( + issueStatus, + hasReassignment ? reassignTarget : currentAssigneeValue, + ) ? true : undefined; const submittedBody = trimmed; setSubmitting(true); setBody(""); try { - await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined); + await onAdd(submittedBody, reopen, reassignment ?? undefined); if (draftKey) clearDraft(draftKey); - setReopen(true); setReassignTarget(effectiveSuggestedAssigneeValue); } catch { setBody((current) => @@ -935,15 +943,6 @@ export function CommentThread({
)} - {enableReassign && reassignOptions.length > 0 && ( ({ markdownEditorFocusMock: vi.fn(), })); +const { appendMock } = vi.hoisted(() => ({ + appendMock: vi.fn(async () => undefined), +})); + const { threadMessagesMock } = vi.hoisted(() => ({ threadMessagesMock: vi.fn(() =>
), })); +const { + captureComposerViewportSnapshotMock, + restoreComposerViewportSnapshotMock, + shouldPreserveComposerViewportMock, +} = vi.hoisted(() => ({ + captureComposerViewportSnapshotMock: vi.fn(), + restoreComposerViewportSnapshotMock: vi.fn(), + shouldPreserveComposerViewportMock: vi.fn(), +})); + vi.mock("@assistant-ui/react", () => ({ AssistantRuntimeProvider: ({ children }: { children: ReactNode }) =>
{children}
, ThreadPrimitive: { @@ -32,7 +46,7 @@ vi.mock("@assistant-ui/react", () => ({ Content: () => null, Parts: () => null, }, - useAui: () => ({ thread: () => ({ append: vi.fn() }) }), + useAui: () => ({ thread: () => ({ append: appendMock }) }), useAuiState: () => false, useMessage: () => ({ id: "message", @@ -51,6 +65,16 @@ vi.mock("./transcript/useLiveRunTranscripts", () => ({ }), })); +vi.mock("../lib/issue-chat-scroll", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + captureComposerViewportSnapshot: captureComposerViewportSnapshotMock.mockImplementation(actual.captureComposerViewportSnapshot), + restoreComposerViewportSnapshot: restoreComposerViewportSnapshotMock.mockImplementation(actual.restoreComposerViewportSnapshot), + shouldPreserveComposerViewport: shouldPreserveComposerViewportMock.mockImplementation(actual.shouldPreserveComposerViewport), + }; +}); + vi.mock("./MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: ReactNode }) =>
{children}
, })); @@ -126,8 +150,12 @@ describe("IssueChatThread", () => { afterEach(() => { container.remove(); vi.useRealTimers(); + appendMock.mockReset(); markdownEditorFocusMock.mockReset(); threadMessagesMock.mockReset(); + captureComposerViewportSnapshotMock.mockClear(); + restoreComposerViewportSnapshotMock.mockClear(); + shouldPreserveComposerViewportMock.mockClear(); }); it("drops the count heading and does not use an internal scrollbox", () => { @@ -338,9 +366,67 @@ describe("IssueChatThread", () => { }); }); + it("hides the reopen control and infers reopen for closed agent-assigned issue replies", async () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.textContent).not.toContain("Re-open"); + + const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null; + const submitButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent === "Send", + ) as HTMLButtonElement | undefined; + expect(editor).not.toBeNull(); + expect(submitButton).toBeDefined(); + + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value", + )?.set; + valueSetter?.call(editor, "Please pick this back up"); + editor?.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + submitButton?.click(); + }); + + expect(appendMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: [{ type: "text", text: "Please pick this back up" }], + runConfig: { + custom: { + reopen: true, + }, + }, + }), + ); + + act(() => { + root.unmount(); + }); + }); + it("exposes a composer focus handle that forwards to the editor", () => { const root = createRoot(container); - const composerRef = createRef<{ focus: () => void }>(); + const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>(); const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {}); const requestAnimationFrameMock = vi .spyOn(window, "requestAnimationFrame") @@ -387,6 +473,159 @@ describe("IssueChatThread", () => { }); }); + it("restores a cancelled queued draft into the composer handle", () => { + const root = createRoot(container); + const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>(); + const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {}); + const requestAnimationFrameMock = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + + act(() => { + root.render( + + {}} + composerRef={composerRef} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + 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( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + 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( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(restoreComposerViewportSnapshotMock).toHaveBeenCalled(); + + scrollByMock.mockRestore(); + act(() => { + root.unmount(); + }); + }); + it("folds chain-of-thought when the same message transitions from running to complete", () => { expect(resolveAssistantMessageFoldedState({ messageId: "message-1", @@ -406,4 +645,20 @@ describe("IssueChatThread", () => { previousIsFoldable: true, })).toBe(false); }); + + it("shows the stop-run action for active run-linked messages even without embedded run status", () => { + expect(canStopIssueChatRun({ + runId: "run-1", + runStatus: null, + activeRunIds: new Set(["run-1"]), + })).toBe(true); + }); + + it("hides the stop-run action for completed historical runs", () => { + expect(canStopIssueChatRun({ + runId: "run-1", + runStatus: "cancelled", + activeRunIds: new Set(), + })).toBe(false); + }); }); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 00d3df61..a8f48cd9 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -15,6 +15,7 @@ import { useContext, useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, @@ -36,8 +37,10 @@ import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from import { buildIssueChatMessages, formatDurationWords, + stabilizeThreadMessages, type IssueChatComment, type IssueChatLinkedRun, + type StableThreadMessageCacheEntry, type IssueChatTranscriptEntry, type SegmentTiming, } from "../lib/issue-chat-messages"; @@ -65,6 +68,11 @@ import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { AgentIcon } from "./AgentIconPicker"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; +import { + captureComposerViewportSnapshot, + restoreComposerViewportSnapshot, + shouldPreserveComposerViewport, +} from "../lib/issue-chat-scroll"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { timeAgo } from "../lib/timeAgo"; import { @@ -80,7 +88,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; -import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react"; +import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react"; interface IssueChatMessageContext { feedbackVoteByTargetId: Map; @@ -88,12 +96,16 @@ interface IssueChatMessageContext { feedbackTermsUrl: string | null; agentMap?: Map; currentUserId?: string | null; + activeRunIds: ReadonlySet; onVote?: ( commentId: string, vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => Promise; + onStopRun?: (runId: string) => Promise; + stoppingRunId?: string | null; onInterruptQueued?: (runId: string) => Promise; + onCancelQueued?: (commentId: string) => void; interruptingQueuedRunId?: string | null; onImageClick?: (src: string) => void; } @@ -102,6 +114,7 @@ const IssueChatCtx = createContext({ feedbackVoteByTargetId: new Map(), feedbackDataSharingPreference: "prompt", feedbackTermsUrl: null, + activeRunIds: new Set(), }); export function resolveAssistantMessageFoldedState(args: { @@ -125,6 +138,17 @@ export function resolveAssistantMessageFoldedState(args: { return currentFolded; } +export function canStopIssueChatRun(args: { + runId: string | null; + runStatus: string | null; + activeRunIds: ReadonlySet; +}) { + const { runId, runStatus, activeRunIds } = args; + if (!runId) return false; + if (activeRunIds.has(runId)) return true; + return runStatus === "queued" || runStatus === "running"; +} + function findCoTSegmentIndex( messageParts: ReadonlyArray<{ type: string }>, cotParts: ReadonlyArray<{ type: string }>, @@ -162,6 +186,7 @@ interface CommentReassignment { export interface IssueChatComposerHandle { focus: () => void; + restoreDraft: (submittedBody: string) => void; } interface IssueChatComposerProps { @@ -199,6 +224,7 @@ interface IssueChatThreadProps { ) => Promise; onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; onCancelRun?: () => Promise; + onStopRun?: (runId: string) => Promise; imageUploadHandler?: (file: File) => Promise; onAttachImage?: (file: File) => Promise; draftKey?: string; @@ -217,7 +243,9 @@ interface IssueChatThreadProps { hasOutputForRun?: (runId: string) => boolean; includeSucceededRunsWithoutOutput?: boolean; onInterruptQueued?: (runId: string) => Promise; + onCancelQueued?: (commentId: string) => void; interruptingQueuedRunId?: string | null; + stoppingRunId?: string | null; onImageClick?: (src: string) => void; composerRef?: Ref; } @@ -412,6 +440,11 @@ function parseReassignment(target: string): PaperclipIssueRuntimeReassignment | return null; } +function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) { + const isClosed = issueStatus === "done" || issueStatus === "cancelled"; + return isClosed && assigneeValue.startsWith("agent:"); +} + const WEEK_MS = 7 * 24 * 60 * 60 * 1000; function commentDateLabel(date: Date | string | undefined): string { @@ -873,10 +906,11 @@ function IssueChatToolPart({ } function IssueChatUserMessage() { - const { onInterruptQueued, interruptingQueuedRunId } = useContext(IssueChatCtx); + const { onInterruptQueued, onCancelQueued, interruptingQueuedRunId } = useContext(IssueChatCtx); const message = useMessage(); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; + const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id; const queued = custom.queueState === "queued" || custom.clientStatus === "queued"; const pending = custom.clientStatus === "pending"; const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; @@ -911,6 +945,16 @@ function IssueChatUserMessage() { {interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"} ) : null} + {onCancelQueued ? ( + + ) : null}
) : null}
@@ -976,6 +1020,9 @@ function IssueChatAssistantMessage() { feedbackTermsUrl, onVote, agentMap, + activeRunIds, + onStopRun, + stoppingRunId, } = useContext(IssueChatCtx); const message = useMessage(); const custom = message.metadata.custom as Record; @@ -988,6 +1035,7 @@ function IssueChatAssistantMessage() { const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null; const runId = typeof custom.runId === "string" ? custom.runId : null; const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; + const runStatus = typeof custom.runStatus === "string" ? custom.runStatus : null; const agentId = authorAgentId ?? runAgentId; const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined; const commentId = typeof custom.commentId === "string" ? custom.commentId : null; @@ -997,6 +1045,7 @@ function IssueChatAssistantMessage() { const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : ""; const isRunning = message.role === "assistant" && message.status?.type === "running"; const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null; + const canStopRun = canStopIssueChatRun({ runId, runStatus, activeRunIds }); const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null; const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call"); const isFoldable = !isRunning && !!chainOfThoughtLabel; @@ -1162,6 +1211,18 @@ function IssueChatAssistantMessage() { Copy message + {canStopRun && onStopRun && runId ? ( + { + void onStopRun(runId); + }} + > + + {stoppingRunId === runId ? "Stopping…" : "Stop run"} + + ) : null} {runHref ? ( @@ -1557,7 +1618,6 @@ const IssueChatComposer = forwardRef(null); const draftTimer = useRef | null>(null); + function queueViewportRestore(snapshot: ReturnType) { + if (!snapshot) return; + requestAnimationFrame(() => { + restoreComposerViewportSnapshot(snapshot, composerContainerRef.current); + }); + } + + function focusComposer() { + if (typeof composerContainerRef.current?.scrollIntoView === "function") { + composerContainerRef.current.scrollIntoView({ behavior: "smooth", block: "end" }); + } + requestAnimationFrame(() => { + window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" }); + editorRef.current?.focus(); + }); + } + useEffect(() => { if (!draftKey) return; setBody(loadDraft(draftKey)); @@ -1591,12 +1668,15 @@ const IssueChatComposer = forwardRef ({ - focus: () => { - composerContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); - requestAnimationFrame(() => { - window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" }); - editorRef.current?.focus(); - }); + focus: focusComposer, + restoreDraft: (submittedBody: string) => { + setBody((current) => + restoreSubmittedCommentDraft({ + currentBody: current, + submittedBody, + }), + ); + focusComposer(); }, }), []); @@ -1606,12 +1686,17 @@ const IssueChatComposer = forwardRef @@ -1635,6 +1721,7 @@ const IssueChatComposer = forwardRef ) : null} - - {enableReassign && reassignOptions.length > 0 ? ( (null); + const composerViewportAnchorRef = useRef(null); + const composerViewportSnapshotRef = useRef>(null); + const preserveComposerViewportRef = useRef(false); const displayLiveRuns = useMemo(() => { const deduped = new Map(); for (const run of liveRuns) { @@ -1834,14 +1917,22 @@ export function IssueChatThread({ activeRun, }); }, [activeRun, displayLiveRuns, linkedRuns]); + const activeRunIds = useMemo(() => { + const ids = new Set(); + for (const run of displayLiveRuns) { + if (run.status === "queued" || run.status === "running") { + ids.add(run.id); + } + } + return ids; + }, [displayLiveRuns]); const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs: enableLiveTranscriptPolling ? transcriptRuns : [], companyId, }); const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun; const resolvedHasOutputForRun = hasOutputForRunOverride ?? hasOutputForRun; - - const messages = useMemo( + const rawMessages = useMemo( () => buildIssueChatMessages({ comments, @@ -1872,6 +1963,18 @@ export function IssueChatThread({ currentUserId, ], ); + const stableMessagesRef = useRef([]); + const stableMessageCacheRef = useRef>(new Map()); + const messages = useMemo(() => { + const stabilized = stabilizeThreadMessages( + rawMessages, + stableMessagesRef.current, + stableMessageCacheRef.current, + ); + stableMessagesRef.current = stabilized.messages; + stableMessageCacheRef.current = stabilized.cache; + return stabilized.messages; + }, [rawMessages]); const isRunning = displayLiveRuns.some((run) => run.status === "queued" || run.status === "running"); const feedbackVoteByTargetId = useMemo(() => { @@ -1890,6 +1993,19 @@ export function IssueChatThread({ onCancel: onCancelRun, }); + useLayoutEffect(() => { + const composerElement = composerViewportAnchorRef.current; + if (preserveComposerViewportRef.current) { + restoreComposerViewportSnapshot( + composerViewportSnapshotRef.current, + composerElement, + ); + } + + composerViewportSnapshotRef.current = captureComposerViewportSnapshot(composerElement); + preserveComposerViewportRef.current = shouldPreserveComposerViewport(composerElement); + }, [messages]); + useEffect(() => { const hash = location.hash; if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return; @@ -1912,8 +2028,12 @@ export function IssueChatThread({ feedbackTermsUrl, agentMap, currentUserId, + activeRunIds, onVote, + onStopRun, + stoppingRunId, onInterruptQueued, + onCancelQueued, interruptingQueuedRunId, onImageClick, }), @@ -1923,8 +2043,12 @@ export function IssueChatThread({ feedbackTermsUrl, agentMap, currentUserId, + activeRunIds, onVote, + onStopRun, + stoppingRunId, onInterruptQueued, + onCancelQueued, interruptingQueuedRunId, onImageClick, ], @@ -1990,20 +2114,22 @@ export function IssueChatThread({ {showComposer ? ( - +
+ +
) : null}
diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index e87fa41c..03f78feb 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -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( + 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( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.includes("New Sub-issue"), + ); + expect(button).not.toBeUndefined(); + }); + + await act(async () => { + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.includes("New Sub-issue"), + ); + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(dialogState.openNewIssue).toHaveBeenCalledWith({ + parentId: "parent-1", + projectId: "project-1", + }); + + act(() => { + root.unmount(); + }); + }); + it("debounces search updates so typing does not notify the page on every keystroke", async () => { vi.useFakeTimers(); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 9dde9e08..64b37f26 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -50,9 +50,10 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; -import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, Columns3, User, Search } from "lucide-react"; +import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; import { buildIssueTree, countDescendants } from "../lib/issue-tree"; +import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import type { Issue, Project } from "@paperclipai/shared"; const ISSUE_SEARCH_DEBOUNCE_MS = 150; @@ -63,6 +64,7 @@ export type IssueViewState = IssueFilterState & { sortDir: "asc" | "desc"; groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none"; viewMode: "list" | "board"; + nestingEnabled: boolean; collapsedGroups: string[]; collapsedParents: string[]; }; @@ -73,6 +75,7 @@ const defaultViewState: IssueViewState = { sortDir: "desc", groupBy: "none", viewMode: "list", + nestingEnabled: true, collapsedGroups: [], collapsedParents: [], }; @@ -118,6 +121,7 @@ interface Agent { } type ProjectOption = Pick & Partial>; +type IssueListRequestFilters = NonNullable[1]>; interface IssuesListProps { issues: Issue[]; @@ -131,9 +135,9 @@ interface IssuesListProps { issueLinkState?: unknown; initialAssignees?: string[]; initialSearch?: string; - searchFilters?: { - participantAgentId?: string; - }; + searchFilters?: Omit; + baseCreateIssueDefaults?: Record; + createIssueLabel?: string; enableRoutineVisibilityFilter?: boolean; onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; @@ -214,6 +218,8 @@ export function IssuesList({ initialAssignees, initialSearch, searchFilters, + baseCreateIssueDefaults, + createIssueLabel, enableRoutineVisibilityFilter = false, onSearchChange, onUpdateIssue, @@ -484,8 +490,8 @@ export function IssuesList({ }, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]); const newIssueDefaults = useCallback((groupKey?: string) => { - const defaults: Record = {}; - if (projectId) defaults.projectId = projectId; + const defaults: Record = { ...(baseCreateIssueDefaults ?? {}) }; + if (projectId && defaults.projectId === undefined) defaults.projectId = projectId; if (groupKey) { if (viewState.groupBy === "status") defaults.status = groupKey; else if (viewState.groupBy === "priority") defaults.priority = groupKey; @@ -494,11 +500,19 @@ export function IssuesList({ else defaults.assigneeAgentId = groupKey; } else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") { - defaults.parentId = groupKey; + const parentIssue = issueById.get(groupKey); + if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId)); + else defaults.parentId = groupKey; } } return defaults; - }, [projectId, viewState.groupBy]); + }, [baseCreateIssueDefaults, currentUserId, issueById, projectId, viewState.groupBy]); + + const createActionLabel = createIssueLabel ? `Create ${createIssueLabel}` : "Create Issue"; + const createButtonLabel = createIssueLabel ? `New ${createIssueLabel}` : "New Issue"; + const openCreateIssueDialog = useCallback((groupKey?: string) => { + openNewIssue(newIssueDefaults(groupKey)); + }, [newIssueDefaults, openNewIssue]); const filterToWorkspace = useCallback((workspaceId: string) => { updateView({ workspaces: [workspaceId] }); @@ -530,9 +544,9 @@ export function IssuesList({ {/* Toolbar */}
-
+ {viewState.viewMode === "list" && ( + + )} + openNewIssue(newIssueDefaults())} + action={createActionLabel} + onAction={() => openCreateIssueDialog()} /> )} @@ -707,7 +734,7 @@ export function IssuesList({ variant="ghost" size="icon-xs" className="ml-auto text-muted-foreground" - onClick={() => openNewIssue(newIssueDefaults(group.key))} + onClick={() => openCreateIssueDialog(group.key)} > @@ -715,7 +742,9 @@ export function IssuesList({ )} {(() => { - const { roots, childMap } = buildIssueTree(group.items); + const { roots, childMap } = viewState.nestingEnabled + ? buildIssueTree(group.items) + : { roots: group.items, childMap: new Map() }; const renderIssueRow = (issue: Issue, depth: number) => { const children = childMap.get(issue.id) ?? []; @@ -817,15 +846,15 @@ export function IssuesList({ ) : issue.assigneeUserId ? ( - - + + {formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"} ) : ( - - + + Assignee diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index f30db991..814d83b0 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -146,6 +146,20 @@ describe("MarkdownBody", () => { expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<"); }); + it("rewrites issue scheme links to internal issue links", () => { + const html = renderMarkdown("See issue://PAP-1310 and issue://:PAP-1311.", [ + { identifier: "PAP-1310", status: "done" }, + { identifier: "PAP-1311", status: "blocked" }, + ]); + + expect(html).toContain('href="/issues/PAP-1310"'); + expect(html).toContain('href="/issues/PAP-1311"'); + expect(html).toContain(">issue://PAP-1310<"); + expect(html).toContain(">issue://:PAP-1311<"); + expect(html).toContain("text-green-600"); + expect(html).toContain("text-red-600"); + }); + it("linkifies issue identifiers inside inline code spans", () => { const html = renderMarkdown("Reference `PAP-1271` here.", [ { identifier: "PAP-1271", status: "done" }, diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 3e043155..106a394b 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { buildSkillMentionHref } from "@paperclipai/shared"; +import { buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared"; import { computeMentionMenuPosition, findClosestAutocompleteAnchor, @@ -16,6 +16,9 @@ import { const mdxEditorMockState = vi.hoisted(() => ({ emitMountEmptyReset: false, + emitMountParseError: false, + emitMountSilentEmptyState: false, + markdownValues: [] as string[], })); vi.mock("@mdxeditor/editor", async () => { @@ -36,19 +39,29 @@ vi.mock("@mdxeditor/editor", async () => { markdown, placeholder, onChange, + onError, + className, }: { markdown: string; placeholder?: string; onChange?: (value: string) => void; + onError?: (error: unknown) => void; + className?: string; }, forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>, ) { + mdxEditorMockState.markdownValues.push(markdown); const [content, setContent] = React.useState(markdown); + const editableRef = React.useRef(null); const handle = React.useMemo(() => ({ setMarkdown: (value: string) => setContent(value), - focus: () => {}, + focus: () => editableRef.current?.focus(), }), []); + React.useEffect(() => { + setContent(markdown); + }, [markdown]); + React.useEffect(() => { setForwardedRef(forwardedRef, null); const timer = window.setTimeout(() => { @@ -57,6 +70,16 @@ vi.mock("@mdxeditor/editor", async () => { setContent(""); onChange?.(""); } + if (mdxEditorMockState.emitMountSilentEmptyState) { + setContent(""); + } + if (mdxEditorMockState.emitMountParseError) { + setContent(""); + onError?.({ + error: "Unsupported markdown syntax", + source: markdown, + }); + } }, 0); return () => { window.clearTimeout(timer); @@ -64,7 +87,17 @@ vi.mock("@mdxeditor/editor", async () => { }; }, []); - return
{content || placeholder || ""}
; + return ( +
+ {content || placeholder || ""} +
+ ); }); return { @@ -105,16 +138,33 @@ async function flush() { describe("MarkdownEditor", () => { let container: HTMLDivElement; + let originalRangeRect: typeof Range.prototype.getBoundingClientRect; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); + originalRangeRect = Range.prototype.getBoundingClientRect; + Range.prototype.getBoundingClientRect = () => ({ + x: 32, + y: 24, + width: 12, + height: 18, + top: 24, + right: 44, + bottom: 42, + left: 32, + toJSON: () => ({}), + }); }); afterEach(() => { container.remove(); + Range.prototype.getBoundingClientRect = originalRangeRect; vi.clearAllMocks(); mdxEditorMockState.emitMountEmptyReset = false; + mdxEditorMockState.emitMountParseError = false; + mdxEditorMockState.emitMountSilentEmptyState = false; + mdxEditorMockState.markdownValues = []; }); it("applies async external value updates once the editor ref becomes ready", async () => { @@ -172,6 +222,94 @@ describe("MarkdownEditor", () => { }); }); + it("converts advisory-style html image tags to markdown image syntax before mounting the editor", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + \n\nAfter`} + onChange={() => {}} + placeholder="Markdown body" + />, + ); + }); + + await flush(); + expect(mdxEditorMockState.markdownValues.at(-1)).toContain("![image](https://example.com/test.png)"); + expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain(" { + 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( + , + ); + }); + + 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( + , + ); + }); + + await flush(); + + await vi.waitFor(() => { + expect(container.querySelector("textarea")).not.toBeNull(); + }); + + const textarea = container.querySelector("textarea"); + expect(textarea).not.toBeNull(); + expect(textarea?.value).toBe("Affected versions: <= v0.3.1"); + expect(container.textContent).toContain("Rich editor unavailable for this markdown"); + expect(handleChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); + it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => { expect( computeMentionMenuPosition( @@ -312,4 +450,64 @@ describe("MarkdownEditor", () => { editable.remove(); }); + + it("accepts mention selection from touchstart taps", async () => { + const handleChange = vi.fn(); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + }); + + 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(); + }); + }); }); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 00e9dfc6..73b7951f 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -8,6 +8,9 @@ import { useRef, useState, type DragEvent, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type TouchEvent as ReactTouchEvent, } from "react"; import { createPortal } from "react-dom"; import { @@ -75,10 +78,76 @@ export interface MarkdownEditorRef { focus: () => void; } +function readHtmlAttribute(attrs: string, name: string): string | null { + const match = new RegExp(`${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attrs); + return match?.[2] ?? match?.[3] ?? match?.[4] ?? null; +} + +function convertHtmlImagesToMarkdown(text: string): string { + return text.replace(/]*?)\/?>/gi, (tag, attrs: string) => { + const src = readHtmlAttribute(attrs, "src"); + if (!src) return tag; + const alt = readHtmlAttribute(attrs, "alt") ?? "image"; + const title = readHtmlAttribute(attrs, "title"); + const escapedAlt = alt.replace(/[[\]]/g, "\\$&"); + const escapedTitle = title?.replace(/"/g, '\\"'); + return escapedTitle + ? `![${escapedAlt}](${src} "${escapedTitle}")` + : `![${escapedAlt}](${src})`; + }); +} + +function prepareMarkdownForEditor(value: string): string { + const normalizedLineEndings = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + return convertHtmlImagesToMarkdown(normalizedLineEndings); +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function hasMeaningfulEditorContent(node: Node | null): boolean { + if (!node) return false; + if (node.nodeType === Node.TEXT_NODE) { + return (node.textContent ?? "").trim().length > 0; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return false; + } + + const element = node as HTMLElement; + if (["IMG", "HR", "TABLE", "VIDEO", "IFRAME"].includes(element.tagName)) { + return true; + } + + return Array.from(element.childNodes).some((child) => hasMeaningfulEditorContent(child)); +} + +function isRichEditorDomEmpty( + editable: HTMLElement, + expectedValue: string, + placeholder?: string, +): boolean { + const expectedText = expectedValue.trim(); + if (!expectedText) return false; + + const visibleText = (editable.textContent ?? "").trim(); + if (visibleText.length === 0) { + return !Array.from(editable.childNodes).some((child) => hasMeaningfulEditorContent(child)); + } + + const normalizedPlaceholder = placeholder?.trim(); + if ( + normalizedPlaceholder + && visibleText === normalizedPlaceholder + && expectedText !== normalizedPlaceholder + ) { + return true; + } + + return false; +} + function isSafeMarkdownLinkUrl(url: string): boolean { const trimmed = url.trim(); if (!trimmed) return true; @@ -417,12 +486,14 @@ export const MarkdownEditor = forwardRef mentions, onSubmit, }: MarkdownEditorProps, forwardedRef) { + const editorValue = useMemo(() => prepareMarkdownForEditor(value), [value]); const { slashCommands } = useEditorAutocomplete(); const containerRef = useRef(null); const ref = useRef(null); - const valueRef = useRef(value); - valueRef.current = value; - const latestValueRef = useRef(value); + const fallbackTextareaRef = useRef(null); + const valueRef = useRef(editorValue); + valueRef.current = editorValue; + const latestValueRef = useRef(editorValue); const initialChildOnChangeRef = useRef(true); /** * After imperative `setMarkdown` (prop sync, mentions, image upload), MDXEditor may emit `onChange` @@ -432,6 +503,7 @@ export const MarkdownEditor = forwardRef const echoIgnoreMarkdownRef = useRef(null); const [uploadError, setUploadError] = useState(null); const [isDragOver, setIsDragOver] = useState(false); + const [richEditorError, setRichEditorError] = useState(null); const dragDepthRef = useRef(0); // Stable ref for imageUploadHandler so plugins don't recreate on every render @@ -443,6 +515,7 @@ export const MarkdownEditor = forwardRef const mentionStateRef = useRef(null); const [mentionIndex, setMentionIndex] = useState(0); const skillEnterArmedRef = useRef(false); + const autocompleteSelectionHandledRef = useRef(false); const mentionActive = mentionState !== null && ( (mentionState.trigger === "mention" && Boolean(mentions?.length)) || (mentionState.trigger === "skill" && slashCommands.length > 0) @@ -491,9 +564,59 @@ export const MarkdownEditor = forwardRef useImperativeHandle(forwardedRef, () => ({ focus: () => { + if (richEditorError) { + fallbackTextareaRef.current?.focus(); + return; + } ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); }, - }), []); + }), [richEditorError]); + + const autoSizeFallbackTextarea = useCallback((element: HTMLTextAreaElement | null) => { + if (!element) return; + element.style.height = "auto"; + element.style.height = `${element.scrollHeight}px`; + }, []); + + useEffect(() => { + if (!richEditorError) return; + autoSizeFallbackTextarea(fallbackTextareaRef.current); + }, [autoSizeFallbackTextarea, richEditorError, value]); + + useEffect(() => { + if (richEditorError || editorValue.trim().length === 0) return; + const container = containerRef.current; + if (!container) return; + + let timeoutId = 0; + const scheduleCheck = () => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + const editable = container.querySelector('[contenteditable="true"]'); + if (!(editable instanceof HTMLElement)) return; + const activeElement = document.activeElement; + if (activeElement === editable || editable.contains(activeElement)) return; + if (isRichEditorDomEmpty(editable, editorValue, placeholder)) { + setRichEditorError("Rich editor failed to load content"); + } + }, 0); + }; + + scheduleCheck(); + const observer = new MutationObserver(() => { + scheduleCheck(); + }); + observer.observe(container, { + subtree: true, + childList: true, + characterData: true, + }); + + return () => { + window.clearTimeout(timeoutId); + observer.disconnect(); + }; + }, [editorValue, placeholder, richEditorError]); // Whether the image plugin should be included (boolean is stable across renders // as long as the handler presence doesn't toggle) @@ -558,15 +681,15 @@ export const MarkdownEditor = forwardRef }, [hasImageUpload]); useEffect(() => { - if (value !== latestValueRef.current) { + if (editorValue !== latestValueRef.current) { if (ref.current) { // Pair with onChange echo suppression (echoIgnoreMarkdownRef). - echoIgnoreMarkdownRef.current = value; - ref.current.setMarkdown(value); - latestValueRef.current = value; + echoIgnoreMarkdownRef.current = editorValue; + ref.current.setMarkdown(editorValue); + latestValueRef.current = editorValue; } } - }, [value]); + }, [editorValue]); const decorateProjectMentions = useCallback(() => { const editable = containerRef.current?.querySelector('[contenteditable="true"]'); @@ -676,6 +799,11 @@ export const MarkdownEditor = forwardRef }; }, [checkMention, mentionActive]); + useEffect(() => { + if (mentionActive) return; + autocompleteSelectionHandledRef.current = false; + }, [mentionActive]); + useEffect(() => { const editable = containerRef.current?.querySelector('[contenteditable="true"]'); if (!editable) return; @@ -696,7 +824,7 @@ export const MarkdownEditor = forwardRef // Read from ref to avoid stale-closure issues (selectionchange can // update state between the last render and this callback firing). const state = mentionStateRef.current; - if (!state) return; + if (!state) return false; const current = latestValueRef.current; const next = applyMention(current, state, option); if (next !== current) { @@ -729,10 +857,24 @@ export const MarkdownEditor = forwardRef mentionStateRef.current = null; skillEnterArmedRef.current = false; setMentionState(null); + return true; }, [decorateProjectMentions, onChange], ); + const handleAutocompletePress = useCallback(( + event: ReactMouseEvent | ReactPointerEvent | ReactTouchEvent, + option: AutocompleteOption, + ) => { + event.preventDefault(); + event.stopPropagation(); + if (autocompleteSelectionHandledRef.current) return; + const handled = selectMention(option); + if (handled) { + autocompleteSelectionHandledRef.current = true; + } + }, [selectMention]); + function hasFilePayload(evt: DragEvent) { return Array.from(evt.dataTransfer?.types ?? []).includes("Files"); } @@ -761,6 +903,52 @@ export const MarkdownEditor = forwardRef ) : null; + if (richEditorError) { + return ( +
+
+

Rich editor unavailable for this markdown. Showing raw source instead.

+ +
+