,
+) {
+ const element = item.element_snapshot;
+ if (!element) return "";
+ let label = element.tag;
+ if (element.id) label += `#${element.id}`;
+ else if (element.classes[0]) label += `.${element.classes[0]}`;
+ return `<${label}>`;
+}
+
+function feedbackEvidence(item: TaskPreviewFeedback) {
+ if (item.kind === "text") return item.selected_text ?? "";
+ if (item.kind === "element") return previewFeedbackElementLabel(item);
+ return item.screenshot_attachment?.name ?? "";
+}
+
+function FeedbackEvidence({ item }: { item: TaskPreviewFeedback }) {
+ const { t } = useTranslation();
+ if (item.kind === "screenshot" && item.screenshot_attachment) {
+ return (
+
+
})
+
{item.screenshot_attachment.name}
+
+ );
+ }
+ return {feedbackEvidence(item)}
;
+}
+
+function PendingFeedbackItem({
+ item,
+ collection,
+ touch,
+}: {
+ item: TaskPreviewFeedback;
+ collection: PreviewFeedbackCollectionController;
+ touch: boolean;
+}) {
+ const { t } = useTranslation();
+ const [editing, setEditing] = useState(false);
+ const [editComment, setEditComment] = useState("");
+ const actionClass = touch ? "h-11 min-w-11" : "h-7 min-w-7";
+
+ function startEditing() {
+ if (collection.isMutating) return;
+ setEditing(true);
+ setEditComment(item.comment);
+ }
+
+ async function saveEditing() {
+ const comment = editComment.trim();
+ if (!comment || collection.isMutating) return;
+ const result = await collection.update(item.id, comment, item.version);
+ if (result) setEditing(false);
+ }
+
+ return (
+
+
+
+
+ {item.source_label} · {item.page_route}
+
+
+
+
+
+
+ {editing ? (
+
+ ) : (
+ {item.comment}
+ )}
+
+ );
+}
+
+function CollectionHeader({
+ collection,
+ touch,
+}: {
+ collection: PreviewFeedbackCollectionController;
+ touch: boolean;
+}) {
+ const { t } = useTranslation();
+ const [confirmClear, setConfirmClear] = useState(false);
+ const actionClass = touch ? "h-11 min-w-11" : "h-7 min-w-7";
+
+ async function confirmClearCollection() {
+ if (collection.isMutating) return;
+ const result = await collection.clear(collection.snapshot?.revision ?? 0);
+ if (result) setConfirmClear(false);
+ }
+
+ return (
+ <>
+
+
{t("task:previewPendingFeedback")}
+ {collection.items.length > 0 && !confirmClear && (
+
+ )}
+
+ {confirmClear && (
+
+
+ {t("task:previewConfirmClearDescription")}
+
+
+
+
+
+
+ )}
+ >
+ );
+}
+
+function CollectionStatus({
+ collection,
+ showEmpty,
+ showErrors,
+}: {
+ collection: PreviewFeedbackCollectionController;
+ showEmpty: boolean;
+ showErrors: boolean;
+}) {
+ const { t } = useTranslation();
+ return (
+ <>
+ {collection.isLoading && collection.items.length === 0 && (
+
+ {t("task:previewLoadingFeedback")}
+
+ )}
+ {showErrors && collection.loadError && (
+
+ {t("task:previewLoadFailed")}
+
+ )}
+ {showErrors && collection.mutationError && (
+
+ {t("task:previewMutationFailed")}
+
+ )}
+ {showEmpty && !collection.isLoading && collection.items.length === 0 && (
+
+ {t("task:previewPendingEmpty")}
+
+ )}
+ >
+ );
+}
+
+export function PreviewFeedbackCollection({
+ collection,
+ touch,
+ showEmpty = true,
+ showErrors = true,
+}: {
+ collection: PreviewFeedbackCollectionController;
+ touch: boolean;
+ showEmpty?: boolean;
+ showErrors?: boolean;
+}) {
+ return (
+
+
+
+
+ {collection.items.map((item) => (
+
+ ))}
+
+
+ );
+}
+
+type CollectionSurfaceProps = {
+ taskId: string | null | undefined;
+ collection: PreviewFeedbackCollectionController;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ showTrigger?: boolean;
+};
+
+function CollectionTrigger({
+ collection,
+ touch,
+ title,
+ countLabel,
+ showTrigger,
+ focusReturnRef,
+ onOpen,
+}: {
+ collection: PreviewFeedbackCollectionController;
+ touch: boolean;
+ title: string;
+ countLabel: string;
+ showTrigger: boolean;
+ focusReturnRef: MutableRefObject;
+ onOpen: () => void;
+}) {
+ if (!showTrigger) return null;
+ return (
+
+ );
+}
+
+function TouchCollectionSurface({
+ collection,
+ open,
+ onOpenChange,
+ title,
+ closeFocus,
+}: {
+ collection: PreviewFeedbackCollectionController;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ closeFocus: (event: Event) => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ }
+ >
+
+
+ );
+}
+
+function DesktopCollectionSurface({
+ collection,
+ open,
+ onOpenChange,
+ title,
+ closeFocus,
+ trigger,
+}: {
+ collection: PreviewFeedbackCollectionController;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ closeFocus: (event: Event) => void;
+ trigger: ReactNode;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {trigger}
+
+
+
+
+
+
{title}
+
+
+
+
+
+
+ );
+}
+
+export function PreviewFeedbackCollectionSurface({
+ taskId,
+ collection,
+ open,
+ onOpenChange,
+ showTrigger = false,
+}: CollectionSurfaceProps) {
+ const { t } = useTranslation();
+ const touch = useTouchDrawer();
+ const focusReturnRef = useRef(null);
+ const title = t("task:previewFeedbackTitle");
+ const countLabel = t("task:previewFeedbackCount", { count: collection.items.length });
+
+ useEffect(() => {
+ if (!open) return;
+ if (document.activeElement instanceof HTMLElement) {
+ focusReturnRef.current = document.activeElement;
+ }
+ }, [open]);
+
+ if (!taskId) return null;
+
+ const rememberFocusAndOpen = () => {
+ if (document.activeElement instanceof HTMLElement) {
+ focusReturnRef.current = document.activeElement;
+ }
+ onOpenChange(true);
+ };
+ const trigger = (
+
+ );
+ const closeFocus = createFocusReturnHandler(focusReturnRef);
+
+ if (touch) {
+ return (
+ <>
+ {trigger}
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/apps/web/components/task/inspector/preview-feedback-controls.integration.test.tsx b/apps/web/components/task/inspector/preview-feedback-controls.integration.test.tsx
new file mode 100644
index 0000000000..9836511eb5
--- /dev/null
+++ b/apps/web/components/task/inspector/preview-feedback-controls.integration.test.tsx
@@ -0,0 +1,159 @@
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { INSPECTOR_PROTOCOL_VERSION } from "@/lib/preview-inspect-bridge";
+import type { TaskPreviewFeedback } from "@/lib/types/http";
+
+const screenshotComment = "Keep this screenshot";
+
+const feedback = vi.hoisted(() => ({
+ items: [] as TaskPreviewFeedback[],
+ snapshot: { task_id: "task-1", revision: 0, items: [] as TaskPreviewFeedback[] },
+ create: vi.fn(),
+ update: vi.fn(),
+ remove: vi.fn(),
+ clear: vi.fn(),
+ isMutating: false,
+ mutationError: null as string | null,
+}));
+vi.mock("@/hooks/domains/comments/use-preview-feedback", () => ({
+ usePreviewFeedback: () => feedback,
+}));
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: unknown) => unknown) =>
+ selector({
+ kanban: { tasks: [{ id: "task-1", workspaceId: "workspace-1" }] },
+ kanbanMulti: { snapshots: {} },
+ workspaces: { activeId: "workspace-1" },
+ }),
+}));
+
+const attachments = vi.hoisted(() => ({
+ uploadAttachment: vi.fn(),
+ deleteAttachment: vi.fn(),
+}));
+vi.mock("@/lib/api/domains/attachment-api", () => attachments);
+
+const html2canvas = vi.hoisted(() => vi.fn());
+vi.mock("html2canvas-pro", () => ({ default: html2canvas }));
+
+const bridge = vi.hoisted(() => ({
+ sendSetPreviewCaptureMode: vi.fn(),
+ sendProjectPreviewMarkers: vi.fn(),
+}));
+vi.mock("@/lib/preview-inspect-bridge", async () => {
+ const actual = await vi.importActual(
+ "@/lib/preview-inspect-bridge",
+ );
+ return { ...actual, ...bridge };
+});
+
+vi.mock("@/hooks/use-compact-task-chrome", () => ({
+ useTouchDrawer: () => false,
+}));
+
+import { PreviewFeedbackControls } from "./preview-feedback-controls";
+import { usePreviewCapture } from "@/hooks/use-preview-capture";
+
+function Harness({ iframeRef }: { iframeRef: React.RefObject }) {
+ const capture = usePreviewCapture({
+ taskId: "task-1",
+ iframeRef,
+ source: { kind: "browser", sessionId: "session-1", label: "Local app" },
+ enabled: true,
+ });
+ return ;
+}
+
+function dispatch(iframe: HTMLIFrameElement, data: unknown) {
+ window.dispatchEvent(new MessageEvent("message", { source: iframe.contentWindow, data }));
+}
+
+describe("PreviewFeedbackControls with the capture controller", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ vi.clearAllMocks();
+ feedback.items = [];
+ feedback.snapshot = { task_id: "task-1", revision: 0, items: [] };
+ feedback.create.mockResolvedValue(null);
+ attachments.uploadAttachment.mockResolvedValue({ attachment_id: "attachment-1" });
+ attachments.deleteAttachment.mockResolvedValue(undefined);
+ html2canvas.mockResolvedValue({
+ width: 320,
+ height: 180,
+ toBlob: (callback: BlobCallback) =>
+ callback(
+ new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], { type: "image/png" }),
+ ),
+ });
+ Object.defineProperty(URL, "createObjectURL", {
+ configurable: true,
+ value: vi.fn(() => "blob:preview"),
+ });
+ Object.defineProperty(URL, "revokeObjectURL", {
+ configurable: true,
+ value: vi.fn(),
+ });
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("retains the comment and uploaded PNG after create fails, then retries without upload", async () => {
+ const iframe = document.createElement("iframe");
+ document.body.appendChild(iframe);
+ const iframeRef = { current: iframe };
+ render();
+
+ let resolveCreate: ((value: null) => void) | undefined;
+ feedback.create.mockImplementation(
+ () => new Promise((resolve) => (resolveCreate = resolve)),
+ );
+
+ act(() =>
+ dispatch(iframe, {
+ source: "kandev-inspector",
+ version: INSPECTOR_PROTOCOL_VERSION,
+ type: "screenshot-region-selected",
+ payload: {
+ page_route: "/checkout",
+ page_title: "Checkout",
+ capture_rect: { x: 0, y: 0, width: 320, height: 180 },
+ },
+ }),
+ );
+ await waitFor(() => expect(screen.getByTestId("preview-feedback-draft")).toBeTruthy());
+
+ const comment = screen.getByLabelText("Comment on selection");
+ fireEvent.change(comment, { target: { value: screenshotComment } });
+ fireEvent.click(screen.getByRole("button", { name: "Save feedback" }));
+
+ await waitFor(() =>
+ expect(feedback.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ comment: screenshotComment,
+ screenshotAttachmentId: "attachment-1",
+ }),
+ ),
+ );
+ await waitFor(() => expect(resolveCreate).toBeDefined());
+ await act(async () => resolveCreate?.(null));
+ expect((screen.getByLabelText("Comment on selection") as HTMLTextAreaElement).value).toBe(
+ screenshotComment,
+ );
+ expect(screen.getByRole("img", { name: "Screenshot preview" }).getAttribute("src")).toBe(
+ "blob:preview",
+ );
+
+ feedback.create.mockResolvedValue(null);
+ fireEvent.click(screen.getByRole("button", { name: "Save feedback" }));
+ await waitFor(() => expect(feedback.create).toHaveBeenCalledTimes(2));
+ expect(attachments.uploadAttachment).toHaveBeenCalledTimes(1);
+ expect(feedback.create).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ comment: screenshotComment,
+ screenshotAttachmentId: "attachment-1",
+ }),
+ );
+ });
+});
diff --git a/apps/web/components/task/inspector/preview-feedback-controls.test.tsx b/apps/web/components/task/inspector/preview-feedback-controls.test.tsx
new file mode 100644
index 0000000000..5594ebdcef
--- /dev/null
+++ b/apps/web/components/task/inspector/preview-feedback-controls.test.tsx
@@ -0,0 +1,210 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { TaskPreviewFeedback } from "@/lib/types/http";
+
+const drawerMode = vi.hoisted(() => ({ value: false }));
+vi.mock("@/hooks/use-compact-task-chrome", () => ({
+ useTouchDrawer: () => drawerMode.value,
+}));
+
+vi.mock("@kandev/ui/popover", async () => {
+ const React = await import("react");
+ const Context = React.createContext<{ open: boolean; setOpen: (open: boolean) => void } | null>(
+ null,
+ );
+ return {
+ Popover: ({
+ open,
+ onOpenChange,
+ children,
+ }: React.PropsWithChildren<{ open: boolean; onOpenChange: (open: boolean) => void }>) => (
+ {children}
+ ),
+ PopoverTrigger: ({ children }: React.PropsWithChildren) => {
+ const value = React.useContext(Context)!;
+ return React.cloneElement(children as React.ReactElement<{ onClick?: () => void }>, {
+ onClick: () => value.setOpen(!value.open),
+ });
+ },
+ PopoverContent: ({ children, ...props }: React.HTMLAttributes) => {
+ const value = React.useContext(Context)!;
+ return value.open ? {children}
: null;
+ },
+ };
+});
+
+vi.mock("@kandev/ui/drawer", async () => {
+ const React = await import("react");
+ const Context = React.createContext<{ open: boolean; setOpen: (open: boolean) => void } | null>(
+ null,
+ );
+ return {
+ Drawer: ({
+ open,
+ onOpenChange,
+ children,
+ }: React.PropsWithChildren<{ open: boolean; onOpenChange: (open: boolean) => void }>) => (
+ {children}
+ ),
+ DrawerContent: ({ children, ...props }: React.HTMLAttributes) => {
+ const value = React.useContext(Context)!;
+ return value.open ? {children}
: null;
+ },
+ DrawerHeader: (props: React.HTMLAttributes) => ,
+ DrawerTitle: (props: React.HTMLAttributes) => ,
+ };
+});
+
+import {
+ PreviewFeedbackControls,
+ type PreviewFeedbackController,
+} from "./preview-feedback-controls";
+
+const item: TaskPreviewFeedback = {
+ id: "feedback-1",
+ task_id: "task-1",
+ kind: "element",
+ comment: "Align this with the price",
+ source_kind: "browser",
+ source_session_id: "session-1",
+ source_label: "Local app",
+ page_route: "/products",
+ page_title: "Products",
+ element_snapshot: {
+ tag: "button",
+ id: "checkout",
+ classes: ["primary"],
+ outer_html: '',
+ },
+ version: 2,
+ created_at: "2026-09-15T00:00:00Z",
+ updated_at: "2026-09-15T00:00:00Z",
+};
+
+function controller(overrides: Partial = {}): PreviewFeedbackController {
+ const value: PreviewFeedbackController = {
+ items: [item],
+ snapshot: { task_id: "task-1", revision: 3, items: [item] },
+ mode: null,
+ draft: null,
+ draftComment: "",
+ setDraftComment: vi.fn(),
+ candidateLabel: null,
+ captureError: null,
+ isRasterizing: false,
+ isUploading: false,
+ isMutating: false,
+ mutationError: null,
+ startCapture: vi.fn(),
+ cancelCapture: vi.fn(),
+ discardDraft: vi.fn(),
+ saveDraft: vi.fn(),
+ update: vi.fn(),
+ remove: vi.fn(),
+ clear: vi.fn(),
+ ...overrides,
+ };
+ if (!overrides.setDraftComment) {
+ value.setDraftComment = vi.fn((comment: string) => {
+ value.draftComment = comment;
+ });
+ }
+ return value;
+}
+
+describe("PreviewFeedbackControls", () => {
+ afterEach(cleanup);
+
+ beforeEach(() => {
+ drawerMode.value = false;
+ });
+
+ it("offers text and visually hinted element selection from a desktop review popover", () => {
+ const capture = controller();
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /Annotate.*1/i }));
+ expect(screen.getByTestId("preview-feedback-popover")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Select element" }));
+ expect(capture.startCapture).toHaveBeenCalledWith("element");
+ });
+
+ it("offers screenshot-region mode and shows the actual PNG before saving", () => {
+ const capture = controller({
+ items: [],
+ snapshot: { task_id: "task-1", revision: 0, items: [] },
+ draft: {
+ kind: "screenshot",
+ page_route: "/checkout",
+ page_title: "Checkout",
+ capture_rect: { x: 20, y: 40, width: 300, height: 180 },
+ screenshot: {
+ blob: new Blob([], { type: "image/png" }),
+ width: 600,
+ height: 360,
+ previewUrl: "blob:preview-screenshot",
+ fileName: "preview.png",
+ },
+ },
+ });
+ render();
+
+ const preview = screen.getByRole("img", { name: "Screenshot preview" });
+ expect(preview.getAttribute("src")).toBe("blob:preview-screenshot");
+ expect(screen.getByText("600 × 360 · PNG")).toBeTruthy();
+ });
+
+ it("keeps the captured evidence while collecting the required comment", async () => {
+ const capture = controller({
+ items: [],
+ snapshot: { task_id: "task-1", revision: 0, items: [] },
+ draft: {
+ kind: "text",
+ page_route: "/checkout",
+ page_title: "Checkout",
+ selected_text: "$42.00",
+ text_anchor: {
+ start: { node_path: [0], offset: 0 },
+ end: { node_path: [0], offset: 6 },
+ },
+ },
+ });
+ const { rerender } = render();
+
+ expect(screen.getByText("$42.00")).toBeTruthy();
+ const comment = screen.getByLabelText("Comment on selection");
+ fireEvent.change(comment, { target: { value: "Keep the generated total visible" } });
+ rerender();
+ fireEvent.click(screen.getByRole("button", { name: "Save feedback" }));
+ expect(capture.saveDraft).toHaveBeenCalledWith("Keep the generated total visible");
+ });
+
+ it("reuses the saved collection editor for item management", () => {
+ const capture = controller();
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /Annotate.*1/i }));
+ fireEvent.click(screen.getByRole("button", { name: "Edit feedback" }));
+ const editor = screen.getByRole("textbox", { name: "Edit comment" });
+ fireEvent.change(editor, { target: { value: "Keep the button aligned" } });
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
+
+ expect(capture.update).toHaveBeenCalledWith("feedback-1", "Keep the button aligned", 2);
+ });
+
+ it("uses a touch-sized drawer entry and exposes the same capture choices", () => {
+ drawerMode.value = true;
+ const capture = controller();
+ render();
+
+ const trigger = screen.getByRole("button", { name: /Annotate.*1/i });
+ expect(trigger.className).toContain("h-11");
+ fireEvent.click(trigger);
+ expect(screen.getByTestId("preview-feedback-drawer")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Select text" }));
+ expect(capture.startCapture).toHaveBeenCalledWith("text");
+ fireEvent.click(screen.getByRole("button", { name: /Annotate.*1/i }));
+ fireEvent.click(screen.getByRole("button", { name: "Select screenshot region" }));
+ expect(capture.startCapture).toHaveBeenCalledWith("screenshot");
+ });
+});
diff --git a/apps/web/components/task/inspector/preview-feedback-controls.tsx b/apps/web/components/task/inspector/preview-feedback-controls.tsx
new file mode 100644
index 0000000000..19c7b3782c
--- /dev/null
+++ b/apps/web/components/task/inspector/preview-feedback-controls.tsx
@@ -0,0 +1,337 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import {
+ IconClick,
+ IconMessagePlus,
+ IconScreenshot,
+ IconTextScan2,
+ IconX,
+} from "@tabler/icons-react";
+import { Button } from "@kandev/ui/button";
+import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
+import { Textarea } from "@kandev/ui/textarea";
+import { useTranslation } from "react-i18next";
+import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
+import type { PreviewCaptureMode } from "@/lib/preview-inspect-bridge";
+import type { PreviewFeedbackDraft } from "@/hooks/use-preview-capture";
+import {
+ PreviewFeedbackCollection,
+ previewFeedbackElementLabel,
+ type PreviewFeedbackCollectionController,
+} from "./preview-feedback-collection";
+
+export type PreviewFeedbackController = PreviewFeedbackCollectionController & {
+ mode: PreviewCaptureMode | null;
+ draft: PreviewFeedbackDraft | null;
+ draftComment: string;
+ setDraftComment: (comment: string) => void;
+ candidateLabel: string | null;
+ captureError: "raster" | "upload" | "workspace" | null;
+ isRasterizing: boolean;
+ isUploading: boolean;
+ isMutating: boolean;
+ mutationError: string | null;
+ startCapture: (mode: PreviewCaptureMode) => void;
+ cancelCapture: () => void;
+ discardDraft: () => void;
+ saveDraft: (comment: string) => Promise;
+};
+
+type PreviewFeedbackControlsProps = {
+ capture: PreviewFeedbackController;
+ enabled: boolean;
+};
+
+function draftEvidence(draft: PreviewFeedbackDraft) {
+ if (draft.kind === "text") return draft.selected_text ?? "";
+ if (draft.kind === "screenshot") return "";
+ const element = draft.element_snapshot;
+ if (!element) return "";
+ return previewFeedbackElementLabel({ kind: "element", element_snapshot: element });
+}
+
+function CaptureChoices({
+ capture,
+ onChoose,
+ touch,
+}: {
+ capture: PreviewFeedbackController;
+ onChoose: (mode: PreviewCaptureMode) => void;
+ touch: boolean;
+}) {
+ const { t } = useTranslation();
+ const buttonClass = touch ? "h-11 min-w-0 gap-1 px-2 text-xs" : "h-8 min-w-0 gap-1 px-2 text-xs";
+ return (
+
+
+
+
+ {capture.mode && (
+
+ )}
+
+ );
+}
+
+function DraftEditor({ capture, touch }: { capture: PreviewFeedbackController; touch: boolean }) {
+ const { t } = useTranslation();
+ if (!capture.draft) return null;
+
+ return (
+
+
+
{capture.draft.page_route}
+ {capture.draft.kind === "screenshot" ? (
+
+

+
+ {t("task:previewScreenshotDimensions", {
+ width: capture.draft.screenshot.width,
+ height: capture.draft.screenshot.height,
+ })}
+
+
+ ) : (
+
+ {draftEvidence(capture.draft)}
+
+ )}
+
+
+ );
+}
+
+function FeedbackSurface({
+ capture,
+ touch,
+ onChoose,
+}: {
+ capture: PreviewFeedbackController;
+ touch: boolean;
+ onChoose: (mode: PreviewCaptureMode) => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {capture.isRasterizing && (
+
+ {t("task:previewRasterizing")}
+
+ )}
+
+ {(capture.mutationError || capture.captureError) && (
+
+ {capture.captureError
+ ? t("task:previewScreenshotFailed")
+ : t("task:previewMutationFailed")}
+
+ )}
+
+
+ );
+}
+
+function Trigger({
+ capture,
+ enabled,
+ touch,
+ onClick,
+}: PreviewFeedbackControlsProps & { touch: boolean; onClick?: () => void }) {
+ const { t } = useTranslation();
+ const label = t("task:previewAnnotateWithCount", { count: capture.items.length });
+ return (
+
+ );
+}
+
+export function PreviewFeedbackControls({ capture, enabled }: PreviewFeedbackControlsProps) {
+ const { t } = useTranslation();
+ const touch = useTouchDrawer();
+ const [open, setOpen] = useState(false);
+ const desktopRootRef = useRef(null);
+ const [desktopMaxHeight, setDesktopMaxHeight] = useState(576);
+
+ useEffect(() => {
+ if (capture.draft || capture.isRasterizing || capture.captureError) setOpen(true);
+ }, [capture.captureError, capture.draft, capture.isRasterizing]);
+
+ useEffect(() => {
+ if (!open || touch) return;
+ const root = desktopRootRef.current;
+ const updateHeight = () => {
+ const bottom = root?.getBoundingClientRect().bottom ?? 0;
+ setDesktopMaxHeight(Math.max(96, window.innerHeight - bottom - 8));
+ };
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setOpen(false);
+ };
+ const closeOnOutsidePointer = (event: PointerEvent) => {
+ if (root && !event.composedPath().includes(root)) setOpen(false);
+ };
+ updateHeight();
+ window.addEventListener("resize", updateHeight);
+ document.addEventListener("keydown", closeOnEscape);
+ document.addEventListener("pointerdown", closeOnOutsidePointer);
+ return () => {
+ window.removeEventListener("resize", updateHeight);
+ document.removeEventListener("keydown", closeOnEscape);
+ document.removeEventListener("pointerdown", closeOnOutsidePointer);
+ };
+ }, [open, touch]);
+
+ function choose(mode: PreviewCaptureMode) {
+ capture.startCapture(mode);
+ setOpen(false);
+ }
+
+ const candidateStatus = (
+
+ {capture.candidateLabel
+ ? t("task:previewCandidate", { candidate: capture.candidateLabel })
+ : ""}
+
+ );
+
+ if (touch) {
+ return (
+
+ setOpen(true)} />
+
+
+ {t("task:previewFeedbackTitle")}
+
+
+
+ {candidateStatus}
+
+ );
+ }
+
+ return (
+
+
setOpen((current) => !current)}
+ />
+ {open && (
+
+
+
{t("task:previewFeedbackTitle")}
+
+
+
+ )}
+ {candidateStatus}
+
+ );
+}
diff --git a/apps/web/components/task/mobile/mobile-file-viewer-panel.test.tsx b/apps/web/components/task/mobile/mobile-file-viewer-panel.test.tsx
index d0a9ff9fee..b9015615d0 100644
--- a/apps/web/components/task/mobile/mobile-file-viewer-panel.test.tsx
+++ b/apps/web/components/task/mobile/mobile-file-viewer-panel.test.tsx
@@ -25,7 +25,18 @@ const state = {
},
},
tasks: { activeTaskId: "task-1" },
+ kanban: { tasks: [] },
+ kanbanMulti: { snapshots: {} },
+ workspaces: { activeId: null },
+ previewFeedback: {
+ byTaskId: {},
+ loadedByTaskId: {},
+ loadingByTaskId: {},
+ errorByTaskId: {},
+ },
+ connection: { status: "disconnected" },
};
+const storeApi = { getState: () => state };
const README_CONTENT = "# README";
const REPORT_CONTENT = "Report
";
const README_PATH = "README.md";
@@ -34,6 +45,7 @@ const FILE_CONTENT_TEST_ID = "file-content";
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (value: typeof state) => unknown) => selector(state),
+ useAppStoreApi: () => storeApi,
}));
vi.mock("@/components/editors/external-vcs-file-link", () => ({
diff --git a/apps/web/components/task/passthrough-chat-composer.tsx b/apps/web/components/task/passthrough-chat-composer.tsx
index dfe4cf6440..bcdc1cd163 100644
--- a/apps/web/components/task/passthrough-chat-composer.tsx
+++ b/apps/web/components/task/passthrough-chat-composer.tsx
@@ -31,6 +31,7 @@ import type { TaskPlanCommentRef } from "@/lib/types/http";
import { isMessageSendError, MessageSendError } from "@/lib/chat/message-send-error";
import { t as translate } from "@/lib/i18n";
import { PlanCommentMigrationNotice } from "@/components/task/plan-comment-migration-notice";
+import { PreviewFeedbackCollectionSurface } from "@/components/task/inspector/preview-feedback-collection";
const PLAN_CONTEXT_PATH = "plan:context";
@@ -80,6 +81,18 @@ export function PassthroughComposerPanel({
}}
>
+ {panelState.previewFeedbackState && (
+ undefined)}
+ showTrigger={Boolean(
+ panelState.previewFeedback.length > 0 &&
+ (!panelState.resolvedSessionId || panelState.isCompleted),
+ )}
+ />
+ )}
{
const remoteDir = path.join(root, "fixture", `${name}.git`);
const checkout = path.join(root, `${name}-checkout`);
+ const emptyHooksDir = path.join(root, ".empty-git-hooks");
fs.mkdirSync(checkout, { recursive: true });
+ fs.mkdirSync(emptyHooksDir, { recursive: true });
execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]);
// The fixture is served as static (dumb) HTTP. Keep its advertised refs in
// sync when E2E tests push additional commits before launching a task.
const postUpdateHook = path.join(remoteDir, "hooks", "post-update");
fs.writeFileSync(postUpdateHook, "#!/bin/sh\nexec git update-server-info\n");
fs.chmodSync(postUpdateHook, 0o755);
+ execFileSync("git", [
+ "--git-dir",
+ remoteDir,
+ "config",
+ "core.hooksPath",
+ path.dirname(postUpdateHook),
+ ]);
execFileSync("git", ["init", "-b", "main"], { cwd: checkout });
+ execFileSync("git", ["config", "core.hooksPath", emptyHooksDir], { cwd: checkout });
fs.writeFileSync(path.join(checkout, "remote-source.txt"), `${name} fixture\n`);
execFileSync("git", ["add", "."], { cwd: checkout });
execFileSync(
diff --git a/apps/web/e2e/helpers/session-entry-recovery.ts b/apps/web/e2e/helpers/session-entry-recovery.ts
index 14a50ec46f..cac0651afd 100644
--- a/apps/web/e2e/helpers/session-entry-recovery.ts
+++ b/apps/web/e2e/helpers/session-entry-recovery.ts
@@ -15,8 +15,10 @@ type DelayRule = {
export type SessionEntryRecoveryProxy = {
delayNextResponses: (action: string, count: number, delayMs: number, reason: string) => void;
+ dropNextResponses: (action: string, count: number) => void;
requestCount: (action: string) => number;
delayedResponseCount: (action: string) => number;
+ droppedResponseCount: (action: string) => number;
};
function parseFrame(value: string): WireFrame | null {
@@ -41,16 +43,59 @@ function responseAction(
return undefined;
}
+function takeResponseAction(
+ frame: WireFrame | null,
+ requestActions: Map,
+): string | undefined {
+ const action = responseAction(frame, requestActions);
+ if (typeof frame?.id === "string") requestActions.delete(frame.id);
+ return action;
+}
+
+function consumeDropRule(
+ action: string | undefined,
+ dropRules: Map,
+ droppedCounts: Map,
+): boolean {
+ if (!action) return false;
+ const remaining = dropRules.get(action) ?? 0;
+ if (remaining < 1) return false;
+ dropRules.set(action, remaining - 1);
+ droppedCounts.set(action, (droppedCounts.get(action) ?? 0) + 1);
+ return true;
+}
+
+function consumeDelayRule(
+ action: string | undefined,
+ message: string,
+ rules: Map,
+ delayedCounts: Map,
+ send: (message: string) => void,
+): boolean {
+ if (!action) return false;
+ const rule = rules.get(action);
+ if (!rule || rule.remaining < 1) return false;
+ rule.remaining -= 1;
+ delayedCounts.set(action, (delayedCounts.get(action) ?? 0) + 1);
+ void (async () => {
+ await injectLatency(rule.delayMs, rule.reason);
+ send(message);
+ })();
+ return true;
+}
+
/**
- * Delay selected gateway responses while forwarding every other frame. Rules
- * correlate replies by request id, so the test never relies on action-only or
- * payload timing and does not inspect message contents.
+ * Delay or drop selected gateway responses while forwarding every other frame.
+ * Rules correlate replies by request id, so the test never relies on
+ * action-only or payload timing and does not inspect message contents.
*/
export async function routeSessionEntryRecovery(page: Page): Promise {
const requestActions = new Map();
const requestCounts = new Map();
const delayedCounts = new Map();
+ const droppedCounts = new Map();
const rules = new Map();
+ const dropRules = new Map();
await page.routeWebSocket(/\/ws$/, (ws) => {
const server = ws.connectToServer();
@@ -82,18 +127,10 @@ export async function routeSessionEntryRecovery(page: Page): Promise 0 && action) {
- rule.remaining -= 1;
- delayedCounts.set(action, (delayedCounts.get(action) ?? 0) + 1);
- void (async () => {
- await injectLatency(rule.delayMs, rule.reason);
- ws.send(trimmed);
- })();
- continue;
+ const action = takeResponseAction(frame, requestActions);
+ if (isResponseFrame(frame)) {
+ if (consumeDropRule(action, dropRules, droppedCounts)) continue;
+ if (consumeDelayRule(action, trimmed, rules, delayedCounts, ws.send.bind(ws))) continue;
}
ws.send(trimmed);
@@ -107,7 +144,12 @@ export async function routeSessionEntryRecovery(page: Page): Promise {
+ if (count < 1) throw new Error("dropNextResponses requires a positive response count");
+ dropRules.set(action, count);
+ },
requestCount: (action) => requestCounts.get(action) ?? 0,
delayedResponseCount: (action) => delayedCounts.get(action) ?? 0,
+ droppedResponseCount: (action) => droppedCounts.get(action) ?? 0,
};
}
diff --git a/apps/web/e2e/helpers/ws-drop.ts b/apps/web/e2e/helpers/ws-drop.ts
index 703943feed..8145c09b67 100644
--- a/apps/web/e2e/helpers/ws-drop.ts
+++ b/apps/web/e2e/helpers/ws-drop.ts
@@ -16,6 +16,17 @@ type MessageAddResponseDropController = {
droppedCount: () => number;
};
+type PreviewFeedbackCreateFailureController = {
+ failNextCreate: () => void;
+ failedCount: () => number;
+};
+
+type ExpiredPluginSnapshotController = {
+ expireNextPluginSnapshot: () => void;
+ modifiedCount: () => number;
+ pluginSubscribeCount: () => number;
+};
+
type ConversationChangeDropController = {
dropChange: (content: string) => void;
droppedCount: () => number;
@@ -280,6 +291,64 @@ export async function routeMainWebSocketWithMessageAddResponseDrop(
};
}
+/**
+ * Rejects one preview-feedback create request before it reaches the backend.
+ * The next request is forwarded normally, so a screenshot draft can exercise
+ * create failure and retry without leaving a server-side row behind.
+ */
+export async function routeMainWebSocketWithPreviewFeedbackCreateFailure(
+ page: Page,
+): Promise {
+ const state = { armed: false, failed: 0 };
+
+ await page.routeWebSocket(/\/ws$/, (ws) => {
+ const server = ws.connectToServer();
+ ws.onMessage((message) => {
+ if (typeof message !== "string") {
+ server.send(message);
+ return;
+ }
+
+ const forwarded: string[] = [];
+ for (const part of message.split("\n")) {
+ const frame = parseQueueAdmissionFrame(part);
+ if (
+ state.armed &&
+ frame?.type === "request" &&
+ frame.action === "task.preview_feedback.create" &&
+ typeof frame.id === "string"
+ ) {
+ state.armed = false;
+ state.failed += 1;
+ ws.send(
+ JSON.stringify({
+ type: "error",
+ id: frame.id,
+ action: frame.action,
+ payload: {
+ code: "INTERNAL_ERROR",
+ message: "Injected preview feedback create failure",
+ },
+ }),
+ );
+ continue;
+ }
+ forwarded.push(part);
+ }
+ const next = forwarded.join("\n");
+ if (next.trim()) server.send(next);
+ });
+ server.onMessage((message) => ws.send(message));
+ });
+
+ return {
+ failNextCreate: () => {
+ state.armed = true;
+ },
+ failedCount: () => state.failed,
+ };
+}
+
/**
* Injects one pre-server request loss or one post-admission response loss for
* `message.queue.add`. Every other gateway frame continues through the proxy.
@@ -413,6 +482,88 @@ export async function routeMainWebSocketWithQueueAdmissionDrops(
};
}
+/**
+ * Rewrites one plugin subscription expiry in the browser transport. The
+ * signed token remains server-valid, so the panel must exercise its normal
+ * fresh rebind path instead of relying on a relaxed backend validation rule.
+ */
+export async function routeMainWebSocketWithExpiredPluginSnapshot(
+ page: Page,
+): Promise {
+ const requestIDs = new Set();
+ let armed = false;
+ let modified = 0;
+ let pluginSubscribeRequests = 0;
+
+ await page.routeWebSocket(/\/ws$/, (ws) => {
+ const server = ws.connectToServer();
+ ws.onMessage((message) => {
+ for (const frame of parseJSONFrames(message)) {
+ const payload = asRecord(frame.payload);
+ if (
+ frame.type === "request" &&
+ frame.action === "session.subscribe" &&
+ typeof frame.id === "string" &&
+ payload?.consumer_kind === "plugin"
+ ) {
+ pluginSubscribeRequests += 1;
+ if (armed) requestIDs.add(frame.id);
+ }
+ }
+ server.send(message);
+ });
+ server.onMessage((message) => {
+ if (typeof message !== "string") {
+ ws.send(message);
+ return;
+ }
+ const rewritten: string[] = [];
+ let didRewrite = false;
+ for (const part of message.split("\n")) {
+ const trimmed = part.trim();
+ if (!trimmed) {
+ rewritten.push(part);
+ continue;
+ }
+ let frame: Record | null = null;
+ try {
+ frame = asRecord(JSON.parse(trimmed));
+ } catch {
+ // Preserve non-JSON frames.
+ }
+ if (
+ armed &&
+ frame?.type === "response" &&
+ frame.action === "session.subscribe" &&
+ typeof frame.id === "string" &&
+ requestIDs.delete(frame.id)
+ ) {
+ const payload = asRecord(frame.payload);
+ if (payload?.success === true) {
+ frame.payload = { ...payload, expires_at: "2000-01-01T00:00:00Z" };
+ rewritten.push(JSON.stringify(frame));
+ armed = false;
+ modified += 1;
+ didRewrite = true;
+ continue;
+ }
+ }
+ rewritten.push(part);
+ }
+ ws.send(didRewrite ? rewritten.join("\n") : message);
+ });
+ });
+
+ return {
+ expireNextPluginSnapshot: () => {
+ requestIDs.clear();
+ armed = true;
+ },
+ modifiedCount: () => modified,
+ pluginSubscribeCount: () => pluginSubscribeRequests,
+ };
+}
+
function hasConversationChangeContent(message: unknown, content: string): boolean {
const envelope = asRecord(message);
if (envelope?.action !== "session.conversation.changed") {
diff --git a/apps/web/e2e/scripts/run-e2e.test.ts b/apps/web/e2e/scripts/run-e2e.test.ts
index 0f1c832079..420d0e1f7a 100644
--- a/apps/web/e2e/scripts/run-e2e.test.ts
+++ b/apps/web/e2e/scripts/run-e2e.test.ts
@@ -25,6 +25,7 @@ function runnerEnv(binDir: string, extra: Record = {}): NodeJS.P
delete env.KANDEV_E2E_CONTAINERS;
delete env.KANDEV_E2E_DOCKER;
delete env.CAPTURE_PR_ASSETS;
+ delete env.BASH_ENV;
return env;
}
@@ -206,11 +207,7 @@ describe("run-e2e.sh", () => {
const result = spawnSync("bash", [rawScriptPath, "--project=docker", "--help"], {
encoding: "utf8",
- env: {
- ...process.env,
- KANDEV_E2E_ALLOW_UNSAFE_PARALLELISM: "",
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- },
+ env: runnerEnv(binDir),
});
expect(result.status).toBe(0);
@@ -229,17 +226,15 @@ describe("run-e2e.sh", () => {
);
fs.chmodSync(pnpmPath, 0o755);
+ const env = runnerEnv(binDir);
+ env.KANDEV_E2E_CONTAINERS = "1";
+ env.KANDEV_E2E_DOCKER = "1";
const result = spawnSync(
"bash",
[scriptPath, "--host", "--no-build", "--project", "chromium", "--", "--help"],
{
encoding: "utf8",
- env: {
- ...process.env,
- PATH: `${binDir}:${process.env.PATH ?? ""}`,
- KANDEV_E2E_CONTAINERS: "1",
- KANDEV_E2E_DOCKER: "1",
- },
+ env,
},
);
diff --git a/apps/web/e2e/tests/chat/html-preview.spec.ts b/apps/web/e2e/tests/chat/html-preview.spec.ts
index 17cdcf020a..555492d722 100644
--- a/apps/web/e2e/tests/chat/html-preview.spec.ts
+++ b/apps/web/e2e/tests/chat/html-preview.spec.ts
@@ -6,6 +6,7 @@ import type { ApiClient } from "../../helpers/api-client";
import type { BackendContext } from "../../fixtures/backend";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
import { SessionPage } from "../../pages/session-page";
+import { chooseCapture, saveDraft } from "../preview/preview-feedback-helpers";
const SAVED_HTML = "Saved source
";
const SVG_ASSET =
@@ -205,6 +206,36 @@ test.describe("HTML preview", () => {
).toContainText("Republished native preview");
});
+ test("opens HTML preview feedback in its shared collection", async ({
+ testPage,
+ apiClient,
+ seedData,
+ backend,
+ }) => {
+ const { session } = await setupDesktopHtmlPreviewTest({
+ testPage,
+ apiClient,
+ seedData,
+ backend,
+ title: "HTML Preview Feedback Collection",
+ });
+
+ await testPage.getByTestId("html-preview-toggle").first().click();
+ const preview = testPage.getByTestId("html-preview").first();
+ const frame = preview.frameLocator("iframe");
+ await expect(frame.locator("#increment")).toBeVisible({ timeout: 15_000 });
+
+ await chooseCapture(testPage, "Select element");
+ await frame.locator("#increment").click();
+ await saveDraft(testPage, "Keep this HTML preview action visible");
+
+ await session.clickSessionChatTab();
+ await session.activeChat().getByRole("button", { name: "1 preview feedback item" }).click();
+
+ await expect(session.browserPanel).toHaveCount(0);
+ await expect(testPage.getByText("Keep this HTML preview action visible")).toBeVisible();
+ });
+
test("shows a retryable error when the session publish endpoint fails", async ({
testPage,
apiClient,
diff --git a/apps/web/e2e/tests/docker/preview-feedback.spec.ts b/apps/web/e2e/tests/docker/preview-feedback.spec.ts
new file mode 100644
index 0000000000..23b51870f4
--- /dev/null
+++ b/apps/web/e2e/tests/docker/preview-feedback.spec.ts
@@ -0,0 +1,78 @@
+import { test, expect } from "../../fixtures/docker-test-base";
+import { waitForLatestSessionDone } from "../../helpers/session";
+import { SessionPage } from "../../pages/session-page";
+import { chooseCapture, saveDraft } from "../preview/preview-feedback-helpers";
+
+const PREVIEW_PORT = 4173;
+const PREVIEW_HTML = `
+
+ Docker preview fixture
+ Container preview
+`;
+
+function dockerPreviewScript(): string {
+ const source = [
+ 'const http = require("node:http")',
+ `const html = ${JSON.stringify(PREVIEW_HTML)}`,
+ 'const server = http.createServer((_request, response) => { response.setHeader("content-type", "text/html; charset=utf-8"); response.end(html) })',
+ `server.listen(${PREVIEW_PORT}, "0.0.0.0", () => console.log("http://localhost:${PREVIEW_PORT}"))`,
+ ].join(";");
+ return `node -e '${source}'`;
+}
+
+test.describe("Docker web preview feedback", () => {
+ test.describe.configure({ retries: 1, timeout: 180_000 });
+
+ test("annotates a container-served page and delivers the feedback", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ await apiClient.updateRepository(seedData.repositoryId, {
+ dev_script: dockerPreviewScript(),
+ });
+
+ try {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Docker Preview Feedback",
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ executor_profile_id: seedData.dockerExecutorProfileId,
+ },
+ );
+ await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for Docker preview task");
+
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await session.waitForChatIdle({ timeout: 60_000 });
+ await testPage.getByTestId("dev-server-preview-toggle-center").click();
+ await expect(session.browserPanel).toBeVisible({ timeout: 30_000 });
+ await session.browserAddressInput.fill(`http://localhost:${PREVIEW_PORT}`);
+ await session.browserAddressInput.press("Enter");
+
+ const frame = session.browserPanel.frameLocator("iframe");
+ await expect(frame.locator("#deploy")).toBeVisible({ timeout: 60_000 });
+ await chooseCapture(testPage, "Select element");
+ await frame.locator("#deploy").click();
+ await saveDraft(testPage, "Clarify the result of this container action");
+
+ await session.clickSessionChatTab();
+ await session.sendMessageViaButton("Apply the container preview feedback");
+ const message = session
+ .activeChat()
+ .getByTestId("user-message-bubble")
+ .filter({ hasText: "Apply the container preview feedback" });
+ await expect(message).toContainText("Web Preview Feedback", { timeout: 30_000 });
+ await expect(message).toContainText("Clarify the result of this container action");
+ await expect(message).toContainText("http://localhost:4173");
+ } finally {
+ await apiClient.updateRepository(seedData.repositoryId, { dev_script: "" });
+ }
+ });
+});
diff --git a/apps/web/e2e/tests/preview/inspector-submission.spec.ts b/apps/web/e2e/tests/preview/inspector-submission.spec.ts
deleted file mode 100644
index 8276dae9f6..0000000000
--- a/apps/web/e2e/tests/preview/inspector-submission.spec.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import http from "node:http";
-import type { AddressInfo } from "node:net";
-import { expect } from "@playwright/test";
-import { test } from "../../fixtures/test-base";
-import { openTaskSession, createStandardProfile } from "../../helpers/git-helper";
-
-const MOCK_HTML = `
-
-Mock Dev Server
-
-
-
-`;
-
-async function startMockDevServer(): Promise<{ port: number; close: () => Promise }> {
- const server = http.createServer((_req, res) => {
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
- res.end(MOCK_HTML);
- });
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
- const { port } = server.address() as AddressInfo;
- return {
- port,
- close: () =>
- new Promise((resolve, reject) =>
- server.close((err) => (err ? reject(err) : resolve())),
- ),
- };
-}
-
-async function openPreview(
- testPage: import("@playwright/test").Page,
- apiClient: import("../../helpers/api-client").ApiClient,
- seedData: { workspaceId: string; workflowId: string; startStepId: string; repositoryId: string },
- title: string,
- port: number,
-) {
- await apiClient.updateRepository(seedData.repositoryId, { dev_script: "echo preview" });
- const profile = await createStandardProfile(apiClient, `inspector-${title.toLowerCase()}`);
- await apiClient.createTaskWithAgent(seedData.workspaceId, title, profile.id, {
- description: "preview inspector submission",
- workflow_id: seedData.workflowId,
- workflow_step_id: seedData.startStepId,
- repository_ids: [seedData.repositoryId],
- });
-
- const session = await openTaskSession(testPage, title);
- await session.waitForChatIdle({ timeout: 30_000 });
- const startDevPreview = testPage.getByTestId("dev-server-preview-toggle-center");
- await expect(startDevPreview).toBeVisible();
- await startDevPreview.click();
-
- const urlInput = testPage.locator('[placeholder*="localhost"], [placeholder*="3000"]').first();
- await expect(urlInput).toBeVisible();
- await urlInput.fill(`http://localhost:${port}`);
- await urlInput.press("Enter");
-
- const iframe = testPage.locator('iframe[title*="preview" i]');
- await expect(iframe).toBeVisible({ timeout: 15_000 });
- const preview = testPage.frameLocator('iframe[title*="preview" i]');
- await expect(preview.locator("#submit-btn")).toBeVisible();
-
- await testPage.getByTestId("preview-inspect-button").click();
- await expect(preview.locator("#submit-btn")).toBeVisible();
- return preview;
-}
-
-test.describe("Browser inspect annotation submission", () => {
- test("Save submits a pin annotation", async ({ testPage, apiClient, seedData }) => {
- const devServer = await startMockDevServer();
- try {
- const preview = await openPreview(
- testPage,
- apiClient,
- seedData,
- "Inspector Save",
- devServer.port,
- );
-
- await preview.locator("#submit-btn").click();
- const comment = preview.locator("textarea");
- await expect(comment).toBeVisible();
- await comment.fill("make this primary color");
- await preview.getByRole("button", { name: "Save" }).click();
-
- await expect(preview.locator("textarea")).toHaveCount(0);
- await expect(preview.locator('[data-kandev-marker="1"]')).toBeVisible();
- await expect(testPage.getByTestId("preview-annotation-item")).toContainText(
- "make this primary color",
- );
- } finally {
- await apiClient.updateRepository(seedData.repositoryId, { dev_script: "" });
- await devServer.close();
- }
- });
-
- test("Enter submits a pin annotation", async ({ testPage, apiClient, seedData }) => {
- const devServer = await startMockDevServer();
- try {
- const preview = await openPreview(
- testPage,
- apiClient,
- seedData,
- "Inspector Enter",
- devServer.port,
- );
-
- await preview.locator("#submit-btn").click();
- const comment = preview.locator("textarea");
- await expect(comment).toBeVisible();
- await comment.fill("increase font size");
- await comment.press("Enter");
-
- await expect(preview.locator("textarea")).toHaveCount(0);
- await expect(preview.locator('[data-kandev-marker="1"]')).toBeVisible();
- await expect(testPage.getByTestId("preview-annotation-item")).toContainText(
- "increase font size",
- );
- } finally {
- await apiClient.updateRepository(seedData.repositoryId, { dev_script: "" });
- await devServer.close();
- }
- });
-});
diff --git a/apps/web/e2e/tests/preview/preview-annotations.spec.ts b/apps/web/e2e/tests/preview/preview-annotations.spec.ts
deleted file mode 100644
index e16de1935b..0000000000
--- a/apps/web/e2e/tests/preview/preview-annotations.spec.ts
+++ /dev/null
@@ -1,282 +0,0 @@
-import http from "node:http";
-import type { AddressInfo } from "node:net";
-import { expect } from "@playwright/test";
-import { test } from "../../fixtures/test-base";
-import { openTaskSession, createStandardProfile } from "../../helpers/git-helper";
-
-const MOCK_HTML = `
-
-Mock Dev Server
-
-
-
-
A test paragraph
-
-
-`;
-
-async function startMockDevServer(): Promise<{ port: number; close: () => Promise }> {
- const server = http.createServer((_req, res) => {
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
- res.end(MOCK_HTML);
- });
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
- const { port } = server.address() as AddressInfo;
- return {
- port,
- close: () =>
- new Promise((resolve, reject) =>
- server.close((err) => (err ? reject(err) : resolve())),
- ),
- };
-}
-
-async function attachPreview(testPage: import("@playwright/test").Page, port: number) {
- const urlInput = testPage.locator('[placeholder*="localhost"], [placeholder*="3000"]').first();
- await urlInput.fill(`http://localhost:${port}`);
- await urlInput.press("Enter");
- await expect(testPage.locator('iframe[title="Preview"]')).toBeVisible({ timeout: 15_000 });
-}
-
-async function simulatePin(
- testPage: import("@playwright/test").Page,
- overrides: Record = {},
-) {
- await testPage.evaluate((data) => {
- window.postMessage(
- {
- source: "kandev-inspector",
- type: "annotation-added",
- payload: {
- id: `a-${Date.now()}`,
- number: 1,
- kind: "pin",
- pagePath: "/",
- comment: "make this primary color",
- element: {
- tag: "button",
- id: "submit-btn",
- role: "button",
- ariaLabel: "Submit form",
- text: "Submit",
- selector: "div#app > button#submit-btn",
- },
- ...data,
- },
- },
- "*",
- );
- }, overrides);
-}
-
-// TODO(preview-annotations): the seed repo has no dev_script, so the preview
-// panel renders a placeholder instead of the toolbar URL input — every test
-// here hangs on attachPreview waiting for an iframe that will never appear.
-// Wiring this up needs apiClient.updateRepository({ dev_script: "..." }),
-// opening the Preview dockview tab, and likely waiting for the dev server
-// process to spawn before the iframe is mountable. Skipping for now; the
-// proxy-injection contract is covered by port_proxy_test.go::
-// TestCreatePortProxy_StripsAcceptEncodingAndInjectsHTML, and the bridge +
-// formatter are covered by preview-inspect-bridge.test.ts.
-test.describe.skip("Preview annotations", () => {
- test("inspect button is hidden when preview has no URL", async ({
- testPage,
- apiClient,
- seedData,
- }) => {
- const profile = await createStandardProfile(apiClient, "annot-no-url");
- await apiClient.createTaskWithAgent(seedData.workspaceId, "Annot no URL", profile.id, {
- description: "no url",
- workflow_id: seedData.workflowId,
- workflow_step_id: seedData.startStepId,
- repository_ids: [seedData.repositoryId],
- });
- await openTaskSession(testPage, "Annot no URL");
- await expect(testPage.getByTestId("preview-inspect-button")).not.toBeVisible();
- });
-
- test("inspect button toggles and shows count badge as pins accumulate", async ({
- testPage,
- apiClient,
- seedData,
- }) => {
- const devServer = await startMockDevServer();
- try {
- const profile = await createStandardProfile(apiClient, "annot-toggle");
- await apiClient.createTaskWithAgent(seedData.workspaceId, "Annot toggle", profile.id, {
- description: "toggle",
- workflow_id: seedData.workflowId,
- workflow_step_id: seedData.startStepId,
- repository_ids: [seedData.repositoryId],
- });
- await openTaskSession(testPage, "Annot toggle");
- await attachPreview(testPage, devServer.port);
-
- const btn = testPage.getByTestId("preview-inspect-button");
- await expect(btn).toBeVisible({ timeout: 5_000 });
- await expect(btn).toHaveAttribute("aria-pressed", "false");
-
- await btn.click();
- await expect(btn).toHaveAttribute("aria-pressed", "true");
-
- await simulatePin(testPage);
- await expect(testPage.getByTestId("preview-annotations-panel")).toBeVisible();
- await expect(testPage.getByTestId("preview-inspect-count")).toHaveText("1");
-
- await simulatePin(testPage, {
- id: "a-second",
- number: 2,
- element: {
- tag: "p",
- classes: "description",
- text: "A test paragraph",
- selector: "div#app > p.description",
- },
- comment: "increase font size",
- });
- await expect(testPage.getByTestId("preview-inspect-count")).toHaveText("2");
- await expect(testPage.getByTestId("preview-annotation-item")).toHaveCount(2);
- } finally {
- await devServer.close();
- }
- });
-
- test("copy writes formatted markdown of all annotations to clipboard", async ({
- testPage,
- apiClient,
- seedData,
- }) => {
- const devServer = await startMockDevServer();
- try {
- const profile = await createStandardProfile(apiClient, "annot-copy");
- await apiClient.createTaskWithAgent(seedData.workspaceId, "Annot copy", profile.id, {
- description: "copy",
- workflow_id: seedData.workflowId,
- workflow_step_id: seedData.startStepId,
- repository_ids: [seedData.repositoryId],
- });
- await openTaskSession(testPage, "Annot copy");
- await attachPreview(testPage, devServer.port);
-
- await testPage.context().grantPermissions(["clipboard-read", "clipboard-write"]);
- await testPage.getByTestId("preview-inspect-button").click();
- await simulatePin(testPage);
- await expect(testPage.getByTestId("preview-annotations-panel")).toBeVisible();
-
- await testPage.getByTestId("preview-annotations-copy").click();
- const text = await testPage.evaluate(() => navigator.clipboard.readText());
- expect(text).toContain("Preview annotations on `/`");
- expect(text).toContain("[Pin]");
- expect(text).toContain("Comment: make this primary color");
- expect(text).toContain("Selector: `div#app > button#submit-btn`");
- } finally {
- await devServer.close();
- }
- });
-
- test("per-item remove and clear all work", async ({ testPage, apiClient, seedData }) => {
- const devServer = await startMockDevServer();
- try {
- const profile = await createStandardProfile(apiClient, "annot-remove");
- await apiClient.createTaskWithAgent(seedData.workspaceId, "Annot remove", profile.id, {
- description: "remove",
- workflow_id: seedData.workflowId,
- workflow_step_id: seedData.startStepId,
- repository_ids: [seedData.repositoryId],
- });
- await openTaskSession(testPage, "Annot remove");
- await attachPreview(testPage, devServer.port);
-
- await testPage.getByTestId("preview-inspect-button").click();
- await simulatePin(testPage);
- await simulatePin(testPage, { id: "a-two", number: 2 });
- await expect(testPage.getByTestId("preview-annotation-item")).toHaveCount(2);
-
- await testPage.getByTestId("preview-annotation-remove").first().click();
- await expect(testPage.getByTestId("preview-annotation-item")).toHaveCount(1);
-
- await testPage.getByTestId("preview-annotations-clear").click();
- await expect(testPage.getByTestId("preview-annotations-panel")).not.toBeVisible();
- await expect(testPage.getByTestId("preview-inspect-count")).not.toBeVisible();
- } finally {
- await devServer.close();
- }
- });
-
- test("inspect mode is preserved across logs-view round trip; annotations persist", async ({
- testPage,
- apiClient,
- seedData,
- }) => {
- const devServer = await startMockDevServer();
- try {
- const profile = await createStandardProfile(apiClient, "annot-logs");
- await apiClient.createTaskWithAgent(seedData.workspaceId, "Annot logs", profile.id, {
- description: "logs",
- workflow_id: seedData.workflowId,
- workflow_step_id: seedData.startStepId,
- repository_ids: [seedData.repositoryId],
- });
- await openTaskSession(testPage, "Annot logs");
- await attachPreview(testPage, devServer.port);
-
- await testPage.getByTestId("preview-inspect-button").click();
- await simulatePin(testPage);
- const btn = testPage.getByTestId("preview-inspect-button");
- await expect(btn).toHaveAttribute("aria-pressed", "true");
-
- await testPage.getByRole("button", { name: "Logs" }).click();
- await testPage.getByRole("button", { name: "Preview" }).click();
-
- // Implementation derives effectiveIsInspectMode = isInspectMode && previewView !== "output",
- // so the underlying inspect state survives the logs round-trip and the button re-activates.
- await expect(btn).toHaveAttribute("aria-pressed", "true");
- await expect(testPage.getByTestId("preview-inspect-count")).toHaveText("1");
- } finally {
- await devServer.close();
- }
- });
-
- test("port proxy response body contains the inspector script", async ({
- testPage,
- apiClient,
- seedData,
- backend,
- }) => {
- const devServer = await startMockDevServer();
- try {
- const profile = await createStandardProfile(apiClient, "annot-inject");
- const created = await apiClient.createTaskWithAgent(
- seedData.workspaceId,
- "Annot inject",
- profile.id,
- {
- description: "inject",
- workflow_id: seedData.workflowId,
- workflow_step_id: seedData.startStepId,
- repository_ids: [seedData.repositoryId],
- },
- );
- await openTaskSession(testPage, "Annot inject");
- await attachPreview(testPage, devServer.port);
-
- // The port-proxy backend route expects a session ID, not the task ID
- // that appears in the URL.
- const sessionId = created.session_id;
- expect(sessionId).toBeTruthy();
-
- // page.request shares cookies with the page context; sufficient for the
- // current no-auth local backend.
- const proxyResp = await testPage.request.get(
- `${backend.baseUrl}/port-proxy/${sessionId}/${devServer.port}/`,
- );
- expect(proxyResp.status()).toBe(200);
- const body = await proxyResp.text();
- expect(body).toContain("kandev-inspector");
- expect(proxyResp.headers()["content-security-policy"]).toBeUndefined();
- expect(proxyResp.headers()["x-frame-options"]).toBeUndefined();
- } finally {
- await devServer.close();
- }
- });
-});
diff --git a/apps/web/e2e/tests/preview/preview-feedback-helpers.ts b/apps/web/e2e/tests/preview/preview-feedback-helpers.ts
new file mode 100644
index 0000000000..0da0611320
--- /dev/null
+++ b/apps/web/e2e/tests/preview/preview-feedback-helpers.ts
@@ -0,0 +1,143 @@
+import http from "node:http";
+import type { AddressInfo } from "node:net";
+import { expect, type FrameLocator, type Locator, type Page } from "@playwright/test";
+import type { ApiClient } from "../../helpers/api-client";
+import type { SeedData } from "../../fixtures/test-base";
+import { SessionPage } from "../../pages/session-page";
+
+const PREVIEW_HTML = `
+
+
+
+ Preview feedback fixture
+
+
+
+
+
+ Checkout
+
+
+
+
+
+`;
+
+export type PreviewServer = { url: string; close: () => Promise };
+
+export async function startPreviewServer(): Promise {
+ const server = http.createServer((_request, response) => {
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
+ response.end(PREVIEW_HTML);
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const { port } = server.address() as AddressInfo;
+ return {
+ url: `http://localhost:${port}`,
+ close: () =>
+ new Promise((resolve, reject) =>
+ server.close((error) => (error ? reject(error) : resolve())),
+ ),
+ };
+}
+
+export async function openBrowserPreview(
+ page: Page,
+ apiClient: ApiClient,
+ seedData: SeedData,
+ title: string,
+ url: string,
+): Promise<{ session: SessionPage; frame: FrameLocator }> {
+ await apiClient.updateRepository(seedData.repositoryId, { dev_script: "echo preview" });
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ title,
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ await page.goto(`/t/${task.id}`);
+ const session = new SessionPage(page);
+ await session.waitForLoad();
+ await session.waitForChatIdle({ timeout: 45_000 });
+ await page.getByTestId("dev-server-preview-toggle-center").click();
+ await expect(session.browserPanel).toBeVisible();
+ await session.browserAddressInput.fill(url);
+ await session.browserAddressInput.press("Enter");
+ const iframe = session.browserPanel.locator("iframe");
+ await expect(iframe).toBeVisible({ timeout: 15_000 });
+ const frame = session.browserPanel.frameLocator("iframe");
+ await expect(frame.locator("#save")).toBeVisible({ timeout: 15_000 });
+ return { session, frame };
+}
+
+export async function chooseCapture(page: Page, name: string): Promise {
+ const popover = page.getByTestId("preview-feedback-popover");
+ if (!(await popover.isVisible())) {
+ await page.getByTestId("preview-feedback-trigger").click();
+ }
+ await expect(popover).toBeVisible();
+ const choice = popover.getByRole("button", { name, exact: true });
+ await expect(choice).toBeVisible({ timeout: 5_000 });
+ const box = await choice.boundingBox();
+ const viewport = page.viewportSize();
+ if (!box || !viewport || box.y < 0 || box.y + box.height > viewport.height) {
+ throw new Error(`capture choice is outside viewport: ${JSON.stringify({ box, viewport })}`);
+ }
+ await choice.click();
+}
+
+export async function saveDraft(page: Page, comment: string): Promise {
+ const draft = page.getByTestId("preview-feedback-draft");
+ await expect(draft).toBeVisible({ timeout: 15_000 });
+ await draft.getByRole("textbox", { name: "Comment on selection" }).fill(comment);
+ await draft.getByRole("button", { name: "Save feedback" }).click();
+ await expect(draft).toBeHidden({ timeout: 15_000 });
+}
+
+export async function selectGeneratedText(frame: FrameLocator): Promise {
+ await frame.locator("#runtime-total").evaluate((element) => {
+ const text = element.firstChild;
+ if (!text) throw new Error("generated text node missing");
+ const value = text.textContent ?? "";
+ const start = value.indexOf("$42.00");
+ const range = document.createRange();
+ range.setStart(text, start);
+ range.setEnd(text, start + "$42.00".length);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+ });
+}
+
+export async function dragScreenshotRegion(page: Page, target: Locator): Promise {
+ const box = await target.boundingBox();
+ if (!box) throw new Error("screenshot target did not have a bounding box");
+ await page.mouse.move(box.x + 4, box.y + 4);
+ await page.mouse.down();
+ await page.mouse.move(box.x + box.width - 4, box.y + box.height - 4);
+ await page.mouse.up();
+}
diff --git a/apps/web/e2e/tests/preview/preview-feedback.spec.ts b/apps/web/e2e/tests/preview/preview-feedback.spec.ts
new file mode 100644
index 0000000000..801ca1ddcf
--- /dev/null
+++ b/apps/web/e2e/tests/preview/preview-feedback.spec.ts
@@ -0,0 +1,213 @@
+import { expect } from "@playwright/test";
+import { test } from "../../fixtures/test-base";
+import { typeWhileBusy, waitForComposerQueueMode } from "../../helpers/type-while-busy";
+import { routeMainWebSocketWithPreviewFeedbackCreateFailure } from "../../helpers/ws-drop";
+import {
+ chooseCapture,
+ dragScreenshotRegion,
+ openBrowserPreview,
+ saveDraft,
+ selectGeneratedText,
+ startPreviewServer,
+} from "./preview-feedback-helpers";
+
+test.describe("Web preview feedback", () => {
+ test.describe.configure({ retries: 1, timeout: 180_000 });
+
+ test("persists multi-route captures and sends them directly or through the queue", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const server = await startPreviewServer();
+ const createFailure = await routeMainWebSocketWithPreviewFeedbackCreateFailure(testPage);
+ try {
+ const { session, frame } = await openBrowserPreview(
+ testPage,
+ apiClient,
+ seedData,
+ "Web Preview Feedback",
+ server.url,
+ );
+
+ const trigger = testPage.getByTestId("preview-feedback-trigger");
+ const triggerBox = await trigger.boundingBox();
+ expect(triggerBox?.height).toBeLessThanOrEqual(30);
+ expect(triggerBox?.width).toBeLessThanOrEqual(48);
+ await trigger.click();
+ const choices = [
+ { name: "Select text", label: "Text" },
+ { name: "Select element", label: "Element" },
+ { name: "Select screenshot region", label: "Screenshot" },
+ ];
+ const choiceBoxes = [];
+ for (const choice of choices) {
+ const button = testPage.getByRole("button", { name: choice.name, exact: true });
+ await expect(button).toHaveText(choice.label);
+ choiceBoxes.push(await button.boundingBox());
+ }
+ expect(new Set(choiceBoxes.map((box) => Math.round(box?.y ?? -1))).size).toBe(1);
+
+ await chooseCapture(testPage, "Select element");
+ await frame.locator("#save").hover();
+ const candidate = frame.locator('[data-kandev-inspector-ui="candidate"]');
+ await expect(candidate).toBeVisible();
+ await expect(candidate).toContainText("button#save.primary");
+ await frame.locator("#save").click();
+ await saveDraft(testPage, "Make the primary action more prominent");
+
+ await frame.locator("#details-route").click();
+ await expect(frame.locator("h1")).toHaveText("Order details");
+ await chooseCapture(testPage, "Select text");
+ await selectGeneratedText(frame);
+ await expect(testPage.getByTestId("preview-feedback-draft")).toContainText("$42.00");
+ await saveDraft(testPage, "Explain how this generated total was calculated");
+
+ await chooseCapture(testPage, "Select screenshot region");
+ await dragScreenshotRegion(testPage, frame.locator("#save"));
+ const screenshotDraft = testPage.getByTestId("preview-feedback-draft");
+ await expect(screenshotDraft.getByRole("img", { name: "Screenshot preview" })).toBeVisible();
+ await expect(screenshotDraft).toContainText(/\d+ × \d+ · PNG/);
+ await saveDraft(testPage, "Tighten the spacing in this region");
+
+ const failedScreenshotComment = "Keep this screenshot comment after create fails";
+ createFailure.failNextCreate();
+ await chooseCapture(testPage, "Select screenshot region");
+ await dragScreenshotRegion(testPage, frame.locator("#save"));
+ const failedDraft = testPage.getByTestId("preview-feedback-draft");
+ await expect(failedDraft.getByRole("img", { name: "Screenshot preview" })).toBeVisible();
+ await failedDraft
+ .getByRole("textbox", { name: "Comment on selection" })
+ .fill(failedScreenshotComment);
+ await failedDraft.getByRole("button", { name: "Save feedback" }).click();
+ await expect.poll(() => createFailure.failedCount()).toBe(1);
+ await expect(failedDraft).toBeVisible();
+ await expect(failedDraft.getByRole("textbox", { name: "Comment on selection" })).toHaveValue(
+ failedScreenshotComment,
+ );
+ await expect(failedDraft.getByRole("img", { name: "Screenshot preview" })).toBeVisible();
+ await expect(
+ testPage.getByTestId("preview-feedback-popover").getByRole("alert"),
+ ).toContainText("feedback could not be saved");
+
+ await failedDraft.getByRole("button", { name: "Save feedback" }).click();
+ await expect(failedDraft).toBeHidden({ timeout: 15_000 });
+ await expect(testPage.getByTestId("preview-feedback-trigger")).toContainText("4");
+
+ await session.clickSessionChatTab();
+ const userMessageCount = await session
+ .activeChat()
+ .getByTestId("user-message-bubble")
+ .count();
+ await expect(session.activeChat()).toContainText("4 preview feedback items");
+
+ await testPage.evaluate(() => {
+ type Panel = { id: string; api: { close: () => void } };
+ type DockviewApi = { panels: Panel[] };
+ const api = (window as unknown as { __dockviewApi__?: DockviewApi }).__dockviewApi__;
+ for (const panel of [...(api?.panels ?? [])]) {
+ if (panel.id.startsWith("browser:")) panel.api.close();
+ }
+ });
+ await expect(session.browserPanel).toHaveCount(0);
+
+ const feedbackChip = session
+ .activeChat()
+ .getByRole("button", { name: "4 preview feedback items", exact: true });
+ await feedbackChip.click();
+ const collection = testPage.getByTestId("preview-feedback-collection-popover");
+ await expect(collection).toBeVisible();
+ const collectionItems = collection.getByTestId("preview-feedback-item");
+ await expect(collectionItems).toHaveCount(4);
+ await expect(
+ collectionItems.nth(2).getByRole("img", { name: "Screenshot preview" }),
+ ).toBeVisible();
+
+ const firstItem = collectionItems.nth(0);
+ await firstItem.getByRole("button", { name: "Edit feedback" }).click();
+ await firstItem
+ .getByRole("textbox", { name: "Edit comment" })
+ .fill("Make the action clearer");
+ await firstItem.getByRole("button", { name: "Save changes" }).click();
+ await expect(firstItem).toContainText("Make the action clearer");
+
+ await collectionItems.nth(1).getByRole("button", { name: "Delete feedback" }).click();
+ await expect(collectionItems).toHaveCount(3);
+ await expect(collection).toContainText(failedScreenshotComment);
+ await expect(collection).not.toContainText("Explain how this generated total was calculated");
+ expect(await session.activeChat().getByTestId("user-message-bubble").count()).toBe(
+ userMessageCount,
+ );
+ await testPage.keyboard.press("Escape");
+
+ await testPage.reload();
+ await session.waitForLoad();
+ await expect(session.activeChat()).toContainText("3 preview feedback items", {
+ timeout: 15_000,
+ });
+ const restoredFeedbackChip = session
+ .activeChat()
+ .getByRole("button", { name: "3 preview feedback items", exact: true });
+ await restoredFeedbackChip.click();
+ await expect(collection).toBeVisible();
+ await expect(collection).toContainText("Make the action clearer");
+ await expect(collection).not.toContainText("Explain how this generated total was calculated");
+ await testPage.keyboard.press("Escape");
+
+ await session.sendMessageViaButton("Apply the pending preview feedback");
+ const directMessage = session
+ .activeChat()
+ .getByTestId("user-message-bubble")
+ .filter({ hasText: "Apply the pending preview feedback" });
+ await expect(directMessage).toContainText("Web Preview Feedback", { timeout: 20_000 });
+ await expect(directMessage).toContainText("Make the action clearer");
+ await expect(directMessage).not.toContainText(
+ "Explain how this generated total was calculated",
+ );
+ await expect(directMessage).toContainText("Rendered element snapshot");
+ await expect(directMessage).toContainText('"selector": "button#save"');
+ await expect(directMessage).toContainText("viewport_width");
+ const screenshotAttachment = directMessage.getByRole("button", {
+ name: "Open Attachment 1",
+ exact: true,
+ });
+ await expect(screenshotAttachment).toBeVisible();
+ await screenshotAttachment.click();
+ await expect(testPage.getByRole("dialog", { name: "Image preview" })).toBeVisible();
+ await testPage.keyboard.press("Escape");
+ await session.waitForChatIdle({ timeout: 45_000 });
+
+ await session.sendMessage("/slow 5s");
+ await waitForComposerQueueMode(testPage);
+ await session.addBrowserPanel();
+ await session.clickTab("Browser");
+ await session.browserAddressInput.fill(server.url);
+ await session.browserAddressInput.press("Enter");
+ const queuedFrame = session.browserPanel.frameLocator("iframe");
+ await expect(queuedFrame.locator("#save")).toBeVisible({ timeout: 15_000 });
+ await chooseCapture(testPage, "Select element");
+ await queuedFrame.locator("#save").click();
+ await saveDraft(testPage, "Queue this button adjustment while the agent is busy");
+
+ await session.clickSessionChatTab();
+ const editor = session.activeChat().locator(".tiptap.ProseMirror");
+ await typeWhileBusy(testPage, editor, "Apply the queued preview feedback");
+ await session.submitButton().click();
+ await expect(testPage.getByTestId("queue-chip")).toBeVisible({ timeout: 15_000 });
+
+ const queuedMessage = session
+ .activeChat()
+ .getByTestId("user-message-bubble")
+ .filter({ hasText: "Apply the queued preview feedback" });
+ await expect(queuedMessage).toContainText(
+ "Queue this button adjustment while the agent is busy",
+ { timeout: 45_000 },
+ );
+ await session.waitForChatIdle({ timeout: 45_000 });
+ await expect(session.activeChat()).not.toContainText("1 preview feedback item");
+ } finally {
+ await apiClient.updateRepository(seedData.repositoryId, { dev_script: "" });
+ await server.close();
+ }
+ });
+});
diff --git a/apps/web/e2e/tests/session/mobile-session-entry-recovery.spec.ts b/apps/web/e2e/tests/session/mobile-session-entry-recovery.spec.ts
index 3061198d3a..320ddeb4c8 100644
--- a/apps/web/e2e/tests/session/mobile-session-entry-recovery.spec.ts
+++ b/apps/web/e2e/tests/session/mobile-session-entry-recovery.spec.ts
@@ -30,12 +30,7 @@ test.describe("mobile session entry recovery", () => {
`Mobile history recovery ${Date.now()}`,
);
- proxy.delayNextResponses(
- "message.list",
- 2,
- 11_000,
- "force both bounded history attempts to expose the mobile unavailable state",
- );
+ proxy.dropNextResponses("message.list", 2);
const session = await openTaskSession(testPage, task.id);
const chat = session.activeChat();
@@ -53,6 +48,6 @@ test.describe("mobile session entry recovery", () => {
await retry.click();
await expect(historyNotice).toHaveCount(0);
await expect(chat).toContainText("simple mock response", { timeout: 30_000 });
- expect(proxy.delayedResponseCount("message.list")).toBe(2);
+ expect(proxy.droppedResponseCount("message.list")).toBe(2);
});
});
diff --git a/apps/web/e2e/tests/session/session-entry-recovery.spec.ts b/apps/web/e2e/tests/session/session-entry-recovery.spec.ts
index 2c8647dbc1..430034d6a8 100644
--- a/apps/web/e2e/tests/session/session-entry-recovery.spec.ts
+++ b/apps/web/e2e/tests/session/session-entry-recovery.spec.ts
@@ -54,12 +54,7 @@ test.describe("session entry recovery", () => {
const proxy = await routeSessionEntryRecovery(testPage);
const task = await createEntryTask(apiClient, seedData, `Retry entry ${Date.now()}`);
- proxy.delayNextResponses(
- "session.subscribe",
- 1,
- 11_000,
- "force the first subscription acknowledgement past the ten-second deadline",
- );
+ proxy.dropNextResponses("session.subscribe", 1);
const session = await openTaskSession(testPage, task.id);
await expect
@@ -70,7 +65,7 @@ test.describe("session entry recovery", () => {
.toBeGreaterThan(1);
await expect(session.activeChat()).toContainText("simple mock response", { timeout: 60_000 });
await expect(testPage.getByTestId("ensure-session-error-banner")).toHaveCount(0);
- expect(proxy.delayedResponseCount("session.subscribe")).toBe(1);
+ expect(proxy.droppedResponseCount("session.subscribe")).toBe(1);
});
test("shows one recoverable history notice and restores it with Retry", async ({
@@ -82,12 +77,7 @@ test.describe("session entry recovery", () => {
const proxy = await routeSessionEntryRecovery(testPage);
const task = await createEntryTask(apiClient, seedData, `History recovery ${Date.now()}`);
- proxy.delayNextResponses(
- "message.list",
- 2,
- 11_000,
- "force both bounded history attempts to expose the unavailable state",
- );
+ proxy.dropNextResponses("message.list", 2);
const session = await openTaskSession(testPage, task.id);
const historyNotice = session.activeChat().getByTestId("session-history-unavailable");
@@ -106,7 +96,7 @@ test.describe("session entry recovery", () => {
await historyNotice.getByTestId("session-history-retry").click();
await expect(historyNotice).toHaveCount(0);
await expect(session.activeChat()).toContainText("simple mock response", { timeout: 30_000 });
- expect(proxy.delayedResponseCount("message.list")).toBe(2);
+ expect(proxy.droppedResponseCount("message.list")).toBe(2);
});
test("labels exhausted status checks accurately and retries only the status read", async ({
@@ -118,12 +108,7 @@ test.describe("session entry recovery", () => {
const proxy = await routeSessionEntryRecovery(testPage);
const task = await createEntryTask(apiClient, seedData, `Status recovery ${Date.now()}`);
- proxy.delayNextResponses(
- "task.session.status",
- 2,
- 11_000,
- "force both bounded status attempts to expose the compact status notice",
- );
+ proxy.dropNextResponses("task.session.status", 2);
await openTaskSession(testPage, task.id);
const statusNotice = testPage.getByTestId("session-status-unavailable");
@@ -140,6 +125,6 @@ test.describe("session entry recovery", () => {
await expect(statusNotice).toHaveCount(0);
expect(proxy.requestCount("task.session.status")).toBeGreaterThanOrEqual(3);
expect(proxy.requestCount("session.launch")).toBe(launchRequestCountBeforeRetry);
- expect(proxy.delayedResponseCount("task.session.status")).toBe(2);
+ expect(proxy.droppedResponseCount("task.session.status")).toBe(2);
});
});
diff --git a/apps/web/e2e/tests/task/mobile-html-preview.spec.ts b/apps/web/e2e/tests/task/mobile-html-preview.spec.ts
index 181e86d2b7..0d8ff93de9 100644
--- a/apps/web/e2e/tests/task/mobile-html-preview.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-html-preview.spec.ts
@@ -6,6 +6,7 @@ import type { ApiClient } from "../../helpers/api-client";
import type { BackendContext } from "../../fixtures/backend";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { GitHelper, makeGitEnv, createStandardProfile } from "../../helpers/git-helper";
+import { routeMainWebSocketWithPreviewFeedbackCreateFailure } from "../../helpers/ws-drop";
import { SessionPage } from "../../pages/session-page";
const SVG_ASSET =
@@ -22,6 +23,8 @@ function mobilePreviewHtml(assetDirectory: string): string {
Mobile native preview
CSS asset pending
+
+
Browser script pending