mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add workflow interaction cancellation and issue cost summaries (#4862)
## Thinking Path > - Paperclip coordinates work through issue-thread interactions, run history, and cost telemetry. > - Operators need workflow prompts to be cancellable and costs to be visible at the issue level. > - The earlier rollup mixed this workflow/cost work with database backups, reliability recovery, thread scaling, and settings polish. > - This pull request isolates the interaction and cost surfaces into a reviewable slice. > - The backend now supports cancelling pending question interactions and summarizing issue-tree costs. > - The UI component layer can render cancelled questions and interleave activity with run ledger rows. ## What Changed - Added `cancelled` as an issue-thread interaction status and result shape for question interactions. - Added the board-only `POST /issues/:id/interactions/:interactionId/cancel` route and service implementation. - Added issue-tree cost summary support in the cost service and `/issues/:id/cost-summary` API route. - Extended shared cost exports and UI API/query keys for issue cost summaries. - Updated `IssueThreadInteractionCard` and `IssueRunLedger` components for cancelled questions, issue cost surfaces, and activity/run interleaving. - Added focused server and component regression coverage. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run server/src/__tests__/costs-service.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts ui/src/components/IssueRunLedger.test.tsx` - Result: 4 test files passed, 45 tests passed. - UI screenshots not included because this PR updates reusable components and API surfaces without wiring a new page-level layout. ## Risks - Adds a new interaction terminal status; clients that switch exhaustively on interaction status may need to handle `cancelled`. - Issue-tree cost summaries use recursive issue traversal and should be watched on unusually large issue trees. - Page-level issue detail wiring is intentionally left to the board QoL/issue-detail branch to keep this PR narrow. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.5, code execution and GitHub CLI tool use, medium reasoning effort. ## 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 checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] 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 <noreply@paperclip.ing>
This commit is contained in:
parent
87f19cd9a6
commit
c4269bab59
23 changed files with 810 additions and 53 deletions
|
|
@ -8,6 +8,7 @@ import type {
|
|||
FeedbackVote,
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueCostSummary,
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueLabel,
|
||||
|
|
@ -159,6 +160,8 @@ export const issuesApi = {
|
|||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
|
||||
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
|
||||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}),
|
||||
cancelInteraction: (id: string, interactionId: string, reason?: string) =>
|
||||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/cancel`, reason ? { reason } : {}),
|
||||
respondToInteraction: (
|
||||
id: string,
|
||||
interactionId: string,
|
||||
|
|
@ -168,6 +171,7 @@ export const issuesApi = {
|
|||
getComment: (id: string, commentId: string) =>
|
||||
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
|
||||
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
||||
getCostSummary: (id: string) => api.get<IssueCostSummary>(`/issues/${id}/cost-summary`),
|
||||
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters ?? {})) {
|
||||
|
|
|
|||
|
|
@ -3464,6 +3464,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
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { act } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { Issue, RunLivenessState } from "@paperclipai/shared";
|
||||
import type { ActivityEvent, Issue, RunLivenessState } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RunForIssue } from "../api/activity";
|
||||
import type { ActiveRunForIssue } from "../api/heartbeats";
|
||||
|
|
@ -62,6 +62,23 @@ function createRun(overrides: Partial<RunForIssue> = {}): RunForIssue {
|
|||
};
|
||||
}
|
||||
|
||||
function createActivity(overrides: Partial<ActivityEvent> = {}): ActivityEvent {
|
||||
return {
|
||||
id: "activity-1",
|
||||
companyId: "company-1",
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
details: null,
|
||||
createdAt: new Date("2026-04-18T19:57:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
|
|
@ -139,6 +156,8 @@ function renderLedger(props: Partial<ComponentProps<typeof IssueRunLedgerContent
|
|||
issueStatus={props.issueStatus ?? "in_progress"}
|
||||
childIssues={props.childIssues ?? []}
|
||||
agentMap={props.agentMap ?? new Map([["agent-1", { name: "CodexCoder" }]])}
|
||||
activityEvents={props.activityEvents}
|
||||
renderActivityEvent={props.renderActivityEvent}
|
||||
pendingWatchdogDecision={props.pendingWatchdogDecision}
|
||||
canRecordWatchdogDecisions={props.canRecordWatchdogDecisions}
|
||||
watchdogDecisionError={props.watchdogDecisionError}
|
||||
|
|
@ -203,6 +222,42 @@ describe("IssueRunLedger", () => {
|
|||
expect(container.textContent).toContain("Last useful action Unavailable");
|
||||
});
|
||||
|
||||
it("interleaves run rows and activity rows by timestamp", () => {
|
||||
renderLedger({
|
||||
runs: [
|
||||
createRun({
|
||||
runId: "run-oldest",
|
||||
startedAt: "2026-04-18T19:55:00.000Z",
|
||||
createdAt: "2026-04-18T19:55:00.000Z",
|
||||
}),
|
||||
createRun({
|
||||
runId: "run-newest",
|
||||
startedAt: "2026-04-18T19:59:00.000Z",
|
||||
createdAt: "2026-04-18T19:59:00.000Z",
|
||||
}),
|
||||
],
|
||||
activityEvents: [
|
||||
createActivity({
|
||||
id: "activity-middle",
|
||||
action: "activity-middle",
|
||||
createdAt: new Date("2026-04-18T19:57:00.000Z"),
|
||||
}),
|
||||
],
|
||||
renderActivityEvent: (event) => (
|
||||
<div data-testid={`activity-${event.id}`}>{event.action}</div>
|
||||
),
|
||||
});
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
const newestIndex = text.indexOf("run-newe");
|
||||
const activityIndex = text.indexOf("activity-middle");
|
||||
const oldestIndex = text.indexOf("run-olde");
|
||||
|
||||
expect(newestIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(activityIndex).toBeGreaterThan(newestIndex);
|
||||
expect(oldestIndex).toBeGreaterThan(activityIndex);
|
||||
});
|
||||
|
||||
it("shows live runs as pending final checks without missing-data language", () => {
|
||||
renderLedger({
|
||||
runs: [
|
||||
|
|
@ -279,12 +334,18 @@ describe("IssueRunLedger", () => {
|
|||
resultJson: { stopReason: "budget_paused" },
|
||||
createdAt: "2026-04-18T19:56:00.000Z",
|
||||
}),
|
||||
createRun({
|
||||
runId: "run-paused",
|
||||
resultJson: { stopReason: "paused" },
|
||||
createdAt: "2026-04-18T19:55:00.000Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("timeout (30s timeout)");
|
||||
expect(container.textContent).toContain("cancelled");
|
||||
expect(container.textContent).toContain("budget paused");
|
||||
expect(container.textContent).toContain("paused by board");
|
||||
});
|
||||
|
||||
it("surfaces active and completed child issue summaries", () => {
|
||||
|
|
@ -328,7 +389,7 @@ describe("IssueRunLedger", () => {
|
|||
|
||||
it("shows when older runs are clipped from the ledger", () => {
|
||||
renderLedger({
|
||||
runs: Array.from({ length: 10 }, (_, index) =>
|
||||
runs: Array.from({ length: 22 }, (_, index) =>
|
||||
createRun({
|
||||
runId: `run-${index.toString().padStart(8, "0")}`,
|
||||
createdAt: `2026-04-18T19:${String(index).padStart(2, "0")}:00.000Z`,
|
||||
|
|
@ -336,7 +397,7 @@ describe("IssueRunLedger", () => {
|
|||
),
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("2 older runs not shown");
|
||||
expect(container.textContent).toContain("2 older items not shown");
|
||||
});
|
||||
|
||||
it("renders stale-run banner, watchdog actions, and silence badge for live runs", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import type { Issue, Agent } from "@paperclipai/shared";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import type { ActivityEvent, Issue, Agent } from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@/lib/router";
|
||||
import { accessApi, type CurrentBoardAccess } from "../api/access";
|
||||
|
|
@ -24,6 +24,8 @@ type IssueRunLedgerProps = {
|
|||
childIssues: Issue[];
|
||||
agentMap: ReadonlyMap<string, Agent>;
|
||||
hasLiveRuns: boolean;
|
||||
activityEvents?: ActivityEvent[];
|
||||
renderActivityEvent?: (event: ActivityEvent) => ReactNode;
|
||||
};
|
||||
|
||||
type IssueRunLedgerContentProps = {
|
||||
|
|
@ -33,6 +35,8 @@ type IssueRunLedgerContentProps = {
|
|||
issueStatus: Issue["status"];
|
||||
childIssues: Issue[];
|
||||
agentMap: ReadonlyMap<string, Pick<Agent, "name">>;
|
||||
activityEvents?: ActivityEvent[];
|
||||
renderActivityEvent?: (event: ActivityEvent) => ReactNode;
|
||||
pendingWatchdogDecision?: WatchdogDecisionInput["decision"] | null;
|
||||
canRecordWatchdogDecisions?: boolean;
|
||||
watchdogDecisionError?: string | null;
|
||||
|
|
@ -45,6 +49,20 @@ type LedgerRun = RunForIssue & {
|
|||
outputSilence?: ActiveRunForIssue["outputSilence"];
|
||||
};
|
||||
|
||||
type LedgerFeedItem =
|
||||
| {
|
||||
kind: "run";
|
||||
id: string;
|
||||
timestamp: string;
|
||||
run: LedgerRun;
|
||||
}
|
||||
| {
|
||||
kind: "activity";
|
||||
id: string;
|
||||
timestamp: string;
|
||||
event: ActivityEvent;
|
||||
};
|
||||
|
||||
type LivenessCopy = {
|
||||
label: string;
|
||||
tone: string;
|
||||
|
|
@ -256,7 +274,7 @@ function stopReasonLabel(run: RunForIssue) {
|
|||
}
|
||||
if (stopReason === "budget_paused") return "budget paused";
|
||||
if (stopReason === "cancelled") return "cancelled";
|
||||
if (stopReason === "paused") return "paused";
|
||||
if (stopReason === "paused") return "paused by board";
|
||||
if (stopReason === "process_lost") return "process lost";
|
||||
if (stopReason === "adapter_failed") return "adapter failed";
|
||||
if (stopReason === "completed") return timeoutText ? `completed (${timeoutText})` : "completed";
|
||||
|
|
@ -345,6 +363,8 @@ export function IssueRunLedger({
|
|||
childIssues,
|
||||
agentMap,
|
||||
hasLiveRuns,
|
||||
activityEvents,
|
||||
renderActivityEvent,
|
||||
}: IssueRunLedgerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
|
|
@ -405,6 +425,8 @@ export function IssueRunLedger({
|
|||
issueStatus={issueStatus}
|
||||
childIssues={childIssues}
|
||||
agentMap={agentMap}
|
||||
activityEvents={activityEvents}
|
||||
renderActivityEvent={renderActivityEvent}
|
||||
pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null}
|
||||
canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(companyId, boardAccess)}
|
||||
watchdogDecisionError={watchdogDecisionError}
|
||||
|
|
@ -420,6 +442,8 @@ export function IssueRunLedgerContent({
|
|||
issueStatus,
|
||||
childIssues,
|
||||
agentMap,
|
||||
activityEvents,
|
||||
renderActivityEvent,
|
||||
pendingWatchdogDecision,
|
||||
canRecordWatchdogDecisions = true,
|
||||
watchdogDecisionError,
|
||||
|
|
@ -436,6 +460,37 @@ export function IssueRunLedgerContent({
|
|||
[ledgerRuns],
|
||||
);
|
||||
const children = childIssueSummary(childIssues);
|
||||
const canRenderActivityEvents = Boolean(renderActivityEvent);
|
||||
const feedItems = useMemo<LedgerFeedItem[]>(() => {
|
||||
const items: LedgerFeedItem[] = [];
|
||||
for (const run of ledgerRuns) {
|
||||
items.push({
|
||||
kind: "run",
|
||||
id: run.runId,
|
||||
timestamp: run.startedAt ?? run.createdAt,
|
||||
run,
|
||||
});
|
||||
}
|
||||
if (canRenderActivityEvents) {
|
||||
for (const event of activityEvents ?? []) {
|
||||
items.push({
|
||||
kind: "activity",
|
||||
id: event.id,
|
||||
timestamp: event.createdAt instanceof Date
|
||||
? event.createdAt.toISOString()
|
||||
: String(event.createdAt),
|
||||
event,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items.sort((a, b) => {
|
||||
const aTime = new Date(a.timestamp).getTime();
|
||||
const bTime = new Date(b.timestamp).getTime();
|
||||
if (aTime !== bTime) return bTime - aTime;
|
||||
if (a.kind !== b.kind) return a.kind === "run" ? -1 : 1;
|
||||
return b.id.localeCompare(a.id);
|
||||
});
|
||||
}, [activityEvents, canRenderActivityEvents, ledgerRuns]);
|
||||
|
||||
return (
|
||||
<section className="space-y-3" aria-label="Issue run ledger">
|
||||
|
|
@ -578,28 +633,40 @@ export function IssueRunLedgerContent({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{ledgerRuns.length === 0 ? (
|
||||
{feedItems.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border px-3 py-3 text-sm text-muted-foreground">
|
||||
Historical runs without liveness metadata will appear here once linked to this issue.
|
||||
{renderActivityEvent
|
||||
? "Runs and activity will appear here once this issue has history."
|
||||
: "Historical runs without liveness metadata will appear here once linked to this issue."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border rounded-md border border-border/70">
|
||||
{ledgerRuns.slice(0, 8).map((run) => {
|
||||
<div className="space-y-1.5">
|
||||
{feedItems.slice(0, 20).map((item) => {
|
||||
if (item.kind === "activity") {
|
||||
return <div key={`activity:${item.id}`}>{renderActivityEvent?.(item.event)}</div>;
|
||||
}
|
||||
const run = item.run;
|
||||
const liveness = livenessCopyForRun(run);
|
||||
const stopReason = stopReasonLabel(run);
|
||||
const duration = formatDuration(run.startedAt, run.finishedAt);
|
||||
const exhausted = hasExhaustedContinuation(run);
|
||||
const continuation = continuationLabel(run);
|
||||
const retryState = describeRunRetryState(run);
|
||||
const agentName = compactAgentName(run, agentMap);
|
||||
return (
|
||||
<article key={run.runId} className="space-y-2 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<article
|
||||
key={`run:${run.runId}`}
|
||||
className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium text-foreground">Run</span>
|
||||
<Link
|
||||
to={`/agents/${run.agentId}/runs/${run.runId}`}
|
||||
className="min-w-0 max-w-full truncate font-mono text-xs text-foreground hover:underline"
|
||||
className="min-w-0 max-w-full truncate font-mono text-foreground hover:underline"
|
||||
>
|
||||
{run.runId.slice(0, 8)}
|
||||
</Link>
|
||||
<span>by {agentName}</span>
|
||||
<span className="rounded-md border border-border px-1.5 py-0.5 text-[11px] capitalize text-muted-foreground">
|
||||
{statusLabel(run.status)}
|
||||
</span>
|
||||
|
|
@ -646,6 +713,7 @@ export function IssueRunLedgerContent({
|
|||
{RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.label}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-auto shrink-0">{relativeTime(item.timestamp)}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
|
|
@ -696,9 +764,9 @@ export function IssueRunLedgerContent({
|
|||
</article>
|
||||
);
|
||||
})}
|
||||
{ledgerRuns.length > 8 ? (
|
||||
{feedItems.length > 20 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{ledgerRuns.length - 8} older runs not shown
|
||||
{feedItems.length - 20} older items not shown
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ interface IssueThreadInteractionCardProps {
|
|||
interaction: AskUserQuestionsInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void> | void;
|
||||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function resolveActorLabel(args: {
|
||||
|
|
@ -72,6 +75,8 @@ function statusLabel(status: IssueThreadInteraction["status"]) {
|
|||
return "Rejected";
|
||||
case "answered":
|
||||
return "Answered";
|
||||
case "cancelled":
|
||||
return "Cancelled";
|
||||
case "expired":
|
||||
return "Expired";
|
||||
case "failed":
|
||||
|
|
@ -100,6 +105,7 @@ function statusIcon(status: IssueThreadInteraction["status"]) {
|
|||
case "answered":
|
||||
return CheckCircle2;
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
case "failed":
|
||||
return XCircle;
|
||||
case "expired":
|
||||
|
|
@ -118,6 +124,7 @@ function statusClasses(status: IssueThreadInteraction["status"]) {
|
|||
badge: "border-emerald-500/60 bg-emerald-500/10 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-100",
|
||||
};
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
return {
|
||||
shell: "border-rose-400/70 bg-transparent",
|
||||
badge: "border-rose-500/60 bg-rose-500/10 text-rose-900 dark:bg-rose-500/15 dark:text-rose-100",
|
||||
|
|
@ -636,12 +643,16 @@ function QuestionOptionButton({
|
|||
function AskUserQuestionsCard({
|
||||
interaction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
}: {
|
||||
interaction: AskUserQuestionsInteraction;
|
||||
onSubmitInteractionAnswers?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void> | void;
|
||||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
}) {
|
||||
const [draftAnswers, setDraftAnswers] = useState<Record<string, string[]>>(() =>
|
||||
Object.fromEntries(
|
||||
|
|
@ -652,6 +663,7 @@ function AskUserQuestionsCard({
|
|||
),
|
||||
);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftAnswers(
|
||||
|
|
@ -699,6 +711,16 @@ function AskUserQuestionsCard({
|
|||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!onCancelInteraction) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
await onCancelInteraction(interaction);
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
|
|
@ -765,26 +787,54 @@ function AskUserQuestionsCard({
|
|||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/75 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/75 p-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Submit once after you finish the full form.
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!onSubmitInteractionAnswers || !canSubmit || working}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{working ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
interaction.payload.submitLabel ?? "Submit answers"
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{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}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{working ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
interaction.payload.submitLabel ?? "Submit answers"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : interaction.status === "cancelled" ? (
|
||||
<div className="rounded-2xl border border-rose-300/60 bg-rose-50/85 p-4 text-sm leading-6 text-rose-950 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100">
|
||||
<div className="font-semibold">Question cancelled</div>
|
||||
{interaction.result?.cancellationReason ? (
|
||||
<p className="mt-1">{interaction.result.cancellationReason}</p>
|
||||
) : (
|
||||
<p className="mt-1">No answer was recorded.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{questions.map((question) => {
|
||||
|
|
@ -1162,6 +1212,7 @@ export function IssueThreadInteractionCard({
|
|||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
}: IssueThreadInteractionCardProps) {
|
||||
const StatusIcon = statusIcon(interaction.status);
|
||||
const styles = statusClasses(interaction.status);
|
||||
|
|
@ -1247,6 +1298,7 @@ export function IssueThreadInteractionCard({
|
|||
<AskUserQuestionsCard
|
||||
interaction={interaction}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
/>
|
||||
) : (
|
||||
<RequestConfirmationCard
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ export function buildIssueThreadInteractionSummary(
|
|||
if (interaction.status === "answered") {
|
||||
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
|
||||
}
|
||||
if (interaction.status === "cancelled") {
|
||||
return count === 1 ? "Cancelled 1 question" : `Cancelled ${count} questions`;
|
||||
}
|
||||
return count === 1 ? "Asked 1 question" : `Asked ${count} questions`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const queryKeys = {
|
|||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||
interactions: (issueId: string) => ["issues", "interactions", issueId] as const,
|
||||
feedbackVotes: (issueId: string) => ["issues", "feedback-votes", issueId] as const,
|
||||
costSummary: (issueId: string) => ["issues", "cost-summary", issueId] as const,
|
||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||
document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue