From c1bb9385195a0cb57d18cbbde44daa31b1149688 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 10:53:28 -0500 Subject: [PATCH 01/42] Auto-checkout scoped issue wakes in the harness --- packages/adapter-utils/src/server-utils.ts | 14 ++++ .../src/__tests__/codex-local-execute.test.ts | 10 ++- .../heartbeat-comment-wake-batching.test.ts | 21 ++++- server/src/onboarding-assets/ceo/HEARTBEAT.md | 3 +- server/src/services/heartbeat.ts | 84 ++++++++++++++----- 5 files changed, 107 insertions(+), 25 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 3c0e0db7217..ba44249fbd0 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -253,6 +253,7 @@ type PaperclipWakeComment = { type PaperclipWakePayload = { reason: string | null; issue: PaperclipWakeIssue | null; + checkedOutByHarness: boolean; executionStage: PaperclipWakeExecutionStage | null; commentIds: string[]; latestCommentId: string | null; @@ -363,6 +364,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl return { reason: asString(payload.reason, "").trim() || null, issue: normalizePaperclipWakeIssue(payload.issue), + checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false), executionStage, commentIds, latestCommentId: asString(payload.latestCommentId, "").trim() || null, @@ -432,6 +434,9 @@ export function renderPaperclipWakePrompt( if (normalized.issue?.priority) { lines.push(`- issue priority: ${normalized.issue.priority}`); } + if (normalized.checkedOutByHarness) { + lines.push("- checkout: already claimed by the harness for this run"); + } if (normalized.missingCount > 0) { lines.push(`- omitted comments: ${normalized.missingCount}`); } @@ -465,6 +470,15 @@ export function renderPaperclipWakePrompt( } } + if (normalized.checkedOutByHarness) { + lines.push( + "", + "The harness already checked out this issue for the current run.", + "Do not call `/api/issues/{id}/checkout` again unless you intentionally switch to a different task.", + "", + ); + } + if (normalized.comments.length > 0) { lines.push("New comments in order:"); } diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 9514a977420..a4e77f9b7cc 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -568,9 +568,10 @@ describe("codex execute", () => { id: "issue-1", identifier: "PAP-1201", title: "Fix gallery opening for inline images", - status: "todo", + status: "in_progress", priority: "medium", }, + checkedOutByHarness: true, commentIds: [], latestCommentId: null, comments: [], @@ -598,16 +599,19 @@ describe("codex execute", () => { issue: { identifier: "PAP-1201", title: "Fix gallery opening for inline images", - status: "todo", + status: "in_progress", priority: "medium", }, + checkedOutByHarness: true, commentIds: [], }); expect(capture.prompt).toContain("## Paperclip Wake Payload"); expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake."); expect(capture.prompt).toContain("- issue: PAP-1201 Fix gallery opening for inline images"); expect(capture.prompt).toContain("- pending comments: 0/0"); - expect(capture.prompt).toContain("- issue status: todo"); + expect(capture.prompt).toContain("- issue status: in_progress"); + expect(capture.prompt).toContain("- checkout: already claimed by the harness for this run"); + expect(capture.prompt).toContain("The harness already checked out this issue for the current run."); } finally { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 1a4d2f9661d..2ed58443611 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -496,15 +496,34 @@ describe("heartbeat comment wake batching", () => { id: issueId, identifier: `${issuePrefix}-1`, title: "Require a comment", - status: "todo", + status: "in_progress", priority: "medium", }, + checkedOutByHarness: true, commentIds: [], }, }); expect(String(firstPayload.message ?? "")).toContain("## Paperclip Wake Payload"); expect(String(firstPayload.message ?? "")).toContain("Do not switch to another issue until you have handled this wake."); + expect(String(firstPayload.message ?? "")).toContain("- checkout: already claimed by the harness for this run"); + expect(String(firstPayload.message ?? "")).toContain( + "The harness already checked out this issue for the current run.", + ); expect(String(firstPayload.message ?? "")).toContain(`${issuePrefix}-1 Require a comment`); + const checkedOutIssue = await db + .select({ + status: issues.status, + checkoutRunId: issues.checkoutRunId, + executionRunId: issues.executionRunId, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(checkedOutIssue).toMatchObject({ + status: "in_progress", + checkoutRunId: firstRun?.id, + executionRunId: firstRun?.id, + }); gateway.releaseFirstWait(); await waitFor(async () => { const runs = await db diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index 8773823d216..7d297594d17 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -31,7 +31,8 @@ If `PAPERCLIP_APPROVAL_ID` is set: ## 5. Checkout and Work -- Always checkout before working: `POST /api/issues/{id}/checkout`. +- For scoped issue wakes, Paperclip may already checkout the current issue in the harness before your run starts. +- Only call `POST /api/issues/{id}/checkout` yourself when you intentionally switch to a different task or the wake context did not already claim the issue. - Never retry a 409 -- that task belongs to someone else. - Do the work. Update status and comment when done. diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 20cca465fed..6fbcfe1036f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -74,6 +74,7 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const WAKE_COMMENT_IDS_KEY = "wakeCommentIds"; const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake"; +const PAPERCLIP_HARNESS_CHECKOUT_KEY = "paperclipHarnessCheckedOut"; const DETACHED_PROCESS_ERROR_CODE = "process_detached"; const startLocksByAgent = new Map>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; @@ -760,6 +761,32 @@ function describeSessionResetReason( return null; } +function shouldAutoCheckoutIssueForWake(input: { + contextSnapshot: Record | null | undefined; + issueStatus: string | null; + issueAssigneeAgentId: string | null; + agentId: string; +}) { + if (input.issueAssigneeAgentId !== input.agentId) return false; + + const issueStatus = readNonEmptyString(input.issueStatus); + if ( + issueStatus !== "todo" && + issueStatus !== "backlog" && + issueStatus !== "blocked" && + issueStatus !== "in_progress" + ) { + return false; + } + + const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason); + if (!wakeReason) return false; + if (wakeReason === "issue_comment_mentioned") return false; + if (wakeReason.startsWith("execution_")) return false; + + return true; +} + function deriveCommentId( contextSnapshot: Record | null | undefined, payload: Record | null | undefined, @@ -1005,6 +1032,7 @@ async function buildPaperclipWakePayload(input: { priority: issueSummary.priority, } : null, + checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true, executionStage: Object.keys(executionStage).length > 0 ? executionStage : null, commentIds, latestCommentId: commentIds[commentIds.length - 1] ?? null, @@ -1216,6 +1244,27 @@ export function heartbeatService(db: Db) { .then((rows) => rows[0] ?? null); } + async function getIssueExecutionContext(companyId: string, issueId: string) { + return db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspacePreference: issues.executionWorkspacePreference, + assigneeAgentId: issues.assigneeAgentId, + assigneeAdapterOverrides: issues.assigneeAdapterOverrides, + executionWorkspaceSettings: issues.executionWorkspaceSettings, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + } + async function getRuntimeState(agentId: string) { return db .select() @@ -2644,26 +2693,21 @@ export function heartbeatService(db: Db) { const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null); const sessionCodec = getAdapterSessionCodec(agent.adapterType); const issueId = readNonEmptyString(context.issueId); - const issueContext = issueId - ? await db - .select({ - id: issues.id, - identifier: issues.identifier, - title: issues.title, - status: issues.status, - priority: issues.priority, - projectId: issues.projectId, - projectWorkspaceId: issues.projectWorkspaceId, - executionWorkspaceId: issues.executionWorkspaceId, - executionWorkspacePreference: issues.executionWorkspacePreference, - assigneeAgentId: issues.assigneeAgentId, - assigneeAdapterOverrides: issues.assigneeAdapterOverrides, - executionWorkspaceSettings: issues.executionWorkspaceSettings, - }) - .from(issues) - .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) - .then((rows) => rows[0] ?? null) - : null; + let issueContext = issueId ? await getIssueExecutionContext(agent.companyId, issueId) : null; + if ( + issueId && + issueContext && + shouldAutoCheckoutIssueForWake({ + contextSnapshot: context, + issueStatus: issueContext.status, + issueAssigneeAgentId: issueContext.assigneeAgentId, + agentId: agent.id, + }) + ) { + await issuesSvc.checkout(issueId, agent.id, ["todo", "backlog", "blocked"], run.id); + context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = true; + issueContext = await getIssueExecutionContext(agent.companyId, issueId); + } const issueAssigneeOverrides = issueContext && issueContext.assigneeAgentId === agent.id ? parseIssueAssigneeAdapterOverrides( From 2172476e84234e8a4772d3416b0d19b89c96d513 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 20:34:06 -0500 Subject: [PATCH 02/42] Fix linked worktree reuse for execution workspaces --- .../src/__tests__/workspace-runtime.test.ts | 93 +++++++++ server/src/services/workspace-runtime.ts | 197 ++++++++++++------ 2 files changed, 232 insertions(+), 58 deletions(-) diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index ff492ad2fe6..e1908c5f40b 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -367,6 +367,99 @@ describe("realizeExecutionWorkspace", () => { expect(second.branchName).toBe(first.branchName); }); + it("reuses the current linked worktree instead of nesting another worktree inside it", async () => { + const repoRoot = await createTempRepo(); + const branchName = "PAP-1355-worktree-reuse"; + const currentWorktree = path.join(repoRoot, ".paperclip", "worktrees", branchName); + + await fs.mkdir(path.dirname(currentWorktree), { recursive: true }); + await execFileAsync("git", ["worktree", "add", "-b", branchName, currentWorktree, "HEAD"], { cwd: repoRoot }); + + const realized = await realizeExecutionWorkspace({ + base: { + baseCwd: currentWorktree, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-1355", + title: "worktree reuse", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + const expectedWorktreePath = await fs.realpath(currentWorktree); + expect(realized.created).toBe(false); + await expect(fs.realpath(realized.cwd)).resolves.toBe(expectedWorktreePath); + await expect(fs.realpath(realized.worktreePath ?? "")).resolves.toBe(expectedWorktreePath); + }); + + it("reuses an already checked out branch from git worktree metadata even when the target path differs", async () => { + const repoRoot = await createTempRepo(); + const branchName = "PAP-1355-worktree-reuse"; + const existingWorktree = path.join(repoRoot, ".paperclip", "worktrees", branchName); + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); + + await fs.mkdir(path.dirname(existingWorktree), { recursive: true }); + await execFileAsync("git", ["worktree", "add", "-b", branchName, existingWorktree, "HEAD"], { cwd: repoRoot }); + + const realized = await realizeExecutionWorkspace({ + base: { + baseCwd: existingWorktree, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + worktreeParentDir: ".paperclip/other-worktrees", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-1355", + title: "worktree reuse", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + recorder, + }); + + const expectedWorktreePath = await fs.realpath(existingWorktree); + expect(realized.created).toBe(false); + await expect(fs.realpath(realized.cwd)).resolves.toBe(expectedWorktreePath); + expect(operations).toHaveLength(1); + expect(operations[0]?.phase).toBe("worktree_prepare"); + expect(operations[0]?.command).toBeNull(); + expect(operations[0]?.metadata).toMatchObject({ + branchName, + created: false, + reused: true, + worktreePath: expectedWorktreePath, + }); + }); + it("slugifies unsafe issue titles for branch names and worktree folders", async () => { const repoRoot = await createTempRepo(); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index c0447bcc3fb..846af64f340 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -513,6 +513,67 @@ function gitErrorIncludes(error: unknown, needle: string) { return message.toLowerCase().includes(needle.toLowerCase()); } +type GitWorktreeListEntry = { + worktree: string; + branch: string | null; +}; + +function parseGitWorktreeListPorcelain(raw: string): GitWorktreeListEntry[] { + const entries: GitWorktreeListEntry[] = []; + let current: Partial = {}; + + for (const line of raw.split(/\r?\n/)) { + if (line.startsWith("worktree ")) { + current = { worktree: line.slice("worktree ".length) }; + continue; + } + if (line.startsWith("branch ")) { + current.branch = line.slice("branch ".length); + continue; + } + if (line === "" && current.worktree) { + entries.push({ + worktree: current.worktree, + branch: current.branch ?? null, + }); + current = {}; + } + } + + if (current.worktree) { + entries.push({ + worktree: current.worktree, + branch: current.branch ?? null, + }); + } + + return entries; +} + +async function resolveGitOwnerRepoRoot(cwd: string): Promise { + const checkoutRoot = path.resolve(await runGit(["rev-parse", "--show-toplevel"], cwd)); + const commonDir = await runGit(["rev-parse", "--git-common-dir"], checkoutRoot).catch(() => null); + if (!commonDir) return checkoutRoot; + return path.dirname(path.resolve(checkoutRoot, commonDir)); +} + +async function findRegisteredGitWorktreeByBranch(repoRoot: string, branchName: string): Promise { + const raw = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => null); + if (!raw) return null; + + const expectedBranchRef = `refs/heads/${branchName}`; + for (const entry of parseGitWorktreeListPorcelain(raw)) { + if (entry.branch !== expectedBranchRef) continue; + return path.resolve(entry.worktree); + } + + return null; +} + +async function isGitCheckout(cwd: string): Promise { + return Boolean(await runGit(["rev-parse", "--git-dir"], cwd).catch(() => null)); +} + async function detectDefaultBranch(repoRoot: string): Promise { // Try the explicit remote HEAD first (set by git clone or git remote set-head) try { @@ -878,7 +939,7 @@ export async function realizeExecutionWorkspace(input: { }; } - const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd); + const repoRoot = await resolveGitOwnerRepoRoot(input.base.baseCwd); const branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}"); const renderedBranch = renderWorkspaceTemplate(branchTemplate, { issue: input.issue, @@ -901,50 +962,59 @@ export async function realizeExecutionWorkspace(input: { await fs.mkdir(worktreeParentDir, { recursive: true }); - const existingWorktree = await directoryExists(worktreePath); - if (existingWorktree) { - const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null); - if (existingGitDir) { - if (input.recorder) { - await input.recorder.recordOperation({ - phase: "worktree_prepare", - cwd: repoRoot, - metadata: { - repoRoot, - worktreePath, - branchName, - baseRef, - created: false, - reused: true, - }, - run: async () => ({ - status: "succeeded", - exitCode: 0, - system: `Reused existing git worktree at ${worktreePath}\n`, - }), - }); - } - await provisionExecutionWorktree({ - strategy: rawStrategy, - base: input.base, - repoRoot, - worktreePath, - branchName, - issue: input.issue, - agent: input.agent, - created: false, - recorder: input.recorder ?? null, + async function reuseExistingWorktree(reusablePath: string) { + if (input.recorder) { + await input.recorder.recordOperation({ + phase: "worktree_prepare", + cwd: repoRoot, + metadata: { + repoRoot, + worktreePath: reusablePath, + branchName, + baseRef, + created: false, + reused: true, + }, + run: async () => ({ + status: "succeeded", + exitCode: 0, + system: `Reused existing git worktree at ${reusablePath}\n`, + }), }); - return { - ...input.base, - strategy: "git_worktree", - cwd: worktreePath, - branchName, - worktreePath, - warnings: [], - created: false, - }; } + await provisionExecutionWorktree({ + strategy: rawStrategy, + base: input.base, + repoRoot, + worktreePath: reusablePath, + branchName, + issue: input.issue, + agent: input.agent, + created: false, + recorder: input.recorder ?? null, + }); + return { + ...input.base, + strategy: "git_worktree" as const, + cwd: reusablePath, + branchName, + worktreePath: reusablePath, + warnings: [], + created: false, + }; + } + + const existingWorktree = await directoryExists(worktreePath); + if (existingWorktree && await isGitCheckout(worktreePath)) { + return await reuseExistingWorktree(worktreePath); + } + + const registeredBranchWorktree = await findRegisteredGitWorktreeByBranch(repoRoot, branchName); + if (registeredBranchWorktree && await isGitCheckout(registeredBranchWorktree)) { + return await reuseExistingWorktree(registeredBranchWorktree); + } + + if (existingWorktree) { throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a git worktree.`); } @@ -967,21 +1037,32 @@ export async function realizeExecutionWorkspace(input: { if (!gitErrorIncludes(error, "already exists")) { throw error; } - await recordGitOperation(input.recorder, { - phase: "worktree_prepare", - args: ["worktree", "add", worktreePath, branchName], - cwd: repoRoot, - metadata: { - repoRoot, - worktreePath, - branchName, - baseRef, - created: false, - reusedExistingBranch: true, - }, - successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`, - failureLabel: `git worktree add ${worktreePath}`, - }); + try { + await recordGitOperation(input.recorder, { + phase: "worktree_prepare", + args: ["worktree", "add", worktreePath, branchName], + cwd: repoRoot, + metadata: { + repoRoot, + worktreePath, + branchName, + baseRef, + created: false, + reusedExistingBranch: true, + }, + successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`, + failureLabel: `git worktree add ${worktreePath}`, + }); + } catch (attachError) { + if (!gitErrorIncludes(attachError, "already checked out")) { + throw attachError; + } + const reusablePath = await findRegisteredGitWorktreeByBranch(repoRoot, branchName); + if (!reusablePath || !await isGitCheckout(reusablePath)) { + throw attachError; + } + return await reuseExistingWorktree(reusablePath); + } } await provisionExecutionWorktree({ strategy: rawStrategy, From ab5eeca94e6ae1be98cc67b08a2784e9be0640c2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 20:41:31 -0500 Subject: [PATCH 03/42] Fix stale issue live-run state --- ui/src/lib/issueActiveRun.test.ts | 45 +++++++++++++++++++++++++++++++ ui/src/lib/issueActiveRun.ts | 15 +++++++++++ ui/src/pages/IssueDetail.tsx | 7 +++-- 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 ui/src/lib/issueActiveRun.test.ts create mode 100644 ui/src/lib/issueActiveRun.ts diff --git a/ui/src/lib/issueActiveRun.test.ts b/ui/src/lib/issueActiveRun.test.ts new file mode 100644 index 00000000000..78b961185b6 --- /dev/null +++ b/ui/src/lib/issueActiveRun.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import type { ActiveRunForIssue } from "../api/heartbeats"; +import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "./issueActiveRun"; + +describe("issueActiveRun", () => { + const makeIssue = ( + overrides: Partial>, + ): Pick => ({ + status: "todo", + executionRunId: null, + ...overrides, + }); + + it("tracks active runs while an issue is still in progress", () => { + expect(shouldTrackIssueActiveRun(makeIssue({ status: "in_progress" }))).toBe(true); + }); + + it("tracks active runs while an execution run id is still attached", () => { + expect(shouldTrackIssueActiveRun(makeIssue({ status: "done", executionRunId: "run-123" }))).toBe(true); + }); + + it("drops stale cached active runs once the issue is closed and unlocked", () => { + const staleActiveRun: ActiveRunForIssue = { + id: "run-123", + status: "running", + invocationSource: "assignment", + triggerDetail: "system", + startedAt: "2026-04-13T01:29:00.000Z", + finishedAt: null, + createdAt: "2026-04-13T01:29:00.000Z", + agentId: "agent-1", + agentName: "Builder", + adapterType: "codex_local", + issueId: "issue-1", + }; + + expect( + resolveIssueActiveRun( + makeIssue({ status: "done" }), + staleActiveRun, + ), + ).toBeNull(); + }); +}); diff --git a/ui/src/lib/issueActiveRun.ts b/ui/src/lib/issueActiveRun.ts new file mode 100644 index 00000000000..48b8d5c3be9 --- /dev/null +++ b/ui/src/lib/issueActiveRun.ts @@ -0,0 +1,15 @@ +import type { Issue } from "@paperclipai/shared"; +import type { ActiveRunForIssue } from "../api/heartbeats"; + +export function shouldTrackIssueActiveRun( + issue: Pick | null | undefined, +): boolean { + return Boolean(issue && (issue.status === "in_progress" || issue.executionRunId)); +} + +export function resolveIssueActiveRun( + issue: Pick | null | undefined, + activeRun: ActiveRunForIssue | null | undefined, +): ActiveRunForIssue | null { + return shouldTrackIssueActiveRun(issue) ? (activeRun ?? null) : null; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index fb3159799fb..3fbc72f9f83 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -26,6 +26,7 @@ import { readIssueDetailHeaderSeed, rememberIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; +import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun"; import { hasBlockingShortcutDialog, resolveIssueDetailGoKeyAction, @@ -471,13 +472,15 @@ export function IssueDetail() { placeholderData: keepPreviousData, }); - const { data: activeRun, isLoading: activeRunLoading } = useQuery({ + const shouldPollActiveRun = shouldTrackIssueActiveRun(issue); + const { data: rawActiveRun, isLoading: activeRunLoading } = useQuery({ queryKey: queryKeys.issues.activeRun(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), - enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"), + enabled: !!issueId && shouldPollActiveRun, refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000, placeholderData: keepPreviousData, }); + const activeRun = resolveIssueActiveRun(issue, rawActiveRun); const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; const runningIssueRun = useMemo( From be82a912b2f82af270c5c0a499edb1025dada953 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 20:43:50 -0500 Subject: [PATCH 04/42] Fix signoff e2e for auto-checked out issues --- tests/e2e/signoff-policy.spec.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/e2e/signoff-policy.spec.ts b/tests/e2e/signoff-policy.spec.ts index 934eeaf9f15..a2cde2c385c 100644 --- a/tests/e2e/signoff-policy.spec.ts +++ b/tests/e2e/signoff-policy.spec.ts @@ -42,6 +42,12 @@ interface TestContext { issueIds: string[]; } +interface IssueRunLockState { + assigneeAgentId: string | null; + checkoutRunId: string | null; + executionRunId: string | null; +} + /** Create an authenticated APIRequestContext for an agent (token set, no run ID yet). */ async function createAgentRequest(token: string): Promise { return pwRequest.newContext({ @@ -58,6 +64,17 @@ async function invokeHeartbeat(board: APIRequestContext, agentId: string): Promi return run.id; } +async function getIssueRunLockState(board: APIRequestContext, issueId: string): Promise { + const res = await board.get(`${BASE_URL}/api/issues/${issueId}`); + expect(res.ok()).toBe(true); + const issue = await res.json(); + return { + assigneeAgentId: issue.assigneeAgentId ?? null, + checkoutRunId: issue.checkoutRunId ?? null, + executionRunId: issue.executionRunId ?? null, + }; +} + /** PATCH an issue as an agent with a fresh heartbeat run ID. */ async function agentPatch( board: APIRequestContext, @@ -88,6 +105,17 @@ async function agentCheckoutAndPatch( data: { agentId: agent.agentId, expectedStatuses }, }); if (!checkoutRes.ok()) { + if (checkoutRes.status() === 409) { + const issueRunLock = await getIssueRunLockState(board, issueId); + const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId; + const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, { + headers: { "X-Paperclip-Run-Id": lockedRunId ?? runId }, + data: patchData, + }); + if (res.ok() && issueRunLock.assigneeAgentId === agent.agentId) { + return res; + } + } // If agent checkout fails (e.g. run expired), fall back to board checkout // then PATCH with the agent's identity const boardCheckout = await board.post(`${BASE_URL}/api/issues/${issueId}/checkout`, { From 8e82ac7e38483e93b633458521fdf1ce425ac69a Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 20:57:31 -0500 Subject: [PATCH 05/42] Handle harness checkout conflicts gracefully --- .../heartbeat-comment-wake-batching.test.ts | 1 + server/src/services/heartbeat.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 2ed58443611..3626c2f5ff3 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -574,4 +574,5 @@ describe("heartbeat comment wake batching", () => { await gateway.close(); } }, 20_000); + }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6fbcfe1036f..e0e0a317cec 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -17,7 +17,7 @@ import { projects, projectWorkspaces, } from "@paperclipai/db"; -import { conflict, notFound } from "../errors.js"; +import { conflict, HttpError, notFound } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { publishLiveEvent } from "./live-events.js"; import { getRunLogStore, type RunLogHandle } from "./run-log-store.js"; @@ -787,6 +787,10 @@ function shouldAutoCheckoutIssueForWake(input: { return true; } +function isCheckoutConflictError(error: unknown): boolean { + return error instanceof HttpError && error.status === 409 && error.message === "Issue checkout conflict"; +} + function deriveCommentId( contextSnapshot: Record | null | undefined, payload: Record | null | undefined, @@ -2704,8 +2708,13 @@ export function heartbeatService(db: Db) { agentId: agent.id, }) ) { - await issuesSvc.checkout(issueId, agent.id, ["todo", "backlog", "blocked"], run.id); - context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = true; + try { + await issuesSvc.checkout(issueId, agent.id, ["todo", "backlog", "blocked"], run.id); + context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = true; + } catch (error) { + if (!isCheckoutConflictError(error)) throw error; + context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = false; + } issueContext = await getIssueExecutionContext(agent.companyId, issueId); } const issueAssigneeOverrides = From 1729e41179152a8077e675c38a1fc822b06f8331 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 11:05:32 -0500 Subject: [PATCH 06/42] Speed up issue-to-issue navigation --- ui/src/components/IssueLinkQuicklook.tsx | 83 ++++++++++++---- ui/src/components/IssueRow.test.tsx | 24 ++++- ui/src/components/IssueRow.tsx | 1 + ui/src/lib/issueDetailCache.test.ts | 116 +++++++++++++++++++++++ ui/src/lib/issueDetailCache.ts | 103 ++++++++++++++++++++ ui/src/lib/router.tsx | 17 +++- ui/src/pages/Inbox.tsx | 3 + ui/src/pages/IssueDetail.tsx | 32 +++++-- 8 files changed, 347 insertions(+), 32 deletions(-) create mode 100644 ui/src/lib/issueDetailCache.test.ts create mode 100644 ui/src/lib/issueDetailCache.ts diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index 4bb0048f98d..089b236fe1a 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -2,11 +2,11 @@ import * as React from "react"; import { useMemo, useState } from "react"; import * as RouterDom from "react-router-dom"; import type { Issue } from "@paperclipai/shared"; -import { useQuery } from "@tanstack/react-query"; -import { issuesApi } from "@/api/issues"; -import { queryKeys } from "@/lib/queryKeys"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { timeAgo } from "@/lib/timeAgo"; import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb"; +import { fetchIssueDetail, getCachedIssueDetail, prefetchIssueDetail } from "@/lib/issueDetailCache"; +import { queryKeys } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { StatusIcon } from "@/components/StatusIcon"; @@ -67,47 +67,92 @@ export function IssueQuicklookCard({ export const IssueLinkQuicklook = React.forwardRef< HTMLAnchorElement, - React.ComponentProps & { issuePathId: string } + React.ComponentProps & { + issuePathId: string; + disableIssueQuicklook?: boolean; + issuePrefetch?: Issue | null; + } >(function IssueLinkQuicklookImpl( { issuePathId, to, children, className, + state, + disableIssueQuicklook = false, + issuePrefetch = null, onClick, + onClickCapture, + onMouseEnter, + onFocus, + onTouchStart, ...props }, ref, ) { + const queryClient = useQueryClient(); const [open, setOpen] = useState(false); + const prefetchedState = issuePrefetch ? withIssueDetailHeaderSeed(state, issuePrefetch) : state; + const cachedIssue = getCachedIssueDetail(queryClient, issuePathId, issuePrefetch ?? undefined); const { data, isLoading } = useQuery({ queryKey: queryKeys.issues.detail(issuePathId), - queryFn: () => issuesApi.get(issuePathId), + queryFn: () => fetchIssueDetail(queryClient, issuePathId), enabled: open, + initialData: () => cachedIssue, staleTime: 60_000, }); const detailPath = createIssueDetailPath(issuePathId); + const handlePrefetch = React.useCallback(() => { + void prefetchIssueDetail(queryClient, issuePathId, { issue: issuePrefetch }); + }, [issuePathId, issuePrefetch, queryClient]); + const link = ( + { + handlePrefetch(); + onMouseEnter?.(event); + }} + onFocus={(event) => { + handlePrefetch(); + onFocus?.(event); + }} + onTouchStart={(event) => { + handlePrefetch(); + onTouchStart?.(event); + }} + onClickCapture={(event) => { + handlePrefetch(); + onClickCapture?.(event); + }} + onClick={(event) => { + setOpen(false); + onClick?.(event); + }} + {...props} + > + {children} + + ); + + if (disableIssueQuicklook) { + return link; + } return ( setOpen(true)} + onMouseEnter={() => { + handlePrefetch(); + setOpen(true); + }} onMouseLeave={() => setOpen(false)} > - { - setOpen(false); - onClick?.(event); - }} - {...props} - > - {children} - + {link} event.preventDefault()} > {data ? ( - + ) : (
diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 2b52a042b8c..60a201acddd 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -7,10 +7,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssueRow } from "./IssueRow"; vi.mock("@/lib/router", () => ({ - Link: ({ children, className, disableIssueQuicklook: _disableIssueQuicklook, ...props }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean }) => ( + Link: ({ + children, + className, + disableIssueQuicklook: _disableIssueQuicklook, + issuePrefetch, + ...props + }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean; issuePrefetch?: Issue | null }) => ( {children} @@ -157,6 +164,21 @@ describe("IssueRow", () => { }); }); + it("passes the visible row issue into the navigation prefetch path", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; + expect(link?.getAttribute("data-issue-prefetch-id")).toBe("issue-1"); + + act(() => { + root.unmount(); + }); + }); + it("renders titleSuffix inline after the issue title", () => { const root = createRoot(container); const issue = createIssue({ title: "Parent task" }); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 2ba4e92ea10..3bd8a2ead85 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -59,6 +59,7 @@ export function IssueRow({ to={createIssueDetailPath(issuePathId)} state={detailState} disableIssueQuicklook + issuePrefetch={issue} data-inbox-issue-link onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)} className={cn( diff --git a/ui/src/lib/issueDetailCache.test.ts b/ui/src/lib/issueDetailCache.test.ts new file mode 100644 index 00000000000..a2fbe0e3de2 --- /dev/null +++ b/ui/src/lib/issueDetailCache.test.ts @@ -0,0 +1,116 @@ +import { QueryClient } from "@tanstack/react-query"; +import type { Issue } from "@paperclipai/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { issuesApi } from "@/api/issues"; +import { + fetchIssueDetail, + getCachedIssueDetail, + prefetchIssueDetail, + seedIssueDetailCache, +} from "./issueDetailCache"; +import { queryKeys } from "./queryKeys"; + +vi.mock("@/api/issues", () => ({ + issuesApi: { + get: vi.fn(), + }, +})); + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Fast link target", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + isUnreadForMe: false, + ...overrides, + }; +} + +describe("issueDetailCache", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + vi.clearAllMocks(); + }); + + it("seeds and resolves issue detail by both identifier and id", () => { + const issue = createIssue(); + + seedIssueDetailCache(queryClient, issue, { issueRef: issue.identifier }); + + expect(getCachedIssueDetail(queryClient, issue.identifier)).toEqual(issue); + expect(getCachedIssueDetail(queryClient, issue.id)).toEqual(issue); + expect(queryClient.getQueryData(queryKeys.issues.detail(issue.identifier!))).toEqual(issue); + expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue); + }); + + it("prefetches with the provided issue snapshot before the network result lands", async () => { + const issue = createIssue(); + let releaseFetch: (() => void) | null = null; + vi.mocked(issuesApi.get).mockImplementation( + () => + new Promise((resolve) => { + releaseFetch = () => resolve(issue); + }), + ); + + const prefetchPromise = prefetchIssueDetail(queryClient, issue.identifier!, { issue }); + + expect(getCachedIssueDetail(queryClient, issue.identifier)).toEqual(issue); + expect(getCachedIssueDetail(queryClient, issue.id)).toEqual(issue); + + releaseFetch?.(); + await prefetchPromise; + }); + + it("hydrates both cache aliases from a fetched issue detail response", async () => { + const issue = createIssue(); + vi.mocked(issuesApi.get).mockResolvedValue(issue); + + const result = await fetchIssueDetail(queryClient, issue.identifier!); + + expect(result).toEqual(issue); + expect(queryClient.getQueryData(queryKeys.issues.detail(issue.identifier!))).toEqual(issue); + expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue); + }); +}); diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts new file mode 100644 index 00000000000..b4fab727f3b --- /dev/null +++ b/ui/src/lib/issueDetailCache.ts @@ -0,0 +1,103 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { Issue } from "@paperclipai/shared"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "@/lib/queryKeys"; + +const ISSUE_DETAIL_QUERY_PREFIX = ["issues", "detail"] as const; +const ISSUE_DETAIL_STALE_TIME_MS = 60_000; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function collectIssueRefs( + issueRef: string | null | undefined, + issue?: Pick | null, +): string[] { + const refs = new Set(); + if (isNonEmptyString(issueRef)) refs.add(issueRef); + if (isNonEmptyString(issue?.id)) refs.add(issue.id); + if (isNonEmptyString(issue?.identifier)) refs.add(issue.identifier); + return Array.from(refs); +} + +function matchesIssueRef(issue: Pick, refs: Iterable) { + const refSet = refs instanceof Set ? refs : new Set(refs); + return refSet.has(issue.id) || (!!issue.identifier && refSet.has(issue.identifier)); +} + +function mergeIssueSnapshots(existing: Issue | undefined, incoming: Issue): Issue { + if (!existing) return incoming; + return { + ...existing, + ...incoming, + }; +} + +export function getIssueDetailCacheRefs(issue: Pick): string[] { + return collectIssueRefs(null, issue); +} + +export function getCachedIssueDetail( + queryClient: QueryClient, + issueRef: string | null | undefined, + issue?: Pick | null, +): Issue | undefined { + const refs = collectIssueRefs(issueRef, issue); + + for (const ref of refs) { + const cached = queryClient.getQueryData(queryKeys.issues.detail(ref)); + if (cached) return cached; + } + + const cachedEntries = queryClient.getQueriesData({ queryKey: ISSUE_DETAIL_QUERY_PREFIX }); + return cachedEntries + .map(([, cachedIssue]) => cachedIssue) + .find((cachedIssue): cachedIssue is Issue => !!cachedIssue && matchesIssueRef(cachedIssue, refs)); +} + +export function seedIssueDetailCache( + queryClient: QueryClient, + issue: Issue, + options?: { + issueRef?: string | null; + }, +): Issue { + const refs = collectIssueRefs(options?.issueRef, issue); + const merged = mergeIssueSnapshots(getCachedIssueDetail(queryClient, options?.issueRef, issue), issue); + + for (const ref of refs) { + queryClient.setQueryData( + queryKeys.issues.detail(ref), + (existing) => mergeIssueSnapshots(existing, merged), + ); + } + + return merged; +} + +export async function fetchIssueDetail( + queryClient: QueryClient, + issueRef: string, +): Promise { + const issue = await issuesApi.get(issueRef); + return seedIssueDetailCache(queryClient, issue, { issueRef }); +} + +export function prefetchIssueDetail( + queryClient: QueryClient, + issueRef: string, + options?: { + issue?: Issue | null; + }, +) { + if (options?.issue) { + seedIssueDetailCache(queryClient, options.issue, { issueRef }); + } + + return queryClient.prefetchQuery({ + queryKey: queryKeys.issues.detail(issueRef), + queryFn: () => fetchIssueDetail(queryClient, issueRef), + staleTime: ISSUE_DETAIL_STALE_TIME_MS, + }); +} diff --git a/ui/src/lib/router.tsx b/ui/src/lib/router.tsx index 77fab0462e7..c1660277985 100644 --- a/ui/src/lib/router.tsx +++ b/ui/src/lib/router.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import * as RouterDom from "react-router-dom"; import type { NavigateOptions, To } from "react-router-dom"; +import type { Issue } from "@paperclipai/shared"; import { useCompany } from "@/context/CompanyContext"; import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook"; import { @@ -49,18 +50,28 @@ export * from "react-router-dom"; type CompanyLinkProps = React.ComponentProps & { disableIssueQuicklook?: boolean; + issuePrefetch?: Issue | null; }; export const Link = React.forwardRef( - function CompanyLink({ to, disableIssueQuicklook = false, ...props }, ref) { + function CompanyLink({ to, disableIssueQuicklook = false, issuePrefetch = null, ...props }, ref) { const companyPrefix = useActiveCompanyPrefix(); const resolvedTo = resolveTo(to, companyPrefix); const issuePathId = disableIssueQuicklook - ? null + ? parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname) : parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname); if (issuePathId) { - return ; + return ( + + ); } return ; diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 3cb0efa868c..6786e218ff2 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -30,6 +30,7 @@ import { rememberIssueDetailLocationState, withIssueDetailHeaderSeed, } from "../lib/issueDetailBreadcrumb"; +import { prefetchIssueDetail } from "../lib/issueDetailCache"; import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget, @@ -1578,6 +1579,7 @@ export function Inbox() { const pathId = issue.identifier ?? issue.id; const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue)); rememberIssueDetailLocationState(pathId, detailState); + void prefetchIssueDetail(queryClient, pathId, { issue }); act.navigate(createIssueDetailPath(pathId), { state: detailState }); } else if (item) { if (item.kind === "issue") { @@ -1586,6 +1588,7 @@ export function Inbox() { withIssueDetailHeaderSeed(issueLinkState, item.issue), ); rememberIssueDetailLocationState(pathId, detailState); + void prefetchIssueDetail(queryClient, pathId, { issue: item.issue }); act.navigate(createIssueDetailPath(pathId), { state: detailState }); } else if (item.kind === "approval") { act.navigate(`/approvals/${item.approval.id}`); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 3fbc72f9f83..ceceea117fb 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -27,6 +27,7 @@ import { rememberIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun"; +import { fetchIssueDetail, getCachedIssueDetail } from "../lib/issueDetailCache"; import { hasBlockingShortcutDialog, resolveIssueDetailGoKeyAction, @@ -392,6 +393,24 @@ export function IssueDetail() { const fileInputRef = useRef(null); const lastMarkedReadIssueIdRef = useRef(null); const commentComposerRef = useRef(null); + const resolvedIssueDetailState = useMemo( + () => readIssueDetailLocationState(issueId, location.state, location.search), + [issueId, location.state, location.search], + ); + const issueHeaderSeed = useMemo( + () => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState), + [location.state, resolvedIssueDetailState], + ); + const cachedIssue = useMemo( + () => + issueId + ? getCachedIssueDetail(queryClient, issueId, issueHeaderSeed ? { + id: issueHeaderSeed.id, + identifier: issueHeaderSeed.identifier, + } : null) + : undefined, + [issueHeaderSeed, issueId, queryClient], + ); useEffect(() => { setIssueChatInitialTranscriptReady(false); @@ -399,8 +418,9 @@ export function IssueDetail() { const { data: issue, isLoading, error } = useQuery({ queryKey: queryKeys.issues.detail(issueId!), - queryFn: () => issuesApi.get(issueId!), + queryFn: () => fetchIssueDetail(queryClient, issueId!), enabled: !!issueId, + initialData: () => cachedIssue, }); const resolvedCompanyId = issue?.companyId ?? selectedCompanyId; const commentComposerDisabledReason = useMemo(() => { @@ -491,14 +511,6 @@ export function IssueDetail() { ), [activeRun, liveRuns], ); - const resolvedIssueDetailState = useMemo( - () => readIssueDetailLocationState(issueId, location.state, location.search), - [issueId, location.state, location.search], - ); - const issueHeaderSeed = useMemo( - () => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState), - [location.state, resolvedIssueDetailState], - ); const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, [issueId, location.state, location.search], @@ -1639,6 +1651,7 @@ export function IssueDetail() { rememberIssueDetailLocationState( ancestor.identifier ?? ancestor.id, @@ -1883,6 +1896,7 @@ export function IssueDetail() { key={child.id} to={createIssueDetailPath(child.identifier ?? child.id)} state={resolvedIssueDetailState ?? location.state} + issuePrefetch={child} onClickCapture={() => rememberIssueDetailLocationState( child.identifier ?? child.id, From e59047187b203cdc84a4d681fbeba605f6fc7869 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 20:43:51 -0500 Subject: [PATCH 07/42] Reset scroll on issue detail navigation --- ui/src/lib/navigation-scroll.test.ts | 40 ++++++++++++++++++++++++++++ ui/src/lib/navigation-scroll.ts | 18 +++++++++++++ 2 files changed, 58 insertions(+) diff --git a/ui/src/lib/navigation-scroll.test.ts b/ui/src/lib/navigation-scroll.test.ts index 636c29cfd61..967dfa19f30 100644 --- a/ui/src/lib/navigation-scroll.test.ts +++ b/ui/src/lib/navigation-scroll.test.ts @@ -39,6 +39,46 @@ describe("navigation-scroll", () => { ).toBe(false); }); + it("resets scroll when navigating directly between issue detail routes", () => { + expect( + shouldResetScrollOnNavigation({ + previousPathname: "/issues/PAP-1389", + pathname: "/issues/PAP-1346", + navigationType: "PUSH", + state: null, + }), + ).toBe(true); + + expect( + shouldResetScrollOnNavigation({ + previousPathname: "/PAP/issues/PAP-1389", + pathname: "/PAP/issues/PAP-1346", + navigationType: "REPLACE", + state: null, + }), + ).toBe(true); + }); + + it("does not treat non-detail issue routes as issue-to-issue navigation", () => { + expect( + shouldResetScrollOnNavigation({ + previousPathname: "/projects/project-1/issues/all", + pathname: "/issues/PAP-1346", + navigationType: "PUSH", + state: null, + }), + ).toBe(false); + + expect( + shouldResetScrollOnNavigation({ + previousPathname: "/issues/PAP-1389", + pathname: "/projects/project-1/issues/all", + navigationType: "PUSH", + state: null, + }), + ).toBe(false); + }); + it("does not reset scroll on the initial render or when the pathname is unchanged", () => { expect( shouldResetScrollOnNavigation({ diff --git a/ui/src/lib/navigation-scroll.ts b/ui/src/lib/navigation-scroll.ts index a4c5de14ff5..6ea8a1bb803 100644 --- a/ui/src/lib/navigation-scroll.ts +++ b/ui/src/lib/navigation-scroll.ts @@ -14,6 +14,7 @@ export function shouldResetScrollOnNavigation(params: { if (previousPathname === null) return false; if (previousPathname === pathname) return false; if (navigationType === "POP") return false; + if (isIssueDetailPathChange(previousPathname, pathname)) return true; return hasSidebarScrollResetState(state); } @@ -43,3 +44,20 @@ function hasSidebarScrollResetState(state: unknown): boolean { if (!state || typeof state !== "object") return false; return (state as Record).paperclipSidebarScrollReset === true; } + +function isIssueDetailPathChange(previousPathname: string, pathname: string): boolean { + const previousIssueRef = readIssueDetailPathRef(previousPathname); + const nextIssueRef = readIssueDetailPathRef(pathname); + return previousIssueRef !== null && nextIssueRef !== null && previousIssueRef !== nextIssueRef; +} + +function readIssueDetailPathRef(pathname: string): string | null { + const segments = pathname.split("/").filter(Boolean); + if (segments.length === 2 && segments[0] === "issues") { + return segments[1] ?? null; + } + if (segments.length === 3 && segments[1] === "issues") { + return segments[2] ?? null; + } + return null; +} From 0cb42f49eafe95c78683e385e70acdfbba4ab7a4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 21:18:57 -0500 Subject: [PATCH 08/42] Fix rebased issue detail prefetch typing --- ui/src/lib/issueDetailCache.test.ts | 15 +++------------ ui/src/pages/IssueDetail.tsx | 1 - 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/ui/src/lib/issueDetailCache.test.ts b/ui/src/lib/issueDetailCache.test.ts index a2fbe0e3de2..dd55ba72e30 100644 --- a/ui/src/lib/issueDetailCache.test.ts +++ b/ui/src/lib/issueDetailCache.test.ts @@ -84,23 +84,14 @@ describe("issueDetailCache", () => { expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue); }); - it("prefetches with the provided issue snapshot before the network result lands", async () => { + it("prefetches with the provided issue snapshot without forcing a fresh fetch", async () => { const issue = createIssue(); - let releaseFetch: (() => void) | null = null; - vi.mocked(issuesApi.get).mockImplementation( - () => - new Promise((resolve) => { - releaseFetch = () => resolve(issue); - }), - ); - const prefetchPromise = prefetchIssueDetail(queryClient, issue.identifier!, { issue }); + await prefetchIssueDetail(queryClient, issue.identifier!, { issue }); expect(getCachedIssueDetail(queryClient, issue.identifier)).toEqual(issue); expect(getCachedIssueDetail(queryClient, issue.id)).toEqual(issue); - - releaseFetch?.(); - await prefetchPromise; + expect(issuesApi.get).not.toHaveBeenCalled(); }); it("hydrates both cache aliases from a fetched issue detail response", async () => { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index ceceea117fb..982d7aceb2f 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1651,7 +1651,6 @@ export function IssueDetail() { rememberIssueDetailLocationState( ancestor.identifier ?? ancestor.id, From 6844226572161a4ea267bfb026b509b88cc66dd5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 21:30:50 -0500 Subject: [PATCH 09/42] Address Greptile navigation review --- ui/src/components/IssueLinkQuicklook.tsx | 9 +++++++-- ui/src/lib/issueDetailCache.ts | 2 +- ui/src/lib/router.tsx | 4 +--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index 089b236fe1a..b6311eff1af 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -5,7 +5,12 @@ import type { Issue } from "@paperclipai/shared"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { timeAgo } from "@/lib/timeAgo"; import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb"; -import { fetchIssueDetail, getCachedIssueDetail, prefetchIssueDetail } from "@/lib/issueDetailCache"; +import { + fetchIssueDetail, + getCachedIssueDetail, + ISSUE_DETAIL_STALE_TIME_MS, + prefetchIssueDetail, +} from "@/lib/issueDetailCache"; import { queryKeys } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -99,7 +104,7 @@ export const IssueLinkQuicklook = React.forwardRef< queryFn: () => fetchIssueDetail(queryClient, issuePathId), enabled: open, initialData: () => cachedIssue, - staleTime: 60_000, + staleTime: ISSUE_DETAIL_STALE_TIME_MS, }); const detailPath = createIssueDetailPath(issuePathId); diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts index b4fab727f3b..8e02e46eb81 100644 --- a/ui/src/lib/issueDetailCache.ts +++ b/ui/src/lib/issueDetailCache.ts @@ -4,7 +4,7 @@ import { issuesApi } from "@/api/issues"; import { queryKeys } from "@/lib/queryKeys"; const ISSUE_DETAIL_QUERY_PREFIX = ["issues", "detail"] as const; -const ISSUE_DETAIL_STALE_TIME_MS = 60_000; +export const ISSUE_DETAIL_STALE_TIME_MS = 60_000; function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.length > 0; diff --git a/ui/src/lib/router.tsx b/ui/src/lib/router.tsx index c1660277985..c8d3a352d38 100644 --- a/ui/src/lib/router.tsx +++ b/ui/src/lib/router.tsx @@ -57,9 +57,7 @@ export const Link = React.forwardRef( function CompanyLink({ to, disableIssueQuicklook = false, issuePrefetch = null, ...props }, ref) { const companyPrefix = useActiveCompanyPrefix(); const resolvedTo = resolveTo(to, companyPrefix); - const issuePathId = disableIssueQuicklook - ? parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname) - : parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname); + const issuePathId = parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname); if (issuePathId) { return ( From 76fe736e8ee0e30a03a84f9f480facb33a04efbd Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:43:07 -0500 Subject: [PATCH 10/42] chore: add v2026.410.0 release notes for security release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds comprehensive release notes for `v2026.410.0`, the security release that patches GHSA-68qg-g8mg-6pr7 (unauthenticated RCE via import authorization bypass) - Required before triggering the stable release workflow to publish `2026.410.0` to npm and create the GitHub Release ## Context The security fix (PR #3315) is already merged to master. The GHSA advisory references `2026.410.0` as the patched version, but only canary builds exist on npm. This PR unblocks the stable release. ## Test plan - [x] Release notes file is valid markdown - [ ] Merge and trigger `release.yml` workflow with `source_ref=master`, `stable_date=2026-04-10` - [ ] Verify `npm view paperclipai version` returns `2026.410.0` - [ ] Verify GitHub Release `v2026.410.0` exists 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Paperclip Co-authored-by: Paperclip --- releases/v2026.410.0.md | 50 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 releases/v2026.410.0.md diff --git a/releases/v2026.410.0.md b/releases/v2026.410.0.md new file mode 100644 index 00000000000..e835f186c09 --- /dev/null +++ b/releases/v2026.410.0.md @@ -0,0 +1,50 @@ +# v2026.410.0 + +> Released: 2026-04-13 + +## Security + +- **Authorization hardening (GHSA-68qg-g8mg-6pr7)** — Scoped import, approval, activity, and heartbeat API routes to enforce proper authorization checks. Previously, certain administrative endpoints were accessible without adequate permission verification. All users are strongly encouraged to upgrade. ([#3315](https://github.com/cryppadotta/paperclip/pull/3315)) +- **Removed hardcoded JWT secret fallback** — The `createBetterAuthInstance` function no longer falls back to a hardcoded JWT secret, closing a credential-hygiene gap. +- **Redact Bearer tokens in logs** — Server log output now redacts Bearer tokens to prevent accidental credential exposure. ([#2659](https://github.com/cryppadotta/paperclip/pull/2659)) +- **Dependency bumps** — Updated `multer` to 2.1.1 (HIGH CVEs) and `rollup` to 4.59.0 (path-traversal CVE). ([#2819](https://github.com/cryppadotta/paperclip/pull/2819)) + +## Highlights + +- **Issue-to-issue navigation** — Faster navigation between issues with scroll reset, prefetch, and detail-view optimizations. ([#3542](https://github.com/cryppadotta/paperclip/pull/3542)) +- **Auto-checkout for scoped wakes** — Agent harness now automatically checks out the scoped issue on comment-driven wakes, reducing latency for agent heartbeats. ([#3538](https://github.com/cryppadotta/paperclip/pull/3538)) +- **Inbox parent-child nesting** — Issues in the Mine inbox can now be grouped by parent, with a toggle and keyboard-traversable nested rows. +- **Keyboard shortcut cheatsheet** — Press `?` to see all available keyboard shortcuts in a dialog. +- **Issue search in inbox** — Broadened comment matching for inbox issue search with fallback. +- **Codex fast mode** — Added fast mode support for `codex_local` adapters with env probe safeguards. +- **Backups with retention** — Gzip-compressed database backups with tiered daily/weekly/monthly retention and UI controls in Instance Settings. +- **AWS Bedrock auth** — Added AWS Bedrock authentication support on `claude-local` adapters. ([#2793](https://github.com/cryppadotta/paperclip/pull/2793)) + +## Improvements + +- **Issue detail stability** — Faster comment loading, reduced rerenders on interrupted runs, stable transcript rendering for non-succeeded runs. +- **Execution workspaces** — Fixed linked worktree reuse, dev runner isolation, workspace import regressions, and workspace preflight through server toolchain. +- **Agent runtime** — Hardened heartbeat and adapter runtime workflows, scoped-wake fast path skips full heartbeat on comment wakes, signoff stage access fixes. +- **Execution policy** — Fixed non-participant stage mutation rejection, decision persistence, and signoff PR follow-up flows. +- **Chat UX polish** — Shimmer animation improvements, image gallery in chat messages, inline comment composer, Working/Worked status tokens. +- **Inbox refinements** — Avoid refetching on filter-only changes, archive shortcut fix, badge fixture alignment, nesting column alignment. +- **Typing performance** — Fixed typing lag in long comment threads. ([#3163](https://github.com/cryppadotta/paperclip/pull/3163)) +- **Issue list grouping** — Added workspace and parent issue grouping to the issues list view. +- **Worktree tooling** — Improved worktree helpers, bind presets for deployment setup, tailnet bind hardening. +- **Plugin SDK** — Plugin SDK now prepares before CLI dev boot. ([#3343](https://github.com/cryppadotta/paperclip/pull/3343)) + +## Fixes + +- **Agent env bindings** — Cleared agent env bindings now persist correctly on save. +- **Comment editor sync** — Hardened issue comment editor synchronization. +- **Document revisions** — Latest issue document revision stays current in the UI. ([#3342](https://github.com/cryppadotta/paperclip/pull/3342)) +- **Claude instructions** — Fixed instruction sibling path hints, gate file I/O to fresh sessions only, skip `--append-system-prompt-file` on resumed sessions. +- **Codex transcript** — Fixed Codex tool-use transcript completion parsing. +- **Backup cleanup** — Orphaned `.sql` files cleaned up on compression failure; stale startup log fixed. +- **Chat layout** — Fixed avatar positioning, activity line alignment, comment alignment, and feedback panel closing. + +## Upgrade Guide + +Multiple database migrations will run automatically on startup. All migrations are additive — no existing data is modified. + +**Security:** This release addresses [GHSA-68qg-g8mg-6pr7](https://github.com/cryppadotta/paperclip/security/advisories/GHSA-68qg-g8mg-6pr7). All deployments should upgrade as soon as possible. From 5d1ed71779df5622d9fd99ad28816b2da4bdee31 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:43:17 -0500 Subject: [PATCH 11/42] chore: add v2026.410.0 release notes for security release ## Summary - Adds release notes for v2026.410.0 security release covering GHSA-68qg-g8mg-6pr7 - Required before triggering the stable release workflow to publish 2026.410.0 to npm ## Context The security advisory GHSA-68qg-g8mg-6pr7 was published on 2026-04-10 listing 2026.410.0 as the patched version, but only canary builds exist on npm. The authz fix (PR #3315) is already merged to master. This PR adds release notes so the stable release workflow can be triggered. ## Test plan - Verify release notes content is accurate - Merge, then trigger release.yml workflow_dispatch with source_ref=master, stable_date=2026-04-10, dry_run=false - Confirm npm view paperclipai version returns 2026.410.0 Co-authored-by: Paperclip From 6e6f5386302045e7df5598cc16705f0e7b0ef76f Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:50:48 -0500 Subject: [PATCH 12/42] [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 --- .../heartbeat-comment-wake-batching.test.ts | 300 ++++ .../issue-comment-cancel-routes.test.ts | 191 +++ .../issue-comment-reopen-routes.test.ts | 76 + server/src/routes/issues.ts | 139 +- server/src/services/heartbeat.ts | 116 +- server/src/services/issues.ts | 22 + ui/src/api/issues.ts | 4 + ui/src/components/BreadcrumbBar.tsx | 10 +- ui/src/components/CommentThread.test.tsx | 46 + ui/src/components/CommentThread.tsx | 23 +- ui/src/components/IssueChatThread.test.tsx | 261 +++- ui/src/components/IssueChatThread.tsx | 200 ++- ui/src/components/IssuesList.test.tsx | 73 + ui/src/components/IssuesList.tsx | 65 +- ui/src/components/MarkdownBody.test.tsx | 14 + ui/src/components/MarkdownEditor.test.tsx | 204 ++- ui/src/components/MarkdownEditor.tsx | 226 ++- ui/src/components/NewIssueDialog.test.tsx | 37 + ui/src/context/BreadcrumbContext.tsx | 9 +- ui/src/context/LiveUpdatesProvider.test.ts | 248 ++++ ui/src/context/LiveUpdatesProvider.tsx | 127 +- .../hooks/usePaperclipIssueRuntime.test.tsx | 179 +++ ui/src/hooks/usePaperclipIssueRuntime.ts | 33 +- ui/src/lib/activity-format.ts | 2 + ui/src/lib/issue-chat-messages.test.ts | 67 + ui/src/lib/issue-chat-messages.ts | 40 + ui/src/lib/issue-chat-scroll.test.ts | 98 ++ ui/src/lib/issue-chat-scroll.ts | 70 + ui/src/lib/issue-detail-subissues.test.ts | 18 + ui/src/lib/issue-detail-subissues.ts | 3 + ui/src/lib/issue-properties-panel-key.test.ts | 47 + ui/src/lib/issue-properties-panel-key.ts | 76 + ui/src/lib/issue-reference.test.ts | 10 +- ui/src/lib/issue-reference.ts | 14 +- ui/src/lib/optimistic-issue-comments.test.ts | 72 + ui/src/lib/optimistic-issue-comments.ts | 28 + ui/src/lib/query-placeholder-data.test.tsx | 111 ++ ui/src/lib/query-placeholder-data.ts | 10 + ui/src/lib/subIssueDefaults.test.ts | 136 ++ ui/src/lib/subIssueDefaults.ts | 52 + ui/src/pages/IssueDetail.tsx | 1274 +++++++++++------ 41 files changed, 4141 insertions(+), 590 deletions(-) create mode 100644 server/src/__tests__/issue-comment-cancel-routes.test.ts create mode 100644 ui/src/hooks/usePaperclipIssueRuntime.test.tsx create mode 100644 ui/src/lib/issue-chat-scroll.test.ts create mode 100644 ui/src/lib/issue-chat-scroll.ts create mode 100644 ui/src/lib/issue-detail-subissues.test.ts create mode 100644 ui/src/lib/issue-detail-subissues.ts create mode 100644 ui/src/lib/issue-properties-panel-key.test.ts create mode 100644 ui/src/lib/issue-properties-panel-key.ts create mode 100644 ui/src/lib/query-placeholder-data.test.tsx create mode 100644 ui/src/lib/query-placeholder-data.ts create mode 100644 ui/src/lib/subIssueDefaults.test.ts create mode 100644 ui/src/lib/subIssueDefaults.ts diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 3626c2f5ff3..dca9c8f7f88 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 00000000000..ca070233511 --- /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 78b75d3a974..3774a58573e 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 6ffd524e660..0d9c6876b14 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 e0e0a317cec..cf621783e9a 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 f7ac19da0df..db8eb39a1e6 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 bd604af9cdf..e0d8ec05d0d 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 a4d1462ac53..98319075f6b 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 0fa73210108..860ea0ad8f7 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 f4ad17b5233..06fe76813d5 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 00d3df61241..a8f48cd9aec 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 e87fa41c8ba..03f78feb98d 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 9dde9e08807..64b37f26ffc 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 f30db99101c..814d83b05c7 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 3e0431557e0..106a394b996 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 00e9dfc6fca..73b7951f83e 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.

+ +
+