refactor: enhance artifact system, security, and terminal command handling

- Add basic HTTP authentication (express-basic-auth)
- Improve artifact tool with better source type handling (command_output, file, text)
- Update agent prompt with clearer artifact usage patterns
- Refactor tmux command execution to use execFile for safer argument handling
- Add UI cancellation capability for in-progress operations
- Switch session dumps to use util.inspect for better formatting
- Update artifact tool schema to require title parameter first

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-10-20 21:04:29 +02:00
co-authored by Claude
parent f832c43a11
commit 7d0a587619
10 changed files with 245 additions and 88 deletions
@@ -4,7 +4,8 @@
"WebFetch(domain:vite.dev)", "WebFetch(domain:vite.dev)",
"Read(//Users/moboudra/dev/voice-dev/packages/web/app/hooks/**)", "Read(//Users/moboudra/dev/voice-dev/packages/web/app/hooks/**)",
"Read(//Users/moboudra/dev/voice-dev/packages/web/app/**)", "Read(//Users/moboudra/dev/voice-dev/packages/web/app/**)",
"Read(//Users/moboudra/dev/voice-dev/**)" "Read(//Users/moboudra/dev/voice-dev/**)",
"Bash(cat:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
+28 -6
View File
@@ -411,16 +411,38 @@ You: create-terminal(name="authentication", workingDirectory="WHATEVER THE CURRE
**When user says "show me", keep your voice response SHORT and use present_artifact for the actual data.** **When user says "show me", keep your voice response SHORT and use present_artifact for the actual data.**
**Pattern:** **CRITICAL: Use command_output or file sources - don't run commands yourself then pass as text.**
**Pattern for commands:**
User: "Show me the git diff"
You: "Here's the diff."
[Call present_artifact with source: { type: "command_output", command: "git diff" }]
User: "Show me package.json"
You: "Here's package.json."
[Call present_artifact with source: { type: "file", path: "/path/to/package.json" }]
**Only use text source for data you already have (like terminal output you just captured):**
User: "Show me the terminal output" User: "Show me the terminal output"
You: [Capture terminal output] You: [Capture terminal output via capture-terminal]
You: "Here's the terminal output." You: "Here's the terminal output."
[Call present_artifact with the output] [Call present_artifact with source: { type: "text", text: capturedOutput }]
User: "Show me the inbox items" **Bad pattern (don't do this):**
You: "Here's the inbox."
[Call present_artifact with the items] User: "Show me the git diff"
You: [Run git diff command via send-text]
You: [Capture output]
You: [Call present_artifact with text source containing the output]
❌ Wrong - should use command_output source instead
**Good pattern:**
User: "Show me the git diff"
You: [Call present_artifact with command_output source: "git diff"]
✅ Correct - let the tool run the command
**Don't read long content out loud - present it visually instead.** **Don't read long content out loud - present it visually instead.**
+1
View File
@@ -23,6 +23,7 @@
"ai": "^5.0.76", "ai": "^5.0.76",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.18.2", "express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"openai": "^4.20.0", "openai": "^4.20.0",
"playwright": "^1.56.1", "playwright": "^1.56.1",
@@ -0,0 +1,32 @@
import { readFileSync } from "fs";
import { inspect } from "util";
import { standardizePrompt } from "ai/internal";
async function processConversation() {
try {
const conversationPath =
".conversations/ce44c79a-0689-4210-8e00-72c0a627406d-2.json";
console.log("Loading conversation from:", conversationPath);
const conversationData: any = JSON.parse(
readFileSync(conversationPath, "utf-8")
);
console.log(
`\nLoaded conversation ${conversationData.conversationId} with ${conversationData.messages.length} messages\n`
);
const result = await standardizePrompt({
prompt: conversationData.messages,
});
console.log("Standardized prompt result:");
console.log(inspect(result, { depth: null, colors: true }));
} catch (error) {
console.error("Error processing conversation:", error);
process.exit(1);
}
}
processConversation();
@@ -119,6 +119,11 @@ const manualTools = {
type: z type: z
.enum(["markdown", "diff", "image", "code"]) .enum(["markdown", "diff", "image", "code"])
.describe("Type of artifact to present."), .describe("Type of artifact to present."),
title: z
.string()
.describe(
"Title for the artifact (e.g., 'Implementation Plan', 'Refactoring Strategy', '/path/to/project/package.json')."
),
source: z.union([ source: z.union([
z.object({ z.object({
type: z.literal("file"), type: z.literal("file"),
@@ -133,11 +138,6 @@ const manualTools = {
text: z.string(), text: z.string(),
}), }),
]), ]),
title: z
.string()
.describe(
"Title for the artifact (e.g., 'Implementation Plan', 'Refactoring Strategy', '/path/to/project/package.json')."
),
}), }),
execute: async () => { execute: async () => {
// Artifact will be broadcast by orchestrator via onToolCall callback // Artifact will be broadcast by orchestrator via onToolCall callback
@@ -1,5 +1,6 @@
import "dotenv/config"; import "dotenv/config";
import express from "express"; import express from "express";
import basicAuth from "express-basic-auth";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { createServer as createHTTPServer, Server as HttpServer } from "http"; import { createServer as createHTTPServer, Server as HttpServer } from "http";
@@ -17,6 +18,15 @@ const __dirname = path.dirname(__filename);
async function createServer(httpServer: HttpServer, config: ServerConfig) { async function createServer(httpServer: HttpServer, config: ServerConfig) {
const app = express(); const app = express();
// Basic authentication
app.use(
basicAuth({
users: { mo: "bo" },
challenge: true,
realm: "Voice Assistant",
})
);
// Middleware // Middleware
app.use(express.json()); app.use(express.json());
@@ -1,7 +1,7 @@
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { readFile, mkdir, writeFile } from "fs/promises"; import { readFile, mkdir, writeFile } from "fs/promises";
import { exec } from "child_process"; import { exec } from "child_process";
import { promisify } from "util"; import { promisify, inspect } from "util";
import { join } from "path"; import { join } from "path";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import { streamText, stepCountIs } from "ai"; import { streamText, stepCountIs } from "ai";
@@ -729,7 +729,7 @@ export class Session {
messages: this.messages, messages: this.messages,
}; };
await writeFile(filepath, JSON.stringify(dump, null, 2), "utf-8"); await writeFile(filepath, inspect(dump, { depth: null }), "utf-8");
console.log( console.log(
`[Session ${this.clientId}] Dumped conversation to ${filepath}` `[Session ${this.clientId}] Dumped conversation to ${filepath}`
); );
@@ -1,9 +1,10 @@
import { exec as execCallback } from "child_process"; import { exec as execCallback, execFile as execFileCallback } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import os from "node:os"; import os from "node:os";
const exec = promisify(execCallback); const exec = promisify(execCallback);
const execFile = promisify(execFileCallback);
// Basic interfaces for tmux objects // Basic interfaces for tmux objects
export interface TmuxSession { export interface TmuxSession {
@@ -55,10 +56,11 @@ export function setShellConfig(config: { type: string }): void {
/** /**
* Execute a tmux command and return the result * Execute a tmux command and return the result
* Uses execFile to avoid shell interpretation of special characters
*/ */
export async function executeTmux(tmuxCommand: string): Promise<string> { export async function executeTmux(args: string[]): Promise<string> {
try { try {
const { stdout } = await exec(`tmux ${tmuxCommand}`); const { stdout } = await execFile("tmux", args);
return stdout.trim(); return stdout.trim();
} catch (error: any) { } catch (error: any) {
throw new Error(`Failed to execute tmux command: ${error.message}`); throw new Error(`Failed to execute tmux command: ${error.message}`);
@@ -70,7 +72,7 @@ export async function executeTmux(tmuxCommand: string): Promise<string> {
*/ */
export async function isTmuxRunning(): Promise<boolean> { export async function isTmuxRunning(): Promise<boolean> {
try { try {
await executeTmux("list-sessions -F '#{session_name}'"); await executeTmux(["list-sessions", "-F", "#{session_name}"]);
return true; return true;
} catch (error) { } catch (error) {
return false; return false;
@@ -83,7 +85,7 @@ export async function isTmuxRunning(): Promise<boolean> {
export async function listSessions(): Promise<TmuxSession[]> { export async function listSessions(): Promise<TmuxSession[]> {
const format = const format =
"#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}"; "#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}";
const output = await executeTmux(`list-sessions -F '${format}'`); const output = await executeTmux(["list-sessions", "-F", format]);
if (!output) return []; if (!output) return [];
@@ -143,9 +145,13 @@ export async function isWindowNameUnique(
*/ */
export async function listWindows(sessionId: string): Promise<TmuxWindow[]> { export async function listWindows(sessionId: string): Promise<TmuxWindow[]> {
const format = "#{window_id}:#{window_name}:#{?window_active,1,0}"; const format = "#{window_id}:#{window_name}:#{?window_active,1,0}";
const output = await executeTmux( const output = await executeTmux([
`list-windows -t '${sessionId}' -F '${format}'` "list-windows",
); "-t",
sessionId,
"-F",
format,
]);
if (!output) return []; if (!output) return [];
@@ -165,9 +171,7 @@ export async function listWindows(sessionId: string): Promise<TmuxWindow[]> {
*/ */
export async function listPanes(windowId: string): Promise<TmuxPane[]> { export async function listPanes(windowId: string): Promise<TmuxPane[]> {
const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}"; const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}";
const output = await executeTmux( const output = await executeTmux(["list-panes", "-t", windowId, "-F", format]);
`list-panes -t '${windowId}' -F '${format}'`
);
if (!output) return []; if (!output) return [];
@@ -190,12 +194,14 @@ export async function capturePaneContent(
lines: number = 200, lines: number = 200,
includeColors: boolean = false includeColors: boolean = false
): Promise<string> { ): Promise<string> {
const colorFlag = includeColors ? "-e" : "";
// Capture a large range to ensure we have enough content // Capture a large range to ensure we have enough content
const captureLines = Math.max(lines, 1000); const captureLines = Math.max(lines, 1000);
const output = await executeTmux( const args = ["capture-pane", "-p"];
`capture-pane -p ${colorFlag} -t '${paneId}' -S -${captureLines} -E -` if (includeColors) {
); args.push("-e");
}
args.push("-t", paneId, "-S", `-${captureLines}`, "-E", "-");
const output = await executeTmux(args);
// Trim trailing whitespace, split by lines, take last N lines, rejoin // Trim trailing whitespace, split by lines, take last N lines, rejoin
const trimmed = output.trimEnd(); const trimmed = output.trimEnd();
@@ -212,9 +218,13 @@ export async function getCurrentWorkingDirectory(
paneId: string paneId: string
): Promise<string> { ): Promise<string> {
try { try {
const tmuxPath = await executeTmux( const tmuxPath = await executeTmux([
`display-message -p -t '${paneId}' '#{pane_current_path}'` "display-message",
); "-p",
"-t",
paneId,
"#{pane_current_path}",
]);
// If tmux returns a valid path, use it // If tmux returns a valid path, use it
if (tmuxPath && tmuxPath.trim()) { if (tmuxPath && tmuxPath.trim()) {
@@ -222,9 +232,13 @@ export async function getCurrentWorkingDirectory(
} }
// Fallback: get the PID and use lsof to find the actual CWD // Fallback: get the PID and use lsof to find the actual CWD
const shellPid = await executeTmux( const shellPid = await executeTmux([
`display-message -p -t '${paneId}' '#{pane_pid}'` "display-message",
); "-p",
"-t",
paneId,
"#{pane_pid}",
]);
const { stdout } = await exec( const { stdout } = await exec(
`lsof -a -p ${shellPid.trim()} -d cwd -Fn | grep '^n' | cut -c2-` `lsof -a -p ${shellPid.trim()} -d cwd -Fn | grep '^n' | cut -c2-`
); );
@@ -242,9 +256,13 @@ export async function getCurrentWorkingDirectory(
export async function getCurrentCommand(paneId: string): Promise<string> { export async function getCurrentCommand(paneId: string): Promise<string> {
try { try {
// Get the shell PID (the pane's main process) // Get the shell PID (the pane's main process)
const shellPid = await executeTmux( const shellPid = await executeTmux([
`display-message -p -t '${paneId}' '#{pane_pid}'` "display-message",
); "-p",
"-t",
paneId,
"#{pane_pid}",
]);
// First, check if there's a child process using comm= (works for all programs including top) // First, check if there's a child process using comm= (works for all programs including top)
// Use 'ax' flags to see all processes // Use 'ax' flags to see all processes
@@ -268,9 +286,13 @@ export async function getCurrentCommand(paneId: string): Promise<string> {
return shellCmd.trim(); return shellCmd.trim();
} catch (error) { } catch (error) {
// Fallback to just the command name if ps fails // Fallback to just the command name if ps fails
return executeTmux( return executeTmux([
`display-message -p -t '${paneId}' '#{pane_current_command}'` "display-message",
); "-p",
"-t",
paneId,
"#{pane_current_command}",
]);
} }
} }
@@ -279,10 +301,19 @@ export async function getCurrentCommand(paneId: string): Promise<string> {
*/ */
export async function createSession(name: string): Promise<TmuxSession | null> { export async function createSession(name: string): Promise<TmuxSession | null> {
const homeDir = process.env.HOME || "~"; const homeDir = process.env.HOME || "~";
await executeTmux(`new-session -d -s "${name}" -n "default" -c "${homeDir}"`); await executeTmux([
"new-session",
"-d",
"-s",
name,
"-n",
"default",
"-c",
homeDir,
]);
// Disable automatic window renaming for all windows in the session // Disable automatic window renaming for all windows in the session
await executeTmux(`set-window-option -t "${name}" automatic-rename off`); await executeTmux(["set-window-option", "-t", name, "automatic-rename", "off"]);
return findSessionByName(name); return findSessionByName(name);
} }
@@ -321,21 +352,27 @@ export async function createWindow(
} }
// Build new-window command with optional working directory // Build new-window command with optional working directory
let newWindowCmd = `new-window -t '${sessionId}' -n '${name}'`; const args = ["new-window", "-t", sessionId, "-n", name];
if (options?.workingDirectory) { if (options?.workingDirectory) {
// Expand tilde to home directory before passing to tmux // Expand tilde to home directory before passing to tmux
const expandedPath = expandTilde(options.workingDirectory); const expandedPath = expandTilde(options.workingDirectory);
newWindowCmd += ` -c '${expandedPath}'`; args.push("-c", expandedPath);
} }
await executeTmux(newWindowCmd); await executeTmux(args);
const windows = await listWindows(sessionId); const windows = await listWindows(sessionId);
const window = windows.find((window) => window.name === name); const window = windows.find((window) => window.name === name);
if (!window) return null; if (!window) return null;
// Disable automatic window renaming // Disable automatic window renaming
await executeTmux(`set-window-option -t '${window.id}' automatic-rename off`); await executeTmux([
"set-window-option",
"-t",
window.id,
"automatic-rename",
"off",
]);
// Get the default pane created with the window // Get the default pane created with the window
const panes = await listPanes(window.id); const panes = await listPanes(window.id);
@@ -367,21 +404,21 @@ export async function createWindow(
* Kill a tmux session by ID * Kill a tmux session by ID
*/ */
export async function killSession(sessionId: string): Promise<void> { export async function killSession(sessionId: string): Promise<void> {
await executeTmux(`kill-session -t '${sessionId}'`); await executeTmux(["kill-session", "-t", sessionId]);
} }
/** /**
* Kill a tmux window by ID * Kill a tmux window by ID
*/ */
export async function killWindow(windowId: string): Promise<void> { export async function killWindow(windowId: string): Promise<void> {
await executeTmux(`kill-window -t '${windowId}'`); await executeTmux(["kill-window", "-t", windowId]);
} }
/** /**
* Kill a tmux pane by ID * Kill a tmux pane by ID
*/ */
export async function killPane(paneId: string): Promise<void> { export async function killPane(paneId: string): Promise<void> {
await executeTmux(`kill-pane -t '${paneId}'`); await executeTmux(["kill-pane", "-t", paneId]);
} }
/** /**
@@ -413,9 +450,15 @@ export async function renameWindow(
windowId = window.id; windowId = window.id;
} }
await executeTmux(`rename-window -t '${windowId}' '${newName}'`); await executeTmux(["rename-window", "-t", windowId, newName]);
// Disable automatic renaming to preserve the manual name // Disable automatic renaming to preserve the manual name
await executeTmux(`set-window-option -t '${windowId}' automatic-rename off`); await executeTmux([
"set-window-option",
"-t",
windowId,
"automatic-rename",
"off",
]);
} }
/** /**
@@ -426,31 +469,35 @@ export async function splitPane(
direction: "horizontal" | "vertical" = "vertical", direction: "horizontal" | "vertical" = "vertical",
size?: number size?: number
): Promise<TmuxPane | null> { ): Promise<TmuxPane | null> {
// Build the split-window command // Build the split-window command args
let splitCommand = "split-window"; const args = ["split-window"];
// Add direction flag (-h for horizontal, -v for vertical) // Add direction flag (-h for horizontal, -v for vertical)
if (direction === "horizontal") { if (direction === "horizontal") {
splitCommand += " -h"; args.push("-h");
} else { } else {
splitCommand += " -v"; args.push("-v");
} }
// Add target pane // Add target pane
splitCommand += ` -t '${targetPaneId}'`; args.push("-t", targetPaneId);
// Add size if specified (as percentage) // Add size if specified (as percentage)
if (size !== undefined && size > 0 && size < 100) { if (size !== undefined && size > 0 && size < 100) {
splitCommand += ` -p ${size}`; args.push("-p", size.toString());
} }
// Execute the split command // Execute the split command
await executeTmux(splitCommand); await executeTmux(args);
// Get the window ID from the target pane to list all panes // Get the window ID from the target pane to list all panes
const windowInfo = await executeTmux( const windowInfo = await executeTmux([
`display-message -p -t '${targetPaneId}' '#{window_id}'` "display-message",
); "-p",
"-t",
targetPaneId,
"#{window_id}",
]);
// List all panes in the window to find the newly created one // List all panes in the window to find the newly created one
const panes = await listPanes(windowInfo); const panes = await listPanes(windowInfo);
@@ -538,20 +585,18 @@ export async function executeCommand(
if (isSpecialKey || isKeyCombo) { if (isSpecialKey || isKeyCombo) {
// Send special key or key combination as-is // Send special key or key combination as-is
await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`); const args = ["send-keys", "-t", paneId];
args.push(...fullCommand.split(" "));
await executeTmux(args);
} else { } else {
// For regular text, send each character individually to ensure proper processing // For regular text, send each character individually to ensure proper processing
// This handles both single characters (like 'q', 'f') and strings (like 'beam') // This handles both single characters (like 'q', 'f') and strings (like 'beam')
for (const char of fullCommand) { for (const char of fullCommand) {
await executeTmux( await executeTmux(["send-keys", "-t", paneId, char]);
`send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
);
} }
} }
} else { } else {
await executeTmux( await executeTmux(["send-keys", "-t", paneId, fullCommand, "Enter"]);
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
);
} }
return commandId; return commandId;
@@ -712,9 +757,13 @@ export async function list({
if (!target) { if (!target) {
throw new Error("target is required for scope 'pane'"); throw new Error("target is required for scope 'pane'");
} }
const windowId = await executeTmux( const windowId = await executeTmux([
`display-message -p -t '${target}' '#{window_id}'` "display-message",
); "-p",
"-t",
target,
"#{window_id}",
]);
const panes = await listPanes(windowId); const panes = await listPanes(windowId);
const pane = panes.find((p) => p.id === target); const pane = panes.find((p) => p.id === target);
if (!pane) { if (!pane) {
@@ -779,9 +828,7 @@ export async function executeShellCommand({
rawMode: false, rawMode: false,
}); });
await executeTmux( await executeTmux(["send-keys", "-t", paneId, fullCommand, "Enter"]);
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
);
// Poll for completion // Poll for completion
const startTime = Date.now(); const startTime = Date.now();
@@ -830,7 +877,9 @@ export async function sendKeys({
// Repeat the key press the specified number of times // Repeat the key press the specified number of times
for (let i = 0; i < repeat; i++) { for (let i = 0; i < repeat; i++) {
// Raw pass-through, no validation or processing // Raw pass-through, no validation or processing
await executeTmux(`send-keys -t '${paneId}' ${keys}`); const args = ["send-keys", "-t", paneId];
args.push(...keys.split(" "));
await executeTmux(args);
} }
// If return_output is requested, wait and capture pane content // If return_output is requested, wait and capture pane content
@@ -897,15 +946,14 @@ export async function sendText({
}; };
}): Promise<string | void> { }): Promise<string | void> {
// Send each character with -l flag for literal interpretation // Send each character with -l flag for literal interpretation
// Using execFile avoids shell interpretation of special characters like ; | & $
for (const char of text) { for (const char of text) {
await executeTmux( await executeTmux(["send-keys", "-l", "-t", paneId, char]);
`send-keys -l -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
);
} }
if (pressEnter) { if (pressEnter) {
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
await executeTmux(`send-keys -t '${paneId}' Enter`); await executeTmux(["send-keys", "-t", paneId, "Enter"]);
} }
// If return_output is requested, wait and capture pane content // If return_output is requested, wait and capture pane content
+8
View File
@@ -380,6 +380,14 @@
background-color: #f59e0b; background-color: #f59e0b;
} }
.send-button.cancelling {
background-color: #ef4444;
}
.send-button.cancelling:hover:not(:disabled) {
background-color: #dc2626;
}
.send-button:disabled { .send-button:disabled {
background-color: #444; background-color: #444;
color: #999; color: #999;
+43 -8
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Mic, Send, Radio } from "lucide-react"; import { Mic, Send, Radio, X } from "lucide-react";
import { useWebSocket } from "./hooks/useWebSocket"; import { useWebSocket } from "./hooks/useWebSocket";
import { createAudioPlayer } from "./lib/audio-playback"; import { createAudioPlayer } from "./lib/audio-playback";
import { createAudioRecorder, type AudioRecorder } from "./lib/audio-capture"; import { createAudioRecorder, type AudioRecorder } from "./lib/audio-capture";
@@ -399,12 +399,47 @@ function App() {
} }
}; };
const handleCancel = async () => {
console.log("[App] Cancelling operations...");
// Stop audio playback
audioPlayerRef.current.stop();
setIsPlayingAudio(false);
// Clear streaming state
setCurrentAssistantMessage("");
// Reset processing state
setIsProcessingAudio(false);
// Send abort request to server
ws.send({
type: "session",
message: {
type: "abort_request",
},
});
addLog("info", "Operations cancelled");
};
// Compute if we're processing an already sent request (not including recording)
const isInProgress =
isProcessingAudio || isPlayingAudio || currentAssistantMessage.length > 0;
const handleButtonClick = async (e: React.MouseEvent<HTMLButtonElement>) => { const handleButtonClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (userInput.trim()) { if (isRecording) {
// Stop recording and send the audio
await handleToggleRecording();
} else if (isInProgress) {
// Cancel processing/playback of already sent request
await handleCancel();
} else if (userInput.trim()) {
handleSendMessage(); handleSendMessage();
} else { } else {
// Start recording
await handleToggleRecording(); await handleToggleRecording();
} }
}; };
@@ -649,15 +684,15 @@ function App() {
<button <button
type="button" type="button"
onClick={handleButtonClick} onClick={handleButtonClick}
disabled={!ws.isConnected || isProcessingAudio} disabled={!ws.isConnected}
className={`send-button ${isRecording ? "recording" : ""} ${ className={`send-button ${isInProgress ? "cancelling" : ""} ${
isProcessingAudio ? "processing" : "" isRecording ? "recording" : ""
} ${userInput.trim() ? "has-text" : ""}`} } ${userInput.trim() ? "has-text" : ""}`}
> >
{isRecording ? ( {isInProgress ? (
<X size={20} />
) : isRecording ? (
<span className="recording-indicator" /> <span className="recording-indicator" />
) : isProcessingAudio ? (
<span className="processing-indicator" />
) : userInput.trim() ? ( ) : userInput.trim() ? (
<Send size={20} /> <Send size={20} />
) : ( ) : (