diff --git a/server/src/__tests__/documents.test.ts b/server/src/__tests__/documents.test.ts
index c36b7265acb..c80d650e919 100644
--- a/server/src/__tests__/documents.test.ts
+++ b/server/src/__tests__/documents.test.ts
@@ -1,5 +1,6 @@
-import { describe, expect, it } from "vitest";
-import { extractLegacyPlanBody } from "../services/documents.js";
+import { describe, expect, it, vi } from "vitest";
+import type { Db } from "@paperclipai/db";
+import { documentService, extractLegacyPlanBody } from "../services/documents.js";
describe("extractLegacyPlanBody", () => {
it("returns null when no plan block exists", () => {
@@ -27,3 +28,43 @@ intro
expect(extractLegacyPlanBody(" ")).toBeNull();
});
});
+
+
+describe("document creation conflicts", () => {
+ function serviceWithTransactionError(error: Error) {
+ const db = {
+ select: () => ({
+ from: () => ({
+ where: () => Promise.resolve([{ id: "issue-1", companyId: "company-1" }]),
+ }),
+ }),
+ transaction: vi.fn().mockRejectedValue(error),
+ };
+ return documentService(db as unknown as Db);
+ }
+
+ const input = { issueId: "issue-1", key: "plan", format: "markdown", body: "# Plan" };
+
+ it("returns a retryable conflict for a Drizzle-wrapped concurrent insert", async () => {
+ const error = new Error("Failed query: insert into issue_documents", {
+ cause: Object.assign(new Error("duplicate key"), {
+ code: "23505",
+ constraint_name: "issue_documents_company_issue_key_uq",
+ }),
+ });
+ await expect(serviceWithTransactionError(error).upsertIssueDocument(input)).rejects.toMatchObject({
+ status: 409,
+ message: "Document key already exists on this issue",
+ details: { key: "plan" },
+ });
+ });
+
+ it.each([
+ { code: "23503", constraint_name: "issue_documents_issue_id_issues_id_fk" },
+ { code: "23505", constraint_name: "document_revisions_document_revision_uq" },
+ { code: "23505", constraint_name: "issue_documents_document_uq" },
+ ])("preserves unrelated database failures ($constraint_name)", async (cause) => {
+ const error = new Error("Failed query", { cause });
+ await expect(serviceWithTransactionError(error).upsertIssueDocument(input)).rejects.toBe(error);
+ });
+});
diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts
index 9517456b53e..1ee5dcc2f27 100644
--- a/server/src/services/documents.ts
+++ b/server/src/services/documents.ts
@@ -4,6 +4,7 @@ import type { Db } from "@paperclipai/db";
import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db";
import { isSystemIssueDocumentKey, issueDocumentKeySchema } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
+import { isUniqueViolation } from "../db-errors.js";
import { insertRowsInChunks } from "./batch-insert.js";
import type { ImportIssueDocumentRow } from "./import-write-types.js";
@@ -16,10 +17,6 @@ function normalizeDocumentKey(key: string) {
return parsed.data;
}
-function isUniqueViolation(error: unknown): boolean {
- return !!error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "23505";
-}
-
function nextAvailableDocumentKey(sourceKey: string, existingKeys: string[]) {
const usedKeys = new Set(existingKeys);
for (let index = 2; index < 1000; index += 1) {
@@ -502,7 +499,7 @@ export function documentService(db: Db) {
};
});
} catch (error) {
- if (isUniqueViolation(error)) {
+ if (isUniqueViolation(error, "issue_documents_company_issue_key_uq")) {
if (input.lockedDocumentStrategy === "create_new_document" && attempt < maxAttempts - 1) {
continue;
}
diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx
index f14a659fa42..2cd0a36adaf 100644
--- a/ui/src/pages/Inbox.test.tsx
+++ b/ui/src/pages/Inbox.test.tsx
@@ -813,7 +813,10 @@ describe("Inbox toolbar", () => {
,
);
});
- await vi.waitFor(() => expect(container.textContent).toContain("Workspace-aware task"));
+ // Workspace metadata resolves independently of the task list.
+ await vi.waitFor(() => {
+ expect(container.querySelector('[data-slot="task-row"]')?.textContent).toContain("Workspace Aurora");
+ });
const taskRow = container.querySelector('[data-slot="task-row"]');
const identifier = taskRow?.querySelector('[data-slot="task-row-identifier"]');
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx
index c5cf9dec795..3e5a8f8e217 100644
--- a/ui/src/pages/IssueDetail.test.tsx
+++ b/ui/src/pages/IssueDetail.test.tsx
@@ -41,6 +41,7 @@ import {
} from "../lib/issueDetailBreadcrumb";
import { getRecentTasksStorageKey, readRecentTasks } from "../lib/recent-tasks";
import { ApiError } from "../api/client";
+import type { issuesApi } from "../api/issues";
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
@@ -5290,6 +5291,93 @@ describe("IssueDetail", () => {
localStorage.removeItem("paperclip:issue-comment-draft:issue-1");
});
+ describe.each([false, true])("composer tree control (mobile=%s)", (isMobile) => {
+ const treeControlKey = ["issues", "tree-control-state", "PAP-1"];
+ const composerProps = () => mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
+ composerDisabledReason: string | null;
+ composerPause: TaskComposerPause | null;
+ onAdd: (body: string) => Promise;
+ };
+
+ async function renderWithPendingTreeControl() {
+ mockSidebarState.isMobile = isMobile;
+ mockIssuesApi.get.mockResolvedValue(createIssue());
+ let resolve!: (value: Awaited>) => void;
+ let reject!: (reason: Error) => void;
+ const promise = new Promise>>((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ const response = { promise, resolve, reject };
+ mockIssuesApi.getTreeControlState.mockReturnValue(response.promise);
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await waitForAssertion(() => {
+ expect(mockIssuesApi.getTreeControlState).toHaveBeenCalledWith("PAP-1");
+ expect(mockIssueChatThreadRender).toHaveBeenCalled();
+ expect(queryClient.getQueryState(treeControlKey)?.status).toBe("pending");
+ expect(queryClient.getQueryState(treeControlKey)?.fetchStatus).toBe("fetching");
+ });
+ return response;
+ }
+
+ it("allows a message while tree control is pending and remains enabled after success", async () => {
+ mockIssuesApi.addComment.mockClear().mockResolvedValue(createIssueComment({ body: "Keep working" }));
+ const response = await renderWithPendingTreeControl();
+ expect(composerProps().composerDisabledReason).toBeNull();
+ expect(composerProps().composerPause).toBeNull();
+ await act(async () => {
+ await composerProps().onAdd("Keep working");
+ });
+ expect(mockIssuesApi.addComment).toHaveBeenCalledWith(
+ "PAP-1", "Keep working", undefined, undefined, undefined, expect.any(String),
+ );
+ expect(queryClient.getQueryState(treeControlKey)?.status).toBe("pending");
+
+ response.resolve({ activePauseHold: null });
+ await waitForAssertion(() => {
+ expect(queryClient.getQueryState(treeControlKey)?.status).toBe("success");
+ expect(composerProps().composerDisabledReason).toBeNull();
+ expect(composerProps().composerPause).toBeNull();
+ });
+ });
+
+ it("blocks the composer when the pending tree control request fails", async () => {
+ const response = await renderWithPendingTreeControl();
+ response.reject(new Error("tree control unavailable"));
+ await waitForAssertion(() => {
+ expect(queryClient.getQueryState(treeControlKey)?.status).toBe("error");
+ expect(composerProps().composerDisabledReason).toBe(
+ "Couldn’t check whether this task is paused. Refresh to try again.",
+ );
+ });
+ });
+
+ it.each([true, false])("applies a late pause response (root=%s)", async (isRoot) => {
+ const response = await renderWithPendingTreeControl();
+ response.resolve({
+ activePauseHold: {
+ holdId: "hold-1",
+ rootIssueId: isRoot ? "issue-1" : "parent-1",
+ issueId: "issue-1",
+ isRoot,
+ mode: "pause",
+ reason: null,
+ releasePolicy: { strategy: "manual" },
+ },
+ });
+ await waitForAssertion(() => {
+ expect(composerProps().composerPause?.scope).toBe(isRoot ? "leaf" : "subtree");
+ expect(container.querySelector('[data-testid="paused-composer-takeover"]')).not.toBeNull();
+ });
+ });
+ });
+
it("renders a quiet task pause notice and defaults leaf resume to wake the assignee", async () => {
const activeHold = createPauseHold();
const releasedHold = createPauseHold({
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 0cc45cb507e..15d14bef1fd 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -3440,7 +3440,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
staleTime: 0,
retry: false,
});
- const { data: treeControlState, isPending: treeControlStatePending, error: treeControlStateError } = useQuery({
+ const { data: treeControlState, error: treeControlStateError } = useQuery({
queryKey: ["issues", "tree-control-state", issueId ?? "pending"],
queryFn: () => issuesApi.getTreeControlState(issueId!),
enabled: !!issueId,
@@ -7834,7 +7834,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
} : undefined,
resumeHref: !activePauseHold.isRoot ? createIssueDetailPath(activePauseHoldRoot?.identifier ?? activePauseHold.rootIssueId) : undefined,
} : null}
- composerDisabledReason={issue.conversationAgentId && !instanceExperimentalSettings?.enableAgentChat ? "Agent Chat is disabled in Experimental settings." : issueId && treeControlStatePending ? "Checking task status…" : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null}
+ composerDisabledReason={issue.conversationAgentId && !instanceExperimentalSettings?.enableAgentChat ? "Agent Chat is disabled in Experimental settings." : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null}
composerHint={composerHint}
queuedCommentReason={queuedCommentReason}
onVote={handleCommentVote}