Merge master into board QoL PR

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-30 15:07:24 -05:00
commit e225fb3605
8 files changed
+84 -48

No files matched your search

+2 -2
View File
@@ -670,8 +670,8 @@ export {
requestConfirmationResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
cancelIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
cancelIssueThreadInteractionSchema,
respondIssueThreadInteractionSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
@@ -705,8 +705,8 @@ export {
type AddIssueComment,
type CreateIssueThreadInteraction,
type AcceptIssueThreadInteraction,
type CancelIssueThreadInteraction,
type RejectIssueThreadInteraction,
type CancelIssueThreadInteraction,
type RespondIssueThreadInteraction,
type LinkIssueApproval,
type CreateIssueAttachmentMetadata,
+2 -2
View File
@@ -176,8 +176,8 @@ export {
requestConfirmationResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
cancelIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
cancelIssueThreadInteractionSchema,
respondIssueThreadInteractionSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
@@ -194,8 +194,8 @@ export {
type AddIssueComment,
type CreateIssueThreadInteraction,
type AcceptIssueThreadInteraction,
type CancelIssueThreadInteraction,
type RejectIssueThreadInteraction,
type CancelIssueThreadInteraction,
type RespondIssueThreadInteraction,
type LinkIssueApproval,
type CreateIssueAttachmentMetadata,
+10 -6
View File
@@ -1,4 +1,5 @@
import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import type { Db } from "@paperclipai/db";
import { activityLog, agents, companies, costEvents, issues, projects } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
@@ -135,6 +136,9 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
},
issueTreeSummary: async (companyId: string, issueId: string) => {
// Callers must resolve and authorize a visible root issue before invoking this.
// The route does that so zero counts are not mistaken for a missing root.
const childIssues = alias(issues, "child");
const issueTreeCondition = sql<boolean>`
${issues.id} IN (
WITH RECURSIVE issue_tree(id) AS (
@@ -143,12 +147,12 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
WHERE ${issues.companyId} = ${companyId}
AND ${issues.id} = ${issueId}
AND ${issues.hiddenAt} IS NULL
UNION
SELECT child.id
FROM ${issues} child
JOIN issue_tree ON child.parent_id = issue_tree.id
WHERE child.company_id = ${companyId}
AND child.hidden_at IS NULL
UNION ALL
SELECT ${childIssues.id}
FROM ${issues} ${childIssues}
JOIN issue_tree ON ${childIssues.parentId} = issue_tree.id
WHERE ${childIssues.companyId} = ${companyId}
AND ${childIssues.hiddenAt} IS NULL
)
SELECT id FROM issue_tree
)
+5
View File
@@ -3495,6 +3495,11 @@ export function IssueChatThread({
return;
}
if (typeof document === "undefined") {
finish();
return;
}
const el = document.getElementById(latestCommentAnchor);
if (!el) {
// Row hasn't been rendered into the virtualizer's buffer yet — nudge
+6 -3
View File
@@ -460,6 +460,7 @@ export function IssueRunLedgerContent({
[ledgerRuns],
);
const children = childIssueSummary(childIssues);
const canRenderActivityEvents = Boolean(renderActivityEvent);
const feedItems = useMemo<LedgerFeedItem[]>(() => {
const items: LedgerFeedItem[] = [];
for (const run of ledgerRuns) {
@@ -470,12 +471,14 @@ export function IssueRunLedgerContent({
run,
});
}
if (renderActivityEvent) {
if (canRenderActivityEvents) {
for (const event of activityEvents ?? []) {
items.push({
kind: "activity",
id: event.id,
timestamp: String(event.createdAt),
timestamp: event.createdAt instanceof Date
? event.createdAt.toISOString()
: String(event.createdAt),
event,
});
}
@@ -487,7 +490,7 @@ export function IssueRunLedgerContent({
if (a.kind !== b.kind) return a.kind === "run" ? -1 : 1;
return b.id.localeCompare(a.id);
});
}, [activityEvents, ledgerRuns, renderActivityEvent]);
}, [activityEvents, canRenderActivityEvents, ledgerRuns]);
return (
<section className="space-y-3" aria-label="Issue run ledger">
@@ -91,6 +91,25 @@ describe("IssueThreadInteractionCard", () => {
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(3);
});
it("only shows question cancellation when a cancel handler is wired", () => {
const withoutHandler = renderCard({
interaction: pendingAskUserQuestionsInteraction,
onSubmitInteractionAnswers: vi.fn(),
});
expect(withoutHandler.textContent).not.toContain("Cancel question");
act(() => root?.unmount());
withoutHandler.remove();
root = null;
const withHandler = renderCard({
interaction: pendingAskUserQuestionsInteraction,
onCancelInteraction: vi.fn(),
onSubmitInteractionAnswers: vi.fn(),
});
expect(withHandler.textContent).toContain("Cancel question");
});
it("makes child tasks explicit in suggested task trees", () => {
const host = renderCard({
interaction: pendingSuggestedTasksInteraction,
@@ -792,21 +792,23 @@ function AskUserQuestionsCard({
Submit once after you finish the full form.
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={!onCancelInteraction || working || cancelling}
onClick={() => void handleCancel()}
>
{cancelling ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Cancelling...
</>
) : (
"Cancel question"
)}
</Button>
{onCancelInteraction ? (
<Button
size="sm"
variant="outline"
disabled={working || cancelling}
onClick={() => void handleCancel()}
>
{cancelling ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Cancelling...
</>
) : (
"Cancel question"
)}
</Button>
) : null}
<Button
size="sm"
disabled={!onSubmitInteractionAnswers || !canSubmit || working || cancelling}
+23 -20
View File
@@ -222,6 +222,25 @@ async function flush() {
});
}
async function typeTextareaValue(textarea: HTMLTextAreaElement, value: string) {
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(textarea, value);
textarea.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: value,
inputType: "insertText",
}),
);
textarea.dispatchEvent(new Event("change", { bubbles: true }));
});
await flush();
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
@@ -260,6 +279,7 @@ describe("NewIssueDialog", () => {
let container: HTMLDivElement;
beforeEach(() => {
vi.useRealTimers();
container = document.createElement("div");
document.body.appendChild(container);
dialogState.newIssueOpen = true;
@@ -411,25 +431,8 @@ describe("NewIssueDialog", () => {
expect(titleInput).not.toBeNull();
expect(descriptionInput).not.toBeNull();
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(titleInput, "Typed issue");
titleInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(descriptionInput, "Typed description");
descriptionInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
await typeTextareaValue(titleInput!, "Typed issue");
await typeTextareaValue(descriptionInput!, "Typed description");
await act(async () => {
resolveProjects([
@@ -448,7 +451,7 @@ describe("NewIssueDialog", () => {
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Issue"));
expect(submitButton).not.toBeUndefined();
await waitForAssertion(() => {
await vi.waitFor(() => {
expect(submitButton?.hasAttribute("disabled")).toBe(false);
});