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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 19 additions & 36 deletions apps/web/src/components/app/chat-surface-settings.tsx
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -11,40 +11,23 @@ export function ChatSurfaceSettings(): JSX.Element {
const { enabled, error, setEnabled } = useChatSurfaceSetting();

return (
<div className="p-6">
<div className="mb-1.5 text-[10px] uppercase tracking-widest text-muted-foreground">
Chat surface
</div>
<p className="mb-3 max-w-2xl text-sm text-muted-foreground">
Adds a <strong>Chat</strong> tab above each agent&apos;s terminal where
you read the agent&apos;s replies and type messages back. The terminal
stays one click away as the <strong>Console</strong>. Agents are told to
answer in the Chat tab; anything they only print in the terminal stays
in the Console.
</p>
<div className="max-w-lg">
<label className="flex cursor-pointer items-center gap-3 rounded border border-border px-3 py-2.5 transition-colors hover:bg-muted/50">
<Checkbox
checked={enabled}
onCheckedChange={(checked) => setEnabled(checked === true)}
data-testid="chat-surface-toggle"
/>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
Chat surface (beta)
</div>
<div className="text-xs text-muted-foreground">
When on, agents open on a Chat tab and the terminal tab is
labelled Console. When off, nothing changes.
</div>
</div>
</label>
</div>
{error ? (
<p role="alert" className="mt-3 text-sm text-destructive">
{error}
</p>
) : null}
</div>
<ToggleSettingCard
eyebrow="Chat surface"
description={
<>
Adds a <strong>Chat</strong> tab above each agent&apos;s terminal
where you read the agent&apos;s replies and type messages back. The
terminal stays one click away as the <strong>Console</strong>. 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}
/>
);
}
46 changes: 11 additions & 35 deletions apps/web/src/components/app/cross-repo-messaging-settings.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -25,39 +25,15 @@ export function CrossRepoMessagingSettings(): JSX.Element {
});

return (
<div className="p-6">
<div className="mb-1.5 text-[10px] uppercase tracking-widest text-muted-foreground">
Cross-repo messaging
</div>
<p className="mb-3 max-w-2xl text-sm text-muted-foreground">
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.
</p>
<div className="max-w-lg">
<label className="flex cursor-pointer items-center gap-3 rounded border border-border px-3 py-2.5 transition-colors hover:bg-muted/50">
<Checkbox
checked={enabled}
onCheckedChange={(checked) => setEnabled(checked === true)}
data-testid="cross-repo-messaging-toggle"
/>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
Allow messaging agents in other repositories
</div>
<div className="text-xs text-muted-foreground">
When on, name-based targeting can match agents across all repos —
use the agent ID (agt_…) to address one unambiguously.
</div>
</div>
</label>
</div>
{error ? (
<p role="alert" className="mt-3 text-sm text-destructive">
{error}
</p>
) : null}
</div>
<ToggleSettingCard
eyebrow="Cross-repo messaging"
description="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."
label="Allow messaging agents in other repositories"
hint="When on, name-based targeting can match agents across all repos — use the agent ID (agt_…) to address one unambiguously."
testId="cross-repo-messaging-toggle"
checked={enabled}
onCheckedChange={setEnabled}
error={error}
/>
);
}
47 changes: 11 additions & 36 deletions apps/web/src/components/app/injection-hold-settings.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,40 +17,15 @@ export function InjectionHoldSettings(): JSX.Element {
});

return (
<div className="p-6">
<div className="mb-1.5 text-[10px] uppercase tracking-widest text-muted-foreground">
Prompt delivery
</div>
<p className="mb-3 max-w-2xl text-sm text-muted-foreground">
Dispatch injects automated prompts (reviews, agent messages, feedback)
into an agent&apos;s terminal — the same input you type into. Enable
this to hold those prompts while you&apos;re actively typing and deliver
them when you pause. Applies to all agents on this Dispatch server.
</p>
<div className="max-w-lg">
<label className="flex cursor-pointer items-center gap-3 rounded border border-border px-3 py-2.5 transition-colors hover:bg-muted/50">
<Checkbox
checked={enabled}
onCheckedChange={(checked) => setEnabled(checked === true)}
data-testid="injection-hold-toggle"
/>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
Hold automated prompts while you type
</div>
<div className="text-xs text-muted-foreground">
When on, a badge appears over the terminal while a prompt waits
(up to 60s) — click it to deliver immediately. When off, prompts
inject as soon as they arrive.
</div>
</div>
</label>
</div>
{error ? (
<p role="alert" className="mt-3 text-sm text-destructive">
{error}
</p>
) : null}
</div>
<ToggleSettingCard
eyebrow="Prompt delivery"
description="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."
label="Hold automated prompts while you type"
hint="When on, a badge appears over the terminal while a prompt waits (up to 60s) — click it to deliver immediately. When off, prompts inject as soon as they arrive."
testId="injection-hold-toggle"
checked={enabled}
onCheckedChange={setEnabled}
error={error}
/>
);
}
95 changes: 95 additions & 0 deletions apps/web/src/components/app/toggle-setting-card.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof ToggleSettingCard>[0]> = {}
) {
const onCheckedChange = vi.fn();
render(
<ToggleSettingCard
eyebrow="Prompt delivery"
description="What the setting does."
label="Hold automated prompts"
hint="When on, a badge appears."
testId="probe-toggle"
checked={false}
onCheckedChange={onCheckedChange}
error=""
{...overrides}
/>
);
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 <strong>Chat</strong> tab.
</>
),
hint: <em>Nothing changes when off.</em>,
});

expect(screen.getByText("Chat").tagName).toBe("STRONG");
expect(screen.getByText("Nothing changes when off.").tagName).toBe("EM");
});
});
67 changes: 67 additions & 0 deletions apps/web/src/components/app/toggle-setting-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="p-6">
<div className="mb-1.5 text-[10px] uppercase tracking-widest text-muted-foreground">
{eyebrow}
</div>
<p className="mb-3 max-w-2xl text-sm text-muted-foreground">
{description}
</p>
<div className="max-w-lg">
<label className="flex cursor-pointer items-center gap-3 rounded border border-border px-3 py-2.5 transition-colors hover:bg-muted/50">
<Checkbox
checked={checked}
onCheckedChange={(next) => onCheckedChange(next === true)}
data-testid={testId}
/>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
</label>
</div>
{error ? (
<p role="alert" className="mt-3 text-sm text-destructive">
{error}
</p>
) : null}
</div>
);
}
Loading