Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DraftActionsWidget } from "./DraftActionsWidget";

type FetchResponseInit = {
ok: boolean;
status: number;
body: string;
};

function mockFetchResponse({ ok, status, body }: FetchResponseInit): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn().mockResolvedValue({
ok,
status,
text: async () => body,
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

describe("DraftActionsWidget", () => {
const baseProps = {
versionId: "ver-1",
canvasId: "canvas-1",
organizationId: "org-1",
isEditing: false,
};

let errorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
errorSpy.mockRestore();
vi.unstubAllGlobals();
});

it("dismisses silently when the API reports the draft is no longer publishable", async () => {
mockFetchResponse({
ok: false,
status: 400,
body: JSON.stringify({ code: 9, message: "only draft versions can be published", details: [] }),
});
const onDismiss = vi.fn();

render(<DraftActionsWidget {...baseProps} onDismiss={onDismiss} />);
fireEvent.click(screen.getByRole("button", { name: /publish/i }));

await waitFor(() => expect(onDismiss).toHaveBeenCalledTimes(1));

// Critically: we don't log to console.error for an expected 4xx outcome, so
// captureConsoleIntegration doesn't ship a Sentry event for this case.
expect(errorSpy).not.toHaveBeenCalled();
expect(screen.queryByRole("alert")).toBeNull();
});

it("shows an inline error (and stays mounted) on other 4xx responses without logging", async () => {
mockFetchResponse({
ok: false,
status: 400,
body: JSON.stringify({ code: 9, message: "change management is enabled for this canvas", details: [] }),
});
const onDismiss = vi.fn();

render(<DraftActionsWidget {...baseProps} onDismiss={onDismiss} />);
fireEvent.click(screen.getByRole("button", { name: /publish/i }));

const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("change management is enabled for this canvas");
expect(onDismiss).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});

it("logs a console.error for unexpected 5xx responses so they reach Sentry", async () => {
mockFetchResponse({
ok: false,
status: 500,
body: JSON.stringify({ message: "internal error" }),
});
const onDismiss = vi.fn();

render(<DraftActionsWidget {...baseProps} onDismiss={onDismiss} />);
fireEvent.click(screen.getByRole("button", { name: /publish/i }));

await waitFor(() => expect(errorSpy).toHaveBeenCalledTimes(1));
expect(errorSpy.mock.calls[0]?.[0]).toContain("publish failed:");
expect(onDismiss).not.toHaveBeenCalled();
expect(await screen.findByRole("alert")).toHaveTextContent("internal error");
});

it("invokes onDismiss after a successful publish", async () => {
const fetchMock = mockFetchResponse({ ok: true, status: 200, body: "" });
const onDismiss = vi.fn();

render(<DraftActionsWidget {...baseProps} onDismiss={onDismiss} />);
fireEvent.click(screen.getByRole("button", { name: /publish/i }));

await waitFor(() => expect(onDismiss).toHaveBeenCalledTimes(1));
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/canvases/canvas-1/versions/ver-1/publish",
expect.objectContaining({ method: "PATCH" }),
);
expect(errorSpy).not.toHaveBeenCalled();
});
});
124 changes: 90 additions & 34 deletions web_src/src/components/AgentSidebar/widgets/DraftActionsWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ export interface DraftActionsWidgetProps {
onDismiss?: () => void;
}

type DraftAction = "publish" | "discard";

// Server messages that indicate the widget is showing a stale draft (it was
// already published / discarded by someone else, or no longer exists). We
// silently dismiss in those cases so the user is not blocked by a stale UI.
const STALE_DRAFT_MARKERS = ["only draft versions can be published", "version not found", "version owner mismatch"];

export function DraftActionsWidget({
versionId,
message,
Expand All @@ -19,14 +26,16 @@ export function DraftActionsWidget({
isEditing,
onDismiss,
}: DraftActionsWidgetProps) {
const [busy, setBusy] = useState<"publish" | "discard" | null>(null);
const [busy, setBusy] = useState<DraftAction | null>(null);
const [error, setError] = useState<string | null>(null);

const handleViewInEditor = () => {
window.dispatchEvent(new CustomEvent("agent:view-version", { detail: { versionId } }));
};

const callApi = async (method: string, url: string, action: "publish" | "discard") => {
const callApi = async (method: string, url: string, action: DraftAction) => {
setBusy(action);
setError(null);
try {
const response = await fetch(url, {
method,
Expand All @@ -38,11 +47,29 @@ export function DraftActionsWidget({
});
if (response.ok) {
onDismiss?.();
} else {
const text = await response.text();
console.error(`${action} failed:`, response.status, text);
return;
}

const text = await response.text();
const apiMessage = extractApiMessage(text);

// 4xx responses are expected business-logic outcomes (e.g. the draft was
// already published from another tab/CLI). Surface them inline and, when
// the widget is clearly stale, dismiss it — but never `console.error`,
// since that ships noise to Sentry via captureConsoleIntegration.
if (response.status >= 400 && response.status < 500) {
if (isStaleDraftMessage(apiMessage)) {
onDismiss?.();
return;
}
setError(apiMessage ?? `Failed to ${action} (HTTP ${response.status}).`);
return;
}

setError(apiMessage ?? `Failed to ${action} (HTTP ${response.status}).`);
console.error(`${action} failed:`, response.status, text);
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${action}.`);
console.error(`Failed to ${action}:`, err);
} finally {
setBusy(null);
Expand All @@ -54,41 +81,70 @@ export function DraftActionsWidget({
const handleDiscard = () => callApi("DELETE", `/api/v1/canvases/${canvasId}/versions/${versionId}`, "discard");

return (
<div className="flex items-center gap-2">
{message && <span className="text-xs text-slate-600 flex-1 truncate">{message}</span>}
{!message && <span className="text-xs text-slate-600 flex-1">Draft ready</span>}
{!isEditing && (
<div className="flex flex-col gap-1">
{error && (
<div role="alert" className="text-xs text-red-600">
{error}
</div>
)}
<div className="flex items-center gap-2">
{message && <span className="text-xs text-slate-600 flex-1 truncate">{message}</span>}
{!message && <span className="text-xs text-slate-600 flex-1">Draft ready</span>}
{!isEditing && (
<Button
variant="outline"
size="sm"
onClick={handleViewInEditor}
className="text-xs h-7 gap-1"
disabled={busy !== null}
>
<Eye size={12} />
See in Editor
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleViewInEditor}
className="text-xs h-7 gap-1"
onClick={handleDiscard}
disabled={busy !== null}
className="text-xs h-7 gap-1 text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
>
<Eye size={12} />
See in Editor
<Trash2 size={12} />
{busy === "discard" ? "Discarding..." : "Discard"}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleDiscard}
disabled={busy !== null}
className="text-xs h-7 gap-1 text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
>
<Trash2 size={12} />
{busy === "discard" ? "Discarding..." : "Discard"}
</Button>
<Button
variant="default"
size="sm"
onClick={handlePublish}
disabled={busy !== null}
className="text-xs h-7 gap-1"
>
<Rocket size={12} />
{busy === "publish" ? "Publishing..." : "Publish"}
</Button>
<Button
variant="default"
size="sm"
onClick={handlePublish}
disabled={busy !== null}
className="text-xs h-7 gap-1"
>
<Rocket size={12} />
{busy === "publish" ? "Publishing..." : "Publish"}
</Button>
</div>
</div>
);
}

function extractApiMessage(text: string): string | null {
const trimmed = text.trim();
if (!trimmed) return null;

try {
const parsed = JSON.parse(trimmed) as { message?: unknown };
if (typeof parsed.message === "string" && parsed.message.trim().length > 0) {
return parsed.message.trim();
}
} catch {
// Body was not JSON; fall through to the raw text.
}

return trimmed;
}

function isStaleDraftMessage(message: string | null): boolean {
if (!message) return false;
const lower = message.toLowerCase();
return STALE_DRAFT_MARKERS.some((marker) => lower.includes(marker));
}
Loading