diff --git a/apps/web/src/components/app/chat-surface-settings.tsx b/apps/web/src/components/app/chat-surface-settings.tsx index fcd0e549..b6264885 100644 --- a/apps/web/src/components/app/chat-surface-settings.tsx +++ b/apps/web/src/components/app/chat-surface-settings.tsx @@ -1,4 +1,4 @@ -import { Checkbox } from "@/components/ui/checkbox"; +import { ToggleSettingCard } from "@/components/app/toggle-setting-card"; import { useChatSurfaceSetting } from "@/hooks/use-chat-surface-enabled"; /** @@ -11,40 +11,23 @@ export function ChatSurfaceSettings(): JSX.Element { const { enabled, error, setEnabled } = useChatSurfaceSetting(); return ( -
-
- Chat surface -
-

- 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. -

-
- -
- {error ? ( -

- {error} -

- ) : null} -
+ ); } diff --git a/apps/web/src/components/app/injection-hold-settings.tsx b/apps/web/src/components/app/injection-hold-settings.tsx index 41d1b33f..563a5ff3 100644 --- a/apps/web/src/components/app/injection-hold-settings.tsx +++ b/apps/web/src/components/app/injection-hold-settings.tsx @@ -1,4 +1,4 @@ -import { Checkbox } from "@/components/ui/checkbox"; +import { ToggleSettingCard } from "@/components/app/toggle-setting-card"; import { useOptimisticToggleSetting } from "@/hooks/use-optimistic-toggle-setting"; const ENDPOINT = "/api/v1/app/settings/injection-hold"; @@ -17,40 +17,15 @@ export function InjectionHoldSettings(): JSX.Element { }); return ( -
-
- Prompt delivery -
-

- 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 ( +
+
+ {eyebrow} +
+

+ {description} +

+
+ +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ); +}