- Adds a Chat tab above each agent's terminal where
- you read the agent's replies and type messages back. The terminal
- stays one click away as the Console. Agents are told to
- answer in the Chat tab; anything they only print in the terminal stays
- in the Console.
-
-
-
-
- {error ? (
-
- {error}
-
- ) : null}
-
+
+ Adds a Chat tab above each agent's terminal
+ where you read the agent's replies and type messages back. The
+ terminal stays one click away as the Console. Agents
+ are told to answer in the Chat tab; anything they only print in the
+ terminal stays in the Console.
+ >
+ }
+ label="Chat surface (beta)"
+ hint="When on, agents open on a Chat tab and the terminal tab is labelled Console. When off, nothing changes."
+ testId="chat-surface-toggle"
+ checked={enabled}
+ onCheckedChange={setEnabled}
+ error={error}
+ />
);
}
diff --git a/apps/web/src/components/app/cross-repo-messaging-settings.tsx b/apps/web/src/components/app/cross-repo-messaging-settings.tsx
index 53702be5..fc41865f 100644
--- a/apps/web/src/components/app/cross-repo-messaging-settings.tsx
+++ b/apps/web/src/components/app/cross-repo-messaging-settings.tsx
@@ -1,6 +1,6 @@
import { useAtom } from "jotai";
-import { Checkbox } from "@/components/ui/checkbox";
+import { ToggleSettingCard } from "@/components/app/toggle-setting-card";
import { useOptimisticToggleSetting } from "@/hooks/use-optimistic-toggle-setting";
import { crossRepoMessagingEnabledAtom } from "@/lib/store";
@@ -25,39 +25,15 @@ export function CrossRepoMessagingSettings(): JSX.Element {
});
return (
-
-
- Cross-repo messaging
-
-
- By default agents can only message and list other agents in the same git
- repository. Enable this to let agents coordinate across repositories for
- local multi-repo workflows. Applies to all agents on this Dispatch
- server.
-
- Dispatch injects automated prompts (reviews, agent messages, feedback)
- into an agent's terminal — the same input you type into. Enable
- this to hold those prompts while you're actively typing and deliver
- them when you pause. Applies to all agents on this Dispatch server.
-
-
-
-
- {error ? (
-
- {error}
-
- ) : null}
-
+
);
}
diff --git a/apps/web/src/components/app/toggle-setting-card.test.tsx b/apps/web/src/components/app/toggle-setting-card.test.tsx
new file mode 100644
index 00000000..4ec3e7c9
--- /dev/null
+++ b/apps/web/src/components/app/toggle-setting-card.test.tsx
@@ -0,0 +1,95 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { ToggleSettingCard } from "./toggle-setting-card";
+
+afterEach(() => {
+ cleanup();
+});
+
+function renderCard(
+ overrides: Partial[0]> = {}
+) {
+ const onCheckedChange = vi.fn();
+ render(
+
+ );
+ return { onCheckedChange };
+}
+
+describe("ToggleSettingCard", () => {
+ it("renders the copy and reflects the unchecked state", () => {
+ renderCard();
+
+ expect(screen.getByText("Prompt delivery")).toBeTruthy();
+ expect(screen.getByText("What the setting does.")).toBeTruthy();
+ expect(screen.getByText("Hold automated prompts")).toBeTruthy();
+ expect(screen.getByText("When on, a badge appears.")).toBeTruthy();
+ expect(screen.getByTestId("probe-toggle").getAttribute("data-state")).toBe(
+ "unchecked"
+ );
+ });
+
+ it("reflects the checked state", () => {
+ renderCard({ checked: true });
+
+ expect(screen.getByTestId("probe-toggle").getAttribute("data-state")).toBe(
+ "checked"
+ );
+ });
+
+ it("reports a boolean when toggled on", () => {
+ const { onCheckedChange } = renderCard();
+
+ fireEvent.click(screen.getByTestId("probe-toggle"));
+
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
+ });
+
+ it("reports a boolean when toggled off", () => {
+ const { onCheckedChange } = renderCard({ checked: true });
+
+ fireEvent.click(screen.getByTestId("probe-toggle"));
+
+ expect(onCheckedChange).toHaveBeenCalledWith(false);
+ });
+
+ it("renders no alert when the error is empty", () => {
+ renderCard();
+
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("renders the error as an alert", () => {
+ renderCard({ error: "Failed to save prompt delivery setting." });
+
+ expect(screen.getByRole("alert").textContent).toBe(
+ "Failed to save prompt delivery setting."
+ );
+ });
+
+ it("renders rich description and hint nodes", () => {
+ renderCard({
+ description: (
+ <>
+ Adds a Chat tab.
+ >
+ ),
+ hint: Nothing changes when off.,
+ });
+
+ expect(screen.getByText("Chat").tagName).toBe("STRONG");
+ expect(screen.getByText("Nothing changes when off.").tagName).toBe("EM");
+ });
+});
diff --git a/apps/web/src/components/app/toggle-setting-card.tsx b/apps/web/src/components/app/toggle-setting-card.tsx
new file mode 100644
index 00000000..9b1022d4
--- /dev/null
+++ b/apps/web/src/components/app/toggle-setting-card.tsx
@@ -0,0 +1,67 @@
+import type { ReactNode } from "react";
+
+import { Checkbox } from "@/components/ui/checkbox";
+
+interface ToggleSettingCardProps {
+ /** Small uppercase label above the section copy. */
+ eyebrow: string;
+ /** Section copy explaining what the setting does. */
+ description: ReactNode;
+ /** Bold title on the toggle row. */
+ label: ReactNode;
+ /** Secondary line under the title explaining on/off behavior. */
+ hint: ReactNode;
+ /** data-testid for the checkbox. */
+ testId: string;
+ checked: boolean;
+ onCheckedChange: (checked: boolean) => void;
+ /** Empty string renders nothing. */
+ error: string;
+}
+
+/**
+ * Presentational shell for a single server-owned boolean setting: eyebrow,
+ * copy, one bordered checkbox row and an error line. Deliberately does not own
+ * the state — callers bring their own hook, because they do not all use the
+ * same one (`useOptimisticToggleSetting` for the atom/fetch-backed flags,
+ * `useChatSurfaceSetting` for the React Query-backed one the tab bar reads).
+ */
+export function ToggleSettingCard({
+ eyebrow,
+ description,
+ label,
+ hint,
+ testId,
+ checked,
+ onCheckedChange,
+ error,
+}: ToggleSettingCardProps): JSX.Element {
+ return (
+