feat(web): add interaction tests with play functions

Quentin Gliech and Claude Opus 4.6 (1M context) created

Add play functions to stories for behavior testing:
- QueryInput: type "label:" → suggestions appear → Enter selects first
- WritePreview: click Preview → content switches → click Write → back

These run as real browser tests via the Storybook Vitest addon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

webui2/src/components/shared/__snapshots__/query-input.test.tsx.snap   | 61 
webui2/src/components/shared/__snapshots__/write-preview.test.tsx.snap | 29 
webui2/src/components/shared/query-input.stories.tsx                   | 36 
webui2/src/components/shared/write-preview.stories.tsx                 | 48 
4 files changed, 174 insertions(+)

Detailed changes

webui2/src/components/shared/__snapshots__/query-input.test.tsx.snap 🔗

@@ -47,6 +47,67 @@ exports[`QueryInput/AsyncCompletions matches snapshot 1`] = `
 </div>
 `;
 
+exports[`QueryInput/AutocompleteInteraction matches snapshot 1`] = `
+<div>
+  <div
+    class="relative flex flex-1 items-center rounded-md border border-input bg-background ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
+  >
+    <div
+      class="text-muted-foreground pointer-events-none absolute left-3 size-4 shrink-0"
+    >
+      <svg
+        aria-hidden="true"
+        class="lucide lucide-search"
+        fill="none"
+        height="24"
+        stroke="currentColor"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        stroke-width="2"
+        viewBox="0 0 24 24"
+        width="24"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <path
+          d="m21 21-4.34-4.34"
+        />
+        <circle
+          cx="11"
+          cy="11"
+          r="8"
+        />
+      </svg>
+    </div>
+    <div
+      aria-hidden="true"
+      class="text-foreground pointer-events-none absolute inset-0 flex items-center overflow-hidden pr-3 pl-9 font-mono text-sm whitespace-pre"
+    >
+      <span>
+        <span
+          class="text-yellow-600 dark:text-yellow-500"
+        >
+          label:
+        </span>
+        <span>
+          bug
+        </span>
+      </span>
+      <span>
+         
+      </span>
+    </div>
+    <input
+      autocomplete="off"
+      class="caret-foreground placeholder:text-muted-foreground relative w-full bg-transparent py-2 pr-3 pl-9 font-mono text-sm text-transparent outline-hidden placeholder:font-sans"
+      placeholder="Type label: to test autocomplete…"
+      spellcheck="false"
+      type="text"
+      value="label:bug "
+    />
+  </div>
+</div>
+`;
+
 exports[`QueryInput/Default matches snapshot 1`] = `
 <div>
   <div

webui2/src/components/shared/__snapshots__/write-preview.test.tsx.snap 🔗

@@ -56,6 +56,35 @@ exports[`WritePreview/Empty matches snapshot 1`] = `
 </div>
 `;
 
+exports[`WritePreview/TabSwitching matches snapshot 1`] = `
+<div>
+  <div>
+    <div
+      class="flex gap-2 mb-2"
+    >
+      <button
+        class="rounded-sm px-2 py-0.5 text-sm transition-colors bg-muted font-medium"
+        type="button"
+      >
+        Write
+      </button>
+      <button
+        class="rounded-sm px-2 py-0.5 text-sm font-medium transition-colors disabled:opacity-40 text-muted-foreground hover:text-foreground"
+        type="button"
+      >
+        Preview
+      </button>
+    </div>
+    <textarea
+      class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm min-h-[120px]"
+      placeholder="Type something…"
+    >
+      Some **content** to preview.
+    </textarea>
+  </div>
+</div>
+`;
+
 exports[`WritePreview/Uncontrolled matches snapshot 1`] = `
 <div>
   <div>

webui2/src/components/shared/query-input.stories.tsx 🔗

@@ -1,5 +1,6 @@
 import type { Meta, StoryObj } from "@storybook/react-vite";
 import { Search } from "lucide-react";
+import { expect, userEvent, within } from "storybook/test";
 import { useState } from "react";
 
 import type { CompletionProvider } from "./query-input";
@@ -148,3 +149,38 @@ export const AsyncCompletions: Story = {
     );
   },
 };
+
+export const AutocompleteInteraction: Story = {
+  args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} },
+  render: () => {
+    const [value, setValue] = useState("");
+    return (
+      <QueryInput.Root
+        value={value}
+        onChange={setValue}
+        onSubmit={() => {}}
+        providers={providers}
+      >
+        <QueryInput.Icon><Search /></QueryInput.Icon>
+        <QueryInput.Input placeholder="Type label: to test autocomplete…" />
+        <QueryInput.Completions />
+      </QueryInput.Root>
+    );
+  },
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+    const input = canvas.getByRole("textbox");
+
+    // Type "label:" to trigger suggestions
+    await userEvent.type(input, "label:");
+    // Suggestions dropdown should appear
+    const bugOption = await canvas.findByText("bug");
+    await expect(bugOption).toBeVisible();
+
+    // First suggestion is already highlighted — press Enter to select
+    await userEvent.keyboard("{Enter}");
+
+    // Input should now contain the selected label
+    await expect(input).toHaveValue("label:bug ");
+  },
+};

webui2/src/components/shared/write-preview.stories.tsx 🔗

@@ -1,4 +1,5 @@
 import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
 import { useState } from "react";
 
 import { Markdown } from "@/components/content/markdown";
@@ -80,3 +81,50 @@ export const Empty: Story = {
     </WritePreview.Root>
   ),
 };
+
+export const TabSwitching: Story = {
+  args: { children: null },
+  render: () => {
+    const [message, setMessage] = useState("Some **content** to preview.");
+    return (
+      <WritePreview.Root hasContent={!!message.trim()}>
+        <WritePreview.Tabs className="mb-2" />
+        <WritePreview.WriteSlot>
+          <Textarea
+            placeholder="Type something…"
+            className="min-h-[120px]"
+            value={message}
+            onChange={(e) => setMessage(e.target.value)}
+          />
+        </WritePreview.WriteSlot>
+        <WritePreview.PreviewSlot>
+          <div className="border-input min-h-[120px] rounded-md border px-3 py-2">
+            <Markdown content={message} />
+          </div>
+        </WritePreview.PreviewSlot>
+      </WritePreview.Root>
+    );
+  },
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+
+    // Initially on Write tab — textarea visible
+    const textarea = canvas.getByRole("textbox");
+    await expect(textarea).toBeVisible();
+
+    // Click Preview tab
+    const previewBtn = canvas.getByRole("button", { name: "Preview" });
+    await userEvent.click(previewBtn);
+
+    // Textarea should be gone, preview content visible
+    await expect(canvas.queryByRole("textbox")).toBeNull();
+    await expect(canvas.getByText("content")).toBeVisible();
+
+    // Click Write tab to switch back
+    const writeBtn = canvas.getByRole("button", { name: "Write" });
+    await userEvent.click(writeBtn);
+
+    // Textarea visible again
+    await expect(canvas.getByRole("textbox")).toBeVisible();
+  },
+};