mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
Reshape AddChannelBotDialog with progressive disclosure
Restructure the dialog from a flat wall of fields into three layers: 1. Primary (always visible): Persona/team selection + runtime picker. These are the two decisions that matter most. 2. Customize (collapsed): Bot name, system prompt, respond-to allowlist. Power user controls that don't block the happy path. 3. Advanced (collapsed): Backend provider selection, provider config. Expert territory hidden by default. Uses a simple DisclosureSection component with chevron toggle. All existing functionality preserved — just layered behind progressive disclosure. Description updated to match the new vibe.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { AlertTriangle, ChevronDown } from "lucide-react";
|
||||
import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
@@ -43,6 +43,7 @@ import { getActivePersonas } from "@/features/agents/lib/catalog";
|
||||
import { getUsableTeams } from "@/features/agents/lib/teamPersonas";
|
||||
import { useLastRuntimeProvider } from "@/features/agents/lib/useLastRuntimeProvider";
|
||||
import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type AddChannelBotDialogProps = {
|
||||
backendProviders?: BackendProviderCandidate[];
|
||||
@@ -92,6 +93,43 @@ function formatBatchFailureSummary(
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
// ── Collapsible section ─────────────────────────────────────────────────────
|
||||
|
||||
function DisclosureSection({
|
||||
children,
|
||||
defaultOpen = false,
|
||||
label,
|
||||
testId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
label: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div data-testid={testId}>
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 py-1 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
isOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
{isOpen ? <div className="mt-3 space-y-4">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main dialog ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function AddChannelBotDialog({
|
||||
backendProviders,
|
||||
backendProvidersLoading,
|
||||
@@ -418,9 +456,6 @@ export function AddChannelBotDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// Allowlist mode requires at least one entry, mirroring the harness's own
|
||||
// validation. If we let it through empty, the agent crash-loops at startup
|
||||
// with a config error.
|
||||
const respondToValid =
|
||||
respondTo !== "allowlist" || respondToAllowlist.length > 0;
|
||||
|
||||
@@ -453,7 +488,7 @@ export function AddChannelBotDialog({
|
||||
<ChooserDialogContent
|
||||
className="max-w-3xl"
|
||||
data-testid="add-channel-bot-dialog"
|
||||
description="Select any combination of saved personas, or turn on Generic for a one-off custom agent."
|
||||
description="Choose who to bring into this channel. Expand sections below for more control."
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
@@ -481,53 +516,41 @@ export function AddChannelBotDialog({
|
||||
scrollAreaTestId="add-channel-bot-dialog-scroll-area"
|
||||
title="Add agents"
|
||||
>
|
||||
{resolvedBackendProviders.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium">Run on</div>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
|
||||
disabled={createBotsMutation.isPending}
|
||||
onChange={(e) => handleRunOnChange(e.target.value)}
|
||||
value={runOn}
|
||||
>
|
||||
<option value="local">This computer</option>
|
||||
{resolvedBackendProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{/* ─── Primary: Who ─────────────────────────────────────────────── */}
|
||||
|
||||
{isProviderMode && selectedBackendProvider ? (
|
||||
<div className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<p className="text-sm text-warning">
|
||||
This provider at{" "}
|
||||
<span className="font-mono font-medium">
|
||||
{selectedBackendProvider.binaryPath}
|
||||
</span>{" "}
|
||||
will receive your agent's private key. Only use providers
|
||||
from trusted sources.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{probeError ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
Could not probe provider: {probeError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isProviderMode && probedProvider?.config_schema ? (
|
||||
<ProviderConfigFields
|
||||
config={providerConfig}
|
||||
onChange={setProviderConfig}
|
||||
schema={probedProvider.config_schema}
|
||||
{teams.length > 0 ? (
|
||||
<AddChannelBotTeamsSection
|
||||
canToggleSelections={canToggleSelections}
|
||||
inChannelPersonaIds={inChannelPersonaIds}
|
||||
isLoading={teamsQuery.isLoading}
|
||||
onToggleTeam={handleToggleTeam}
|
||||
personas={personas}
|
||||
selectedPersonaIds={selectedPersonaIds}
|
||||
teams={teams}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AddChannelBotPersonasSection
|
||||
canToggleSelections={canToggleSelections}
|
||||
inChannelPersonaIds={inChannelPersonaIds}
|
||||
includeGeneric={includeGeneric}
|
||||
isLoading={personasQuery.isLoading}
|
||||
onToggleGeneric={() => {
|
||||
setIncludeGeneric((current) => !current);
|
||||
setSubmissionNotice(null);
|
||||
setSubmissionError(null);
|
||||
}}
|
||||
onTogglePersona={(personaId) => {
|
||||
setSelectedPersonaIds((current) => toggleValue(current, personaId));
|
||||
setSubmissionNotice(null);
|
||||
setSubmissionError(null);
|
||||
}}
|
||||
personas={personas}
|
||||
selectedPersonaIds={selectedPersonaIds}
|
||||
/>
|
||||
|
||||
{/* ─── Primary: Runtime ─────────────────────────────────────────── */}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium">Runtime</div>
|
||||
<DropdownMenu modal={false}>
|
||||
@@ -565,50 +588,6 @@ export function AddChannelBotDialog({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{teams.length > 0 ? (
|
||||
<AddChannelBotTeamsSection
|
||||
canToggleSelections={canToggleSelections}
|
||||
inChannelPersonaIds={inChannelPersonaIds}
|
||||
isLoading={teamsQuery.isLoading}
|
||||
onToggleTeam={handleToggleTeam}
|
||||
personas={personas}
|
||||
selectedPersonaIds={selectedPersonaIds}
|
||||
teams={teams}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AddChannelBotPersonasSection
|
||||
canToggleSelections={canToggleSelections}
|
||||
inChannelPersonaIds={inChannelPersonaIds}
|
||||
includeGeneric={includeGeneric}
|
||||
isLoading={personasQuery.isLoading}
|
||||
onToggleGeneric={() => {
|
||||
setIncludeGeneric((current) => !current);
|
||||
setSubmissionNotice(null);
|
||||
setSubmissionError(null);
|
||||
}}
|
||||
onTogglePersona={(personaId) => {
|
||||
setSelectedPersonaIds((current) => toggleValue(current, personaId));
|
||||
setSubmissionNotice(null);
|
||||
setSubmissionError(null);
|
||||
}}
|
||||
personas={personas}
|
||||
selectedPersonaIds={selectedPersonaIds}
|
||||
/>
|
||||
|
||||
{includeGeneric ? (
|
||||
<AddChannelBotGenericSection
|
||||
disabled={createBotsMutation.isPending}
|
||||
name={customName}
|
||||
onNameChange={(value) => {
|
||||
setHasEditedCustomName(true);
|
||||
setCustomName(value);
|
||||
}}
|
||||
onPromptChange={setCustomPrompt}
|
||||
prompt={customPrompt}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{reusableAgent ? (
|
||||
<div className="pt-2">
|
||||
<AddChannelBotReuseGuard
|
||||
@@ -620,22 +599,96 @@ export function AddChannelBotDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedCount > 0 ? (
|
||||
<CreateAgentRespondToField
|
||||
allowlist={respondToAllowlist}
|
||||
disabled={createBotsMutation.isPending}
|
||||
mode={respondTo}
|
||||
onAllowlistChange={setRespondToAllowlist}
|
||||
onModeChange={setRespondTo}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedCount === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/15 px-4 py-4 text-sm text-muted-foreground">
|
||||
Pick one or more personas, or enable Generic to add a custom agent.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ─── Customize (collapsed by default) ─────────────────────────── */}
|
||||
|
||||
<DisclosureSection label="Customize" testId="add-bot-customize-section">
|
||||
{includeGeneric ? (
|
||||
<AddChannelBotGenericSection
|
||||
disabled={createBotsMutation.isPending}
|
||||
name={customName}
|
||||
onNameChange={(value) => {
|
||||
setHasEditedCustomName(true);
|
||||
setCustomName(value);
|
||||
}}
|
||||
onPromptChange={setCustomPrompt}
|
||||
prompt={customPrompt}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable Generic above to set a custom name and system prompt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedCount > 0 ? (
|
||||
<CreateAgentRespondToField
|
||||
allowlist={respondToAllowlist}
|
||||
disabled={createBotsMutation.isPending}
|
||||
mode={respondTo}
|
||||
onAllowlistChange={setRespondToAllowlist}
|
||||
onModeChange={setRespondTo}
|
||||
/>
|
||||
) : null}
|
||||
</DisclosureSection>
|
||||
|
||||
{/* ─── Advanced (collapsed by default) ──────────────────────────── */}
|
||||
|
||||
<DisclosureSection label="Advanced" testId="add-bot-advanced-section">
|
||||
{resolvedBackendProviders.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium">Run on</div>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
|
||||
disabled={createBotsMutation.isPending}
|
||||
onChange={(e) => handleRunOnChange(e.target.value)}
|
||||
value={runOn}
|
||||
>
|
||||
<option value="local">This computer</option>
|
||||
{resolvedBackendProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isProviderMode && selectedBackendProvider ? (
|
||||
<div className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<p className="text-sm text-warning">
|
||||
This provider at{" "}
|
||||
<span className="font-mono font-medium">
|
||||
{selectedBackendProvider.binaryPath}
|
||||
</span>{" "}
|
||||
will receive your agent's private key. Only use providers
|
||||
from trusted sources.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{probeError ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
Could not probe provider: {probeError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isProviderMode && probedProvider?.config_schema ? (
|
||||
<ProviderConfigFields
|
||||
config={providerConfig}
|
||||
onChange={setProviderConfig}
|
||||
schema={probedProvider.config_schema}
|
||||
/>
|
||||
) : null}
|
||||
</DisclosureSection>
|
||||
|
||||
{/* ─── Warnings & errors ────────────────────────────────────────── */}
|
||||
|
||||
{providersErrorMessage ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{providersErrorMessage}
|
||||
|
||||
Reference in New Issue
Block a user