mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Merge master into board QoL PR
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
e225fb3605
8 files changed
+84
-48
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user