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
6 changes: 3 additions & 3 deletions apps/web-ui/app/app/agent-ops/[runId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useParams, useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Textarea } from "@/components/ui/textarea"
import { DictationTextarea } from "@/components/voice/dictation-textarea"
import { MarkdownContent } from "@/components/ui/markdown-content"
import { Spinner } from "@/components/ui/spinner"
import {
Expand Down Expand Up @@ -187,10 +187,10 @@ export default function RunDetailPage() {
<div className="mb-3 rounded-md border bg-background p-3">
<MarkdownContent content={run.clarification.question} />
</div>
<Textarea
<DictationTextarea
placeholder="Type your response…"
value={clarificationText}
onChange={e => setClarificationText(e.target.value)}
onValueChange={setClarificationText}
rows={3}
disabled={resumeRun.isPending}
/>
Expand Down
7 changes: 4 additions & 3 deletions apps/web-ui/app/app/agent-ops/[runId]/respond/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { DictationTextarea } from '@/components/voice/dictation-textarea';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';

Expand Down Expand Up @@ -129,11 +129,12 @@ export default function RespondPage() {
<p className="text-sm font-medium">Clarification needed:</p>
<p className="text-sm mt-1">{run.clarification.question}</p>
</div>
<Textarea
<DictationTextarea
placeholder="Type your response..."
value={clarificationText}
onChange={e => setClarificationText(e.target.value)}
onValueChange={setClarificationText}
rows={4}
disabled={submitting}
/>
<Button onClick={handleClarification} disabled={submitting || !clarificationText.trim()}>
{submitting ? 'Submitting...' : 'Submit Response'}
Expand Down
6 changes: 3 additions & 3 deletions apps/web-ui/components/agent-ops/new-run-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { DictationTextarea } from "@/components/voice/dictation-textarea"
import { Label } from "@/components/ui/label"

export function NewRunDialog({
Expand Down Expand Up @@ -79,11 +79,11 @@ export function NewRunDialog({
{/* Task Description */}
<div className="space-y-2">
<Label>Objective</Label>
<Textarea
<DictationTextarea
placeholder="What do you want the agent to do? e.g., 'Check all Lambda functions in us-east-1 for public access'"
className="min-h-[100px]"
value={taskDescription}
onChange={(e) => setTaskDescription(e.target.value)}
onValueChange={setTaskDescription}
/>
</div>

Expand Down
6 changes: 3 additions & 3 deletions apps/web-ui/components/agent-ops/scheduled-task-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { DictationTextarea } from "@/components/voice/dictation-textarea"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import {
Expand Down Expand Up @@ -181,11 +181,11 @@ export function ScheduledTaskDialog({ tenantId = "default", task, prefill, onSav

<div className="space-y-1.5">
<Label>Objective</Label>
<Textarea
<DictationTextarea
placeholder="What should the agent do on each run?"
className="min-h-[80px]"
value={form.description}
onChange={e => set("description", e.target.value)}
onValueChange={v => set("description", v)}
/>
</div>

Expand Down
2 changes: 2 additions & 0 deletions apps/web-ui/components/agent/chat/clarification-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { MicButton } from "@/components/voice/mic-button";
import { Check, HelpCircle, Send } from "lucide-react";
import type { PendingClarification } from "./run-state";

Expand Down Expand Up @@ -85,6 +86,7 @@ export function ClarificationCard({
}
}}
/>
<MicButton value={text} onChange={setText} size="sm" />
<Button size="sm" className="h-8 shrink-0" disabled={!text.trim()} onClick={() => submit(text)} aria-label="Send answer">
<Send className="h-3.5 w-3.5" />
</Button>
Expand Down
8 changes: 8 additions & 0 deletions apps/web-ui/components/agent/workspace/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { FileUpload, type FileAttachment } from "@/components/agent/file-upload";
import { MicButton } from "@/components/voice/mic-button";
import { AccountChip, KbSection, ModelChip, SkillChip, ToolsSection, type ComposerContext } from "./composer-pickers";

export type { ComposerContext } from "./composer-pickers";
Expand Down Expand Up @@ -241,6 +242,13 @@ export function Composer({
</PopoverContent>
</Popover>

<MicButton
value={value}
onChange={onChange}
disabled={disabled || isStreaming}
maxLength={MAX_CHARS}
/>

{onEnhance && (
<Button
type="button"
Expand Down
14 changes: 11 additions & 3 deletions apps/web-ui/components/deep-agent/deep-agent-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { TodoPanel } from './todo-panel';
import { ApprovalDialog } from './approval-dialog';
import { McpSkillSelector } from './mcp-skill-selector';
import { AccountSelector } from './account-selector';
import { MicButton } from '@/components/voice/mic-button';
import {
Send,
Bot,
Expand Down Expand Up @@ -478,10 +479,16 @@ export function DeepAgentChat() {

function handleInput(e: React.ChangeEvent<HTMLTextAreaElement>) {
setInput(e.target.value);
const el = e.target;
}

// Keyed on `input` rather than done inside handleInput so programmatic writes
// (voice dictation, clearing on send) resize the box too.
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}
}, [input]);

// ---------------------------------------------------------------------------
// Render
Expand Down Expand Up @@ -618,7 +625,8 @@ export function DeepAgentChat() {
className="w-full bg-transparent px-4 pt-3 pb-11 text-sm text-foreground placeholder:text-muted-foreground outline-none resize-none overflow-y-auto"
disabled={isLoading}
/>
<div className="absolute bottom-2.5 right-2.5">
<div className="absolute bottom-2.5 right-2.5 flex items-center gap-1">
<MicButton value={input} onChange={setInput} disabled={isLoading} size="sm" />
<button
onClick={handleSend}
disabled={!input.trim() || isLoading}
Expand Down
7 changes: 7 additions & 0 deletions apps/web-ui/components/inventory/ask-ai-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { MicButton } from "@/components/voice/mic-button";
import { Bot, Send, Sparkles, User, RefreshCw, Trash2, ChevronDown } from "lucide-react";
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
Expand Down Expand Up @@ -367,6 +368,12 @@ export function AskAIDialog({ open, onOpenChange, filters }: AskAIDialogProps) {
className="flex-1"
autoFocus
/>
<MicButton
value={input}
onChange={setInput}
disabled={isLoading || isStreaming}
className="self-center"
/>
<Button type="submit" disabled={isLoading || isStreaming || !input.trim()}>
<Send className="h-4 w-4" />
<span className="sr-only">Send</span>
Expand Down
2 changes: 2 additions & 0 deletions apps/web-ui/components/knowledge-base/kb-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import remarkGfm from "remark-gfm";
import { KBChatSources, KBSource } from "./kb-chat-sources";
import { KBChatSidebar } from "./kb-chat-sidebar";
import { FileUpload, FileAttachment } from "@/components/agent/file-upload";
import { MicButton } from "@/components/voice/mic-button";
import { useKnowledgeBases } from "@/lib/queries/knowledge-base";
import { useKBChatMessages } from "@/lib/queries/kb-chat";
import { useProviderModels, defaultModelId } from "@/lib/queries/providers";
Expand Down Expand Up @@ -402,6 +403,7 @@ export function KBChat({ initialKbId }: KBChatProps) {
rows={1}
className="flex-1 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground min-h-[24px] max-h-[160px] py-0.5 leading-relaxed"
/>
<MicButton value={input} onChange={setInput} disabled={isBusy} />
<Button
type="button"
size="icon"
Expand Down
95 changes: 95 additions & 0 deletions apps/web-ui/components/voice/__tests__/mic-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MicButton } from '../mic-button';

vi.mock('sonner', () => ({ toast: { error: vi.fn() } }));

class MockRecognition {
static instances: MockRecognition[] = [];
lang = '';
continuous = false;
interimResults = false;
maxAlternatives = 0;
started = false;
onresult: ((event: unknown) => void) | null = null;
onerror: ((event: { error: string }) => void) | null = null;
onend: (() => void) | null = null;

constructor() {
MockRecognition.instances.push(this);
}
start() {
this.started = true;
}
stop() {
this.started = false;
}
abort() {
this.started = false;
}
}

const noop = () => {};

describe('MicButton', () => {
beforeEach(() => {
MockRecognition.instances = [];
(window as unknown as { SpeechRecognition?: unknown }).SpeechRecognition = MockRecognition;
});

afterEach(() => {
delete (window as unknown as { SpeechRecognition?: unknown }).SpeechRecognition;
});

it('renders nothing on browsers without the Web Speech API', () => {
delete (window as unknown as { SpeechRecognition?: unknown }).SpeechRecognition;
render(<MicButton value="" onChange={noop} />);
expect(screen.queryByTestId('mic-button')).toBeNull();
});

it('renders an idle, unpressed mic when supported', () => {
render(<MicButton value="" onChange={noop} />);
const button = screen.getByTestId('mic-button');
expect(button.getAttribute('aria-label')).toBe('Start voice input');
expect(button.getAttribute('aria-pressed')).toBe('false');
});

it('flips to the pressed listening state on click', () => {
render(<MicButton value="" onChange={noop} />);
fireEvent.click(screen.getByTestId('mic-button'));

const button = screen.getByTestId('mic-button');
expect(button.getAttribute('aria-pressed')).toBe('true');
expect(button.getAttribute('aria-label')).toBe('Stop voice input');
expect(button.getAttribute('data-listening')).toBe('true');
expect(MockRecognition.instances).toHaveLength(1);
});

it('is disabled while idle and the field is disabled', () => {
render(<MicButton value="" onChange={noop} disabled />);
expect((screen.getByTestId('mic-button') as HTMLButtonElement).disabled).toBe(true);
});

it('stays clickable while listening even once the field disables, so it can be stopped', () => {
const { rerender } = render(<MicButton value="" onChange={noop} />);
fireEvent.click(screen.getByTestId('mic-button'));
rerender(<MicButton value="" onChange={noop} disabled />);
// The disabled prop stops the session; the button returns to idle.
expect(screen.getByTestId('mic-button').getAttribute('aria-pressed')).toBe('false');
});

it('writes the dictated transcript back through onChange', () => {
const onChange = vi.fn();
render(<MicButton value="scale" onChange={onChange} />);
fireEvent.click(screen.getByTestId('mic-button'));

const results = [{ length: 1, isFinal: true, 0: { transcript: 'the cluster' } }];
MockRecognition.instances[0].onresult?.({
resultIndex: 0,
results: Object.assign(results, { length: 1 }),
});

expect(onChange).toHaveBeenCalledWith('scale the cluster');
});
});
50 changes: 50 additions & 0 deletions apps/web-ui/components/voice/dictation-textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
import { MicButton } from "./mic-button";

export interface DictationTextareaProps
extends Omit<React.ComponentProps<"textarea">, "value" | "onChange"> {
value: string;
/** Receives the new text directly — no ChangeEvent unwrapping at call sites. */
onValueChange: (next: string) => void;
/** Extra classes for the positioning wrapper, not the textarea. */
wrapperClassName?: string;
}

/**
* `Textarea` with a dictation mic tucked into its bottom-right corner.
*
* For standalone prompt fields that have no adjacent control row of their own.
* Surfaces that already render a send button next to the field should compose
* `MicButton` into that row instead.
*/
export const DictationTextarea = React.forwardRef<HTMLTextAreaElement, DictationTextareaProps>(
({ value, onValueChange, className, wrapperClassName, disabled, maxLength, ...props }, ref) => {
return (
<div className={cn("relative", wrapperClassName)}>
<Textarea
ref={ref}
value={value}
onChange={(e) => onValueChange(e.target.value)}
disabled={disabled}
maxLength={maxLength}
// Reserve the corner the mic occupies so text never slides under it.
className={cn("pr-11", className)}
{...props}
/>
<MicButton
value={value}
onChange={onValueChange}
disabled={disabled}
maxLength={maxLength}
size="sm"
className="absolute bottom-1.5 right-1.5"
/>
</div>
);
},
);
DictationTextarea.displayName = "DictationTextarea";
Loading
Loading