Compare commits
5 Commits
v0.1.83-ma
...
v0.1.88-ma
| Author | SHA1 | Date | |
|---|---|---|---|
| c853f2676d | |||
| 090aad6bc6 | |||
| c023d80c86 | |||
| 33f02e65c0 | |||
| c5e28f9caa |
@@ -70,6 +70,8 @@ pub struct AppSettings {
|
||||
pub dismissed_update_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub timezone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_microphone: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
@@ -87,6 +89,7 @@ impl Default for AppSettings {
|
||||
auto_check_updates: true,
|
||||
dismissed_update_version: None,
|
||||
timezone: None,
|
||||
default_microphone: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ pub struct Project {
|
||||
pub bedrock_config: Option<BedrockConfig>,
|
||||
pub allow_docker_access: bool,
|
||||
pub ssh_key_path: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub git_token: Option<String>,
|
||||
pub git_user_name: Option<String>,
|
||||
pub git_user_email: Option<String>,
|
||||
@@ -100,14 +100,14 @@ impl Default for BedrockAuthMethod {
|
||||
pub struct BedrockConfig {
|
||||
pub auth_method: BedrockAuthMethod,
|
||||
pub aws_region: String,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_access_key_id: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_session_token: Option<String>,
|
||||
pub aws_profile: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_bearer_token: Option<String>,
|
||||
pub model_id: Option<String>,
|
||||
pub disable_prompt_caching: bool,
|
||||
|
||||
@@ -30,6 +30,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||
const [operationCompleted, setOperationCompleted] = useState(false);
|
||||
const [showRemoveConfirm, setShowRemoveConfirm] = useState(false);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editName, setEditName] = useState(project.name);
|
||||
const isSelected = selectedProjectId === project.id;
|
||||
const isStopped = project.status === "stopped" || project.status === "error";
|
||||
|
||||
@@ -54,6 +57,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
// Sync local state when project prop changes (e.g., after save or external update)
|
||||
useEffect(() => {
|
||||
setEditName(project.name);
|
||||
setPaths(project.paths ?? []);
|
||||
setSshKeyPath(project.ssh_key_path ?? "");
|
||||
setGitName(project.git_user_name ?? "");
|
||||
@@ -309,7 +313,41 @@ export default function ProjectCard({ project }: Props) {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
||||
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
|
||||
{isEditingName ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={async () => {
|
||||
setIsEditingName(false);
|
||||
const trimmed = editName.trim();
|
||||
if (trimmed && trimmed !== project.name) {
|
||||
try {
|
||||
await update({ ...project, name: trimmed });
|
||||
} catch (err) {
|
||||
console.error("Failed to rename project:", err);
|
||||
setEditName(project.name);
|
||||
}
|
||||
} else {
|
||||
setEditName(project.name);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") { setEditName(project.name); setIsEditingName(false); }
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-sm font-medium flex-1 min-w-0 px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-[var(--text-primary)] focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-medium truncate flex-1 cursor-text"
|
||||
title="Double-click to rename"
|
||||
onDoubleClick={(e) => { e.stopPropagation(); setIsEditingName(true); }}
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 ml-4 space-y-0.5">
|
||||
{project.paths.map((pp, i) => (
|
||||
@@ -385,16 +423,34 @@ export default function ProjectCard({ project }: Props) {
|
||||
disabled={false}
|
||||
label={showConfig ? "Hide" : "Config"}
|
||||
/>
|
||||
<ActionButton
|
||||
onClick={async () => {
|
||||
if (confirm(`Remove project "${project.name}"?`)) {
|
||||
await remove(project.id);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
label="Remove"
|
||||
danger
|
||||
/>
|
||||
{showRemoveConfirm ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs">
|
||||
<span className="text-[var(--text-secondary)]">Remove?</span>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setShowRemoveConfirm(false);
|
||||
await remove(project.id);
|
||||
}}
|
||||
className="px-1.5 py-0.5 rounded text-white bg-[var(--error)] hover:opacity-80 transition-colors"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowRemoveConfirm(false); }}
|
||||
className="px-1.5 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] transition-colors"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<ActionButton
|
||||
onClick={() => setShowRemoveConfirm(true)}
|
||||
disabled={loading}
|
||||
label="Remove"
|
||||
danger
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config panel */}
|
||||
@@ -869,3 +925,4 @@ function ActionButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
101
app/src/components/settings/MicrophoneSettings.tsx
Normal file
101
app/src/components/settings/MicrophoneSettings.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
interface AudioDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function MicrophoneSettings() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
const [devices, setDevices] = useState<AudioDevice[]>([]);
|
||||
const [selected, setSelected] = useState(appSettings?.default_microphone ?? "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [permissionNeeded, setPermissionNeeded] = useState(false);
|
||||
|
||||
// Sync local state when appSettings change
|
||||
useEffect(() => {
|
||||
setSelected(appSettings?.default_microphone ?? "");
|
||||
}, [appSettings?.default_microphone]);
|
||||
|
||||
const enumerateDevices = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setPermissionNeeded(false);
|
||||
try {
|
||||
// Request mic permission first so device labels are available
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
|
||||
const allDevices = await navigator.mediaDevices.enumerateDevices();
|
||||
const mics = allDevices
|
||||
.filter((d) => d.kind === "audioinput")
|
||||
.map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
label: d.label || `Microphone (${d.deviceId.slice(0, 8)}...)`,
|
||||
}));
|
||||
setDevices(mics);
|
||||
} catch {
|
||||
setPermissionNeeded(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Enumerate devices on mount
|
||||
useEffect(() => {
|
||||
enumerateDevices();
|
||||
}, [enumerateDevices]);
|
||||
|
||||
const handleChange = async (deviceId: string) => {
|
||||
setSelected(deviceId);
|
||||
if (appSettings) {
|
||||
await saveSettings({ ...appSettings, default_microphone: deviceId || null });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Microphone</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Audio input device for Claude Code voice mode (/voice)
|
||||
</p>
|
||||
{permissionNeeded ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
Microphone permission required
|
||||
</span>
|
||||
<button
|
||||
onClick={enumerateDevices}
|
||||
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||
>
|
||||
Grant Access
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
disabled={loading}
|
||||
className="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">System Default</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={enumerateDevices}
|
||||
disabled={loading}
|
||||
title="Refresh microphone list"
|
||||
className="text-xs px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useVoice } from "../../hooks/useVoice";
|
||||
import { UrlDetector } from "../../lib/urlDetector";
|
||||
import UrlToast from "./UrlToast";
|
||||
|
||||
@@ -24,8 +23,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const detectorRef = useRef<UrlDetector | null>(null);
|
||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||
|
||||
const voice = useVoice(sessionId);
|
||||
|
||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
@@ -203,7 +200,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||
webglRef.current = null;
|
||||
term.dispose();
|
||||
voice.stop();
|
||||
};
|
||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -288,32 +284,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
{imagePasteMsg}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={voice.toggle}
|
||||
title={
|
||||
voice.state === "active"
|
||||
? "Voice active — click to stop"
|
||||
: voice.error
|
||||
? `Voice error: ${voice.error}`
|
||||
: "Enable voice input for /voice mode"
|
||||
}
|
||||
className={`absolute bottom-4 left-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium border shadow-lg transition-colors cursor-pointer ${
|
||||
voice.state === "active"
|
||||
? "bg-[#1a3a2a] text-[#3fb950] border-[#238636] hover:bg-[#243b2a]"
|
||||
: voice.state === "starting"
|
||||
? "bg-[#1f2937] text-[#d29922] border-[#30363d] opacity-75"
|
||||
: voice.state === "error"
|
||||
? "bg-[#3a1a1a] text-[#ff7b72] border-[#da3633] hover:bg-[#4a2020]"
|
||||
: "bg-[#1f2937] text-[#b1bac4] border-[#30363d] hover:bg-[#2d3748] hover:text-[#e6edf3]"
|
||||
}`}
|
||||
disabled={voice.state === "starting"}
|
||||
>
|
||||
{voice.state === "active"
|
||||
? "Mic On"
|
||||
: voice.state === "starting"
|
||||
? "Mic..."
|
||||
: "Mic Off"}
|
||||
</button>
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={handleScrollToBottom}
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as commands from "../lib/tauri-commands";
|
||||
|
||||
type VoiceState = "inactive" | "starting" | "active" | "error";
|
||||
|
||||
export function useVoice(sessionId: string) {
|
||||
export function useVoice(sessionId: string, deviceId?: string | null) {
|
||||
const [state, setState] = useState<VoiceState>("inactive");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -20,14 +20,19 @@ export function useVoice(sessionId: string) {
|
||||
// 1. Start the audio bridge in the container (creates FIFO writer)
|
||||
await commands.startAudioBridge(sessionId);
|
||||
|
||||
// 2. Get microphone access
|
||||
// 2. Get microphone access (use specific device if configured)
|
||||
const audioConstraints: MediaTrackConstraints = {
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
};
|
||||
if (deviceId) {
|
||||
audioConstraints.deviceId = { exact: deviceId };
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
audio: audioConstraints,
|
||||
});
|
||||
streamRef.current = stream;
|
||||
|
||||
@@ -62,7 +67,7 @@ export function useVoice(sessionId: string) {
|
||||
// Clean up on failure
|
||||
await commands.stopAudioBridge(sessionId).catch(() => {});
|
||||
}
|
||||
}, [sessionId, state]);
|
||||
}, [sessionId, state, deviceId]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
// Tear down audio pipeline
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface AppSettings {
|
||||
auto_check_updates: boolean;
|
||||
dismissed_update_version: string | null;
|
||||
timezone: string | null;
|
||||
default_microphone: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
|
||||
Reference in New Issue
Block a user