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
4 changes: 2 additions & 2 deletions app/(app)/agents/[agentId]/AgentChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ export function AgentChatPanel({

return (
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden border-border/80 bg-card/80 shadow-sm">
<CardHeader className="shrink-0 px-3 py-2 md:p-6">
<CardHeader data-testid="desktop-chat-heading" className="hidden shrink-0 md:grid md:p-6">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
Expand All @@ -369,7 +369,7 @@ export function AgentChatPanel({
</div>
</div>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:gap-3 md:p-6 md:pt-0">
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] md:gap-3 md:p-6 md:pt-0">
<div
ref={scrollRef}
aria-label={`Conversation history with ${agentName}`}
Expand Down
76 changes: 40 additions & 36 deletions app/(app)/agents/[agentId]/AgentVoiceCallCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function AgentVoiceCallCard({

return (
<Card className={active ? "sticky top-[3.6rem] z-20 shrink-0 overflow-hidden border-emerald-400/40 bg-gradient-to-br from-emerald-500/10 via-card to-card shadow-lg shadow-emerald-950/20 md:static" : "sticky top-[3.6rem] z-20 shrink-0 overflow-hidden border-primary/20 bg-gradient-to-br from-primary/5 via-card to-card md:static"}>
<CardHeader className="gap-2 p-3 pb-2 md:gap-3 md:p-6 md:pb-4">
<CardHeader data-testid="desktop-voice-heading" className="hidden gap-2 md:grid md:gap-3 md:p-6 md:pb-4">
<div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
<div>
<CardTitle className="flex items-center gap-2 text-base md:text-2xl">
Expand All @@ -72,59 +72,41 @@ export function AgentVoiceCallCard({
</CardTitle>
<CardDescription className="mt-1 text-sm" aria-live="polite">{AGENT_VOICE_STATE_LABELS[state]}</CardDescription>
</div>
{active ? <div className="font-mono text-2xl font-semibold tabular-nums" aria-label={`Call duration ${duration}`}>{duration}</div> : null}
</div>
</CardHeader>
<CardContent className="space-y-2 p-3 pt-0 md:space-y-4 md:p-6 md:pt-0">
<CardContent className="space-y-2 p-2.5 md:space-y-4 md:p-6 md:pt-0">
{state === "unavailable" ? (
<p className="text-sm text-muted-foreground">{agentName} has not reported a verified voice gateway and speech provider. Text availability does not enable voice.</p>
<p className="hidden text-sm text-muted-foreground md:block">{agentName} has not reported a verified voice gateway and speech provider. Text availability does not enable voice.</p>
) : null}
{chatMirrorAvailable ? (
<>
<div className="flex items-center gap-2 md:hidden" aria-label="Call privacy mode">
<button type="button" disabled={mirrorLocked} onClick={() => onChatMirrorEnabledChange?.(true)} className={chatMirrorEnabled ? "rounded-full bg-primary/15 px-3 py-1 text-xs font-semibold text-primary" : "rounded-full px-3 py-1 text-xs text-muted-foreground"} aria-pressed={chatMirrorEnabled}>Normal · Save</button>
<button type="button" disabled={mirrorLocked} onClick={() => onChatMirrorEnabledChange?.(false)} className={!chatMirrorEnabled ? "rounded-full bg-primary/15 px-3 py-1 text-xs font-semibold text-primary" : "rounded-full px-3 py-1 text-xs text-muted-foreground"} aria-pressed={!chatMirrorEnabled}>Private · Don&apos;t save</button>
</div>
<div className="hidden gap-2 rounded-xl border border-border/70 bg-background/50 p-3 md:grid md:grid-cols-2" aria-label="Call privacy mode">
<label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 ${chatMirrorEnabled ? "border-primary/50 bg-primary/10" : "border-transparent"}`}>
<Checkbox
aria-label={`Save final voice turns to ${mirrorDestination}`}
checked={chatMirrorEnabled}
disabled={mirrorLocked}
onCheckedChange={(checked) => onChatMirrorEnabledChange?.(checked === true)}
/>
<span><span className="block text-sm font-semibold">Normal · Save final turns</span><span className="mt-1 block text-xs text-muted-foreground">Final user and agent text appears in {mirrorDestination}.</span></span>
</label>
<button type="button" disabled={mirrorLocked} onClick={() => onChatMirrorEnabledChange?.(false)} className={`flex items-start gap-3 rounded-lg border p-3 text-left ${!chatMirrorEnabled ? "border-primary/50 bg-primary/10" : "border-transparent"}`} aria-pressed={!chatMirrorEnabled}>
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0" /><span><span className="block text-sm font-semibold">Private · Don&apos;t save</span><span className="mt-1 block text-xs text-muted-foreground">No final text, raw audio, or interim speech is persisted.</span></span>
</button>
</div></>
) : null}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border/60 bg-background/40 p-3">
<div data-testid="mobile-voice-header" className="flex min-h-14 items-center gap-3 md:rounded-xl md:border md:border-border/60 md:bg-background/40 md:p-3">
{active ? (
<>
<Button type="button" variant="outline" className="min-h-11 gap-2 px-3" onClick={onToggleMute} aria-pressed={muted}>
{muted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}{muted ? "Unmute" : "Mute"}
</Button>
<Button
type="button"
variant="destructive"
size="icon"
className="size-12 rounded-full bg-red-600 text-white shadow-sm hover:bg-red-500 focus-visible:border-red-300 focus-visible:ring-red-400/50 dark:bg-red-600 dark:hover:bg-red-500"
className="size-12 shrink-0 rounded-full bg-red-600 text-white shadow-sm hover:bg-red-500 focus-visible:border-red-300 focus-visible:ring-red-400/50 dark:bg-red-600 dark:hover:bg-red-500"
onClick={onEnd}
aria-label="End call"
title="End call"
>
<PhoneOff className="h-5 w-5" />
</Button>
<span className="text-sm font-medium text-red-300">End call</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{agentName}</p>
<p className="text-xs text-muted-foreground" aria-live="polite">{AGENT_VOICE_STATE_LABELS[state]}</p>
</div>
<span className="font-mono text-sm font-semibold tabular-nums" aria-label={`Call duration ${duration}`}>{duration}</span>
<Button type="button" variant="outline" size="icon" className="size-11 shrink-0 rounded-full" onClick={onToggleMute} aria-pressed={muted} aria-label={muted ? "Unmute" : "Mute"} title={muted ? "Unmute" : "Mute"}>
{muted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
</Button>
</>
) : (
<>
<Button
type="button"
size="icon"
className="size-12 rounded-full bg-emerald-600 text-white shadow-sm hover:bg-emerald-500 focus-visible:border-emerald-300 focus-visible:ring-emerald-400/50 disabled:bg-muted disabled:text-muted-foreground"
className="size-12 shrink-0 rounded-full bg-emerald-600 text-white shadow-sm hover:bg-emerald-500 focus-visible:border-emerald-300 focus-visible:ring-emerald-400/50 disabled:bg-muted disabled:text-muted-foreground"
disabled={!enabled || busy}
aria-disabled={!enabled || busy}
aria-label={busy ? AGENT_VOICE_STATE_LABELS[state] : state === "failed" || state === "ended" ? "Retry voice call" : `Call ${agentName}`}
Expand All @@ -133,16 +115,38 @@ export function AgentVoiceCallCard({
>
{busy ? <Loader2 className="h-5 w-5 animate-spin" /> : <Phone className="h-5 w-5" />}
</Button>
<span className="text-sm font-medium" aria-live="polite">
{busy ? AGENT_VOICE_STATE_LABELS[state] : state === "failed" || state === "ended" ? "Retry voice call" : `Call ${agentName}`}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{agentName}</p>
<p className="text-xs text-muted-foreground" aria-live="polite">{AGENT_VOICE_STATE_LABELS[state]}</p>
</div>
</>
)}
</div>
{chatMirrorAvailable ? (
<>
<div className="flex items-center gap-2 md:hidden" aria-label="Call privacy mode">
<button type="button" disabled={mirrorLocked} onClick={() => onChatMirrorEnabledChange?.(true)} className={chatMirrorEnabled ? "rounded-full bg-primary/15 px-3 py-1 text-xs font-semibold text-primary" : "rounded-full px-3 py-1 text-xs text-muted-foreground"} aria-pressed={chatMirrorEnabled}>Normal · Save</button>
<button type="button" disabled={mirrorLocked} onClick={() => onChatMirrorEnabledChange?.(false)} className={!chatMirrorEnabled ? "rounded-full bg-primary/15 px-3 py-1 text-xs font-semibold text-primary" : "rounded-full px-3 py-1 text-xs text-muted-foreground"} aria-pressed={!chatMirrorEnabled}>Private · Don&apos;t save</button>
</div>
<div className="hidden gap-2 rounded-xl border border-border/70 bg-background/50 p-3 md:grid md:grid-cols-2" aria-label="Call privacy mode">
<label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 ${chatMirrorEnabled ? "border-primary/50 bg-primary/10" : "border-transparent"}`}>
<Checkbox
aria-label={`Save final voice turns to ${mirrorDestination}`}
checked={chatMirrorEnabled}
disabled={mirrorLocked}
onCheckedChange={(checked) => onChatMirrorEnabledChange?.(checked === true)}
/>
<span><span className="block text-sm font-semibold">Normal · Save final turns</span><span className="mt-1 block text-xs text-muted-foreground">Final user and agent text appears in {mirrorDestination}.</span></span>
</label>
<button type="button" disabled={mirrorLocked} onClick={() => onChatMirrorEnabledChange?.(false)} className={`flex items-start gap-3 rounded-lg border p-3 text-left ${!chatMirrorEnabled ? "border-primary/50 bg-primary/10" : "border-transparent"}`} aria-pressed={!chatMirrorEnabled}>
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0" /><span><span className="block text-sm font-semibold">Private · Don&apos;t save</span><span className="mt-1 block text-xs text-muted-foreground">No final text, raw audio, or interim speech is persisted.</span></span>
</button>
</div></>
) : null}
{error ? <p className="text-xs text-red-300" role="alert">{error}</p> : null}
{remoteAudioStatus ? <p className="text-xs text-muted-foreground" role="status">{remoteAudioStatus}</p> : null}
{state === "unavailable" ? (
<p className="text-xs text-amber-300">
<p className="hidden text-xs text-amber-300 md:block">
Pending gateway capability and speech-provider configuration.
</p>
) : null}
Expand Down
8 changes: 4 additions & 4 deletions app/(app)/agents/[agentId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,17 +240,17 @@ export default function AgentDetailPage({ params }: PageProps) {

return (
<div className="flex min-h-0 flex-col gap-4 md:gap-6">
<div className="sticky top-0 z-30 grid grid-cols-2 gap-1 border-b border-border bg-background/95 p-1 pb-2 backdrop-blur md:hidden" role="tablist" aria-label="Agent view">
<button type="button" role="tab" aria-selected={mobileView === "conversation"} onClick={() => setMobileView("conversation")} className={cn("min-h-11 rounded-lg px-3 text-sm font-medium", mobileView === "conversation" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}>Conversation</button>
<button type="button" role="tab" aria-selected={mobileView === "settings"} onClick={() => setMobileView("settings")} className={cn("min-h-11 rounded-lg px-3 text-sm font-medium", mobileView === "settings" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}>Settings</button>
<div className="sticky top-0 z-30 grid grid-cols-2 gap-1 rounded-xl border border-border bg-background/95 p-1 shadow-sm backdrop-blur md:hidden" role="tablist" aria-label="Agent view">
<button type="button" role="tab" aria-selected={mobileView === "conversation"} onClick={() => setMobileView("conversation")} className={cn("min-h-11 rounded-lg border px-3 text-sm font-semibold transition-colors", mobileView === "conversation" ? "border-primary/60 bg-primary text-primary-foreground shadow-sm" : "border-transparent text-muted-foreground hover:bg-muted")}>Conversation</button>
<button type="button" role="tab" aria-selected={mobileView === "settings"} onClick={() => setMobileView("settings")} className={cn("min-h-11 rounded-lg border px-3 text-sm font-semibold transition-colors", mobileView === "settings" ? "border-primary/60 bg-primary text-primary-foreground shadow-sm" : "border-transparent text-muted-foreground hover:bg-muted")}>Settings</button>
</div>

<div className="hidden flex-col gap-6 md:flex">
<BackLink />
<AgentHeader agent={agent} onRefresh={refresh} refreshing={agentsQuery.isFetching} walletAddress={address ?? ""} />
</div>

<section role="tabpanel" aria-label="Conversation" className={cn("min-h-0 flex-col gap-3 md:flex md:gap-6", mobileView === "conversation" ? "flex h-[calc(100dvh-20rem)] overflow-hidden" : "hidden")}>
<section role="tabpanel" aria-label="Conversation" className={cn("min-h-0 flex-col gap-2 md:flex md:gap-6", mobileView === "conversation" ? "flex h-[calc(100dvh-11.5rem)] overflow-hidden" : "hidden")}>

{agent.status === "provisioning" ||
agent.status === "failed" ||
Expand Down
1 change: 1 addition & 0 deletions tests/AgentChatPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,5 +159,6 @@ describe("AgentChatPanel hibernation policy", () => {
expect(screen.getByText("Saved voice turn")).toBeVisible();
expect(screen.getByText("A completed response")).toBeVisible();
expect(screen.queryByText(/responding live/i)).not.toBeInTheDocument();
expect(screen.getByTestId("desktop-chat-heading")).toHaveClass("hidden", "md:grid");
});
});
5 changes: 4 additions & 1 deletion tests/AgentVoiceCallCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ describe("AgentVoiceCallCard", () => {
const call = screen.getByRole("button", { name: "Call Bragi" });
expect(call).toHaveClass("size-12", "rounded-full", "bg-emerald-600");
expect(call).toHaveAttribute("title", "Call Bragi");
expect(screen.getAllByText("Call Bragi").length).toBeGreaterThan(0);
expect(screen.getByTestId("desktop-voice-heading")).toHaveClass("hidden");
expect(screen.getByTestId("mobile-voice-header")).toHaveTextContent("BragiReady for voice");
expect(screen.getByTestId("mobile-voice-header")).not.toHaveTextContent("Call Bragi");
});

it("shows a compact connecting indicator without a large action label", () => {
Expand Down Expand Up @@ -76,5 +78,6 @@ describe("AgentVoiceCallCard", () => {
expect(screen.getByRole("button", { name: "End call" })).toBeVisible();
expect(screen.getByRole("button", { name: "End call" })).toHaveClass("size-12", "rounded-full", "bg-red-600");
expect(screen.getByText("Call settings")).toBeVisible();
expect(screen.getByTestId("mobile-voice-header")).toHaveTextContent("BragiVoice call in progress01:05");
});
});
Loading