import { useEffect, useState } from "react"; import { getSettings, setOnboarded, setPdfSettings, setScratchBase, setSessionsPeek, type ModelSettings, type PdfSettings, } from "../api"; import { cancelDictationModelDownload, deleteDictationModel, downloadDictationModel, getAutostart, getDictationStatus, getKeepAwake, checkForUpdate, installUpdate, isTauri, listenDictationDownloadProgress, markDictationTestPassed, pickFolder, setAutostart, setKeepAwake, startDictation, stopDictation, verifyDictationModel, type DictationDownloadProgress, type DictationStatus, } from "../tauri"; import { useThemePref } from "../theme"; import { Icon } from "./Icon"; import { PanelHead } from "./IntegrationsView"; import { ModelsTab } from "./ManageTabs"; import { GalleryModal } from "./GalleryModal"; import { PersonasTab } from "./PersonasTab"; import { showPersonas } from "../flags"; // Settings, restructured (Option 2) into a full-page surface that mirrors IntegrationsView's shell: // a left sub-nav (Appearance · Files · Models · Personas) + centered panel, replacing the old // top-tab ManageModal. Local/app concerns live here; anything external (Connectors, Messaging, MCP, // Activity) stays under Integrations. Appearance + Files are re-skinned to the mock's Tailwind idiom; // Models + Personas host the existing tab components inside the page shell (field re-skin to follow). // "appearance" is the General tab's stable key — callers deep-link with it, so the // rename (UX-021) changed only the label. "files" folded into General as a card. type SetTab = "appearance" | "models" | "voice" | "personas"; const CARD = "rounded-xl2 border border-line bg-panel"; const FIELD_LABEL = "text-[12.5px] font-medium text-ink"; const FIELD_HELP = "text-[12px] text-muted mt-1.5 leading-relaxed"; const INPUT = "flex-1 min-w-0 px-3 py-2 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"; const BTN_ACCENT = "text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shrink-0 disabled:opacity-40"; const BTN_BORDERED = "text-[12.5px] px-3 py-2 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0"; const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" | "sparkle" }[] = [ { key: "appearance", label: "General", icon: "sliders" }, { key: "models", label: "Models", icon: "code" }, { key: "voice", label: "Voice input", icon: "mic" }, { key: "personas", label: "Personas", icon: "sparkle" }, ]; export function SettingsView({ initialTab, onOpenPersona, }: { initialTab?: SetTab; onOpenPersona?: (id: string) => void; }) { // Personas is flag-gated (hidden for launch) — filter the tab AND coerce a stale // deep-link to it (openSettings("personas") callers) so the page never opens on a // section with no nav entry. const personas = showPersonas(); const tabs = personas ? SET_TABS : SET_TABS.filter((t) => t.key !== "personas"); const wanted = initialTab && (personas || initialTab !== "personas") ? initialTab : "appearance"; const [tab, setTab] = useState(wanted); return (
{tab === "appearance" ? ( ) : tab === "models" ? (
{/* Token savings is model-spend behavior, so it lives here (UX-021), not under General. */}
) : tab === "voice" ? ( ) : ( )}
); } // -- Voice input: deliberate model provisioning + compatibility + microphone test (§37) -------- const voiceError = (error: unknown) => error instanceof Error ? error.message : typeof error === "string" ? error : "Voice Input could not complete that action."; const formatBytes = (bytes: number) => { if (!bytes) return "0 MiB"; return `${Math.round(bytes / 1024 / 1024)} MiB`; }; function VoiceInputSection() { const [status, setStatus] = useState(null); const [progress, setProgress] = useState(null); const [phase, setPhase] = useState<"idle" | "downloading" | "verifying" | "testing" | "transcribing">("idle"); const [error, setError] = useState(null); const [testTranscript, setTestTranscript] = useState(""); const desktop = isTauri(); const publish = (next: DictationStatus) => { setStatus(next); window.dispatchEvent(new CustomEvent("coworker:voice-input-changed", { detail: next })); }; useEffect(() => { if (!desktop) return; let active = true; let unlisten = () => {}; void listenDictationDownloadProgress((next) => { if (active) setProgress(next); }).then((stop) => { unlisten = stop; }); void getDictationStatus().then(async (initial) => { if (!active || !initial) return; publish(initial); // One-time migration for models installed by the first STT cut, before verification markers. if (initial.model_installed && !initial.model_verified) { setPhase("verifying"); try { const verified = await verifyDictationModel(); if (active) publish(verified); } catch (verifyError) { if (active) setError(voiceError(verifyError)); } finally { if (active) setPhase("idle"); } } }); return () => { active = false; unlisten(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [desktop]); const download = async () => { setError(null); setProgress({ downloaded_bytes: 0, total_bytes: status?.model_bytes || 0 }); setPhase("downloading"); try { publish(await downloadDictationModel()); } catch (downloadError) { setError(voiceError(downloadError)); const latest = await getDictationStatus(); if (latest) publish(latest); } finally { setPhase("idle"); } }; const cancelDownload = async () => { await cancelDictationModelDownload().catch(() => undefined); }; const repair = async () => { setError(null); try { publish(await deleteDictationModel()); await download(); } catch (repairError) { setError(voiceError(repairError)); } }; const remove = async () => { if (!window.confirm("Delete the local Whisper model and disable Voice Input?")) return; setError(null); try { publish(await deleteDictationModel()); setTestTranscript(""); setProgress(null); } catch (deleteError) { setError(voiceError(deleteError)); } }; const toggleTest = async () => { if (!status?.supported || !status.model_verified) return; setError(null); try { if (status.recording) { setPhase("transcribing"); const transcript = (await stopDictation()).trim(); setTestTranscript(transcript); if (!transcript) throw new Error("No speech was detected. Try again and speak for a little longer."); publish(await markDictationTestPassed()); } else { setTestTranscript(""); setPhase("testing"); publish(await startDictation()); } } catch (testError) { setError(voiceError(testError)); const latest = await getDictationStatus(); if (latest) publish(latest); } finally { setPhase("idle"); } }; const downloading = phase === "downloading" || !!status?.download_in_progress; const progressTotal = progress?.total_bytes || status?.model_bytes || 1; const progressPercent = Math.min(100, Math.round(((progress?.downloaded_bytes || 0) / progressTotal) * 100)); const ready = !!status?.supported && !!status?.model_verified && !!status?.test_passed; return (
{!desktop ? (
Voice Input setup is available in the OpenWorker desktop app.
) : (
Private by design. Audio is held in memory only while you record and is transcribed locally.
This device
{status?.device_summary || "Checking compatibility…"}
{status?.compatibility_reason &&
{status.compatibility_reason}
}
{status && ( {status.supported ? "● Compatible" : "Unsupported"} )}
MacmacOS 12+ · Apple Silicon M1+
WindowsWindows 10 22H2/11 · x64
Memory8 GB recommended
Processor4 CPU cores recommended
W
Whisper Base · English
{status?.model_verified ? `Installed and verified · ${formatBytes(status.model_bytes)}` : `Local voice model · ${formatBytes(status?.model_bytes || 147_964_211)}`}
{status?.model_verified ? ( <> Verified ) : downloading ? ( ) : phase === "verifying" ? ( Verifying… ) : ( )}
{downloading && (
{formatBytes(progress?.downloaded_bytes || 0)} of {formatBytes(progressTotal)}{progressPercent}%
)}
Microphone test
{ready ? "Your microphone and local transcription engine are working." : "Record a short phrase to enable the composer microphone."}
{ready && ● Ready}
{status?.recording &&
● Listening… speak a short phrase, then stop.
} {testTranscript &&
“{testTranscript}”
}
{error &&
{error}
}
)}
); } // -- Personas: installed/enabled/delete management, the dir/Git importer, and the // entry point to the Persona Gallery (a screen-sized modal — installs finish back // here, disabled pending consent; a gallery install re-mounts the list in place). function PersonasSection({ onOpenPersona }: { onOpenPersona?: (id: string) => void }) { const [galleryBump, setGalleryBump] = useState(0); const [galleryOpen, setGalleryOpen] = useState(false); return (
{galleryOpen && ( setGalleryOpen(false)} onInstalled={() => setGalleryBump((b) => b + 1)} /> )}
); } // -- Appearance + app behaviour ------------------------------------------------ function AppearanceSection() { const [theme, setTheme] = useThemePref(); const [autostart, setAuto] = useState(false); const [keepAwake, setKeep] = useState(false); const desktop = isTauri(); useEffect(() => { if (isTauri()) { getAutostart().then((v) => setAuto(!!v)); getKeepAwake().then((v) => setKeep(!!v)); } }, []); const toggleAuto = async (v: boolean) => setAuto(!!(await setAutostart(v))); const toggleKeep = async (v: boolean) => setKeep(!!(await setKeepAwake(v))); const runSetupAgain = async () => { await setOnboarded(false); window.dispatchEvent(new CustomEvent("coworker:open-onboarding")); }; return (
Theme
{(["light", "dark", "auto"] as const).map((p) => ( ))}
Auto follows your Mac’s appearance.
{desktop && (
Always-on
)} {/* One card for the app-lifecycle actions (UX-021): the onboarding replay (§24 — every build, the browser dev shell runs the same first-run flow) and, on desktop, the manual update check (launch also checks automatically). */}
Setup & updates
{desktop && }
Replays the first-run setup: model, first automation, tips.
); } function UpdateInline() { const [state, setState] = useState<"idle" | "checking" | "none" | "found" | "installing" | "error">("idle"); const [version, setVersion] = useState(""); const check = async () => { setState("checking"); try { const u = await checkForUpdate(); if (u) { setVersion(u.version); setState("found"); } else { setState("none"); } } catch { setState("error"); } }; const install = async () => { setState("installing"); try { await installUpdate(); // success restarts the app } catch { setState("error"); } }; return ( {state === "found" ? ( ) : ( )} {(state === "none" || state === "error" || state === "installing") && ( {state === "none" ? "You're on the latest version." : state === "error" ? "Couldn't check right now — try again later." : "Downloading — OpenWorker restarts by itself when it's ready."} )} ); } // Telemetry/Privacy card removed for this release (owner ask 2026-07-22); the // setCloudTelemetry API stays for a future opt-out surface. // -- Sidebar density ------------------------------------------------------------- // -- Token savings (PDF attachments; owner ask, 2026-07-17) --------------------- // Attachments replay with EVERY turn, so a big PDF quietly multiplies token spend. // Auto-compaction of long histories is a planned follow-up (punchlist §7) — until // then this card is the user's dial: attach thresholds + the fallback for models // without native PDF support. function TokenSavingsCard() { const [pdf, setPdf] = useState(null); useEffect(() => { getSettings() .then((s) => setPdf({ pdf_fallback: s.pdf_fallback || "text", pdf_max_pages: s.pdf_max_pages || 20, pdf_max_mb: s.pdf_max_mb || 10, }), ) .catch(() => setPdf({ pdf_fallback: "text", pdf_max_pages: 20, pdf_max_mb: 10 })); }, []); const save = async (patch: Partial) => { setPdf((p) => (p ? { ...p, ...patch } : p)); await setPdfSettings(patch); }; if (!pdf) return null; return (
Token savings
PDF attachments travel with every turn of a conversation, so large documents multiply what you spend on tokens.
PDFs on models without native PDF support
Claude, GPT and Gemini read PDFs natively — this only applies to models that don’t (GLM, Kimi, DeepSeek, local models…). Text extraction is cheapest; page images cost more tokens and need a vision-capable model.
PDFs over these limits are not attached — you’ll see a notice in the composer instead.
); } function SidebarCard() { const [peek, setPeek] = useState(null); useEffect(() => { getSettings() .then((s) => setPeek(s.sessions_peek || 5)) .catch(() => setPeek(5)); }, []); const save = async (n: number) => { const clamped = Math.max(1, Math.min(n || 5, 50)); setPeek(clamped); await setSessionsPeek(clamped); }; if (peek === null) return null; return (
Sidebar
Longer lists collapse behind “Show more”. Applies per coworker and per project.
); } // -- Files (scratch location) — one card inside General (UX-021: a single option // doesn't earn its own tab) ----------------------------------------------------- function FilesCard() { const [settings, setSettings] = useState(null); const [scratchDraft, setScratchDraft] = useState(""); const [scratchMsg, setScratchMsg] = useState(null); const desktop = isTauri(); const refresh = () => getSettings() .then((s) => { setSettings(s); setScratchDraft((d) => d || s.scratch_base || ""); }) .catch(() => setSettings(null)); useEffect(() => { refresh(); }, []); const saveScratch = async () => { setScratchMsg(null); const res = await setScratchBase(scratchDraft.trim()); if (res.ok) { setScratchMsg("Saved. New conversations will use this location."); refresh(); } else { setScratchMsg(res.error || "Could not use that location."); } }; const browseScratch = async () => { const picked = await pickFolder(); if (picked) setScratchDraft(picked); }; if (!settings) return null; return (
Files
setScratchDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && saveScratch()} /> {desktop && ( )}
Each conversation gets its own folder under this location. Existing conversations keep their current folder; you can grant access to more folders inside any conversation.
{scratchMsg &&
{scratchMsg}
}
); }