mirror of
https://github.com/Heretek-AI/ompweb.git
synced 2026-11-07 10:56:00 -10:00
Client logic: - ChatWindow nearBottom listener now attaches after the loading gate (history no longer slides away mid-run); streaming bubble gets a per-message key via timestamp so block timers/expanded state no longer leak across messages - Background loadSession failures log instead of replacing the chat with a full-screen error - AudioContext hoisted to a module singleton (sound died after ~6 session switches); theme OS-flip re-renders all consumers - ChatInput: attachment revision bump on draft switch/unmount (no cross-session image leak), attachError reset, builtin-command clear guard, fileIndex AbortController - SessionSidebar: restore deadlock on empty/failed list fixed, expansion keys case-folded via comparableProjectPath, timer cleanup, loadProjects seq guard, remove-project catch+toast, worktree dropdown close + case-folded highlight, SSE onerror fallback - AppShell: auto-name refreshKey below guard, archive-restore bails when selection changes + tracked timer, same-id select early-return, cwd-suppress expected-string latch, fork optimistic row, palette null-cwd default-cwd fallback, denied-notification toast, visibilitychange update re-check - MessageView: terminal indicator for interrupted toolCalls, image-only results render, edit-from-here gated on navigate success (handleNavigate/loadContext return Promise<boolean>), ToolCallBlock shallow input compare - CommandPalette seq guard + focus restore; composer panels persist collapse state; SubagentTranscriptDialog refetch/version fixes; TodoList in_progress-first preview Server/RPC: - getMcpList dedicated in-flight mutex (no waiter stealing/hangs); abort resets awaitingAgentStart state (no 10s phantom running) - agent/new destroys spawned child on post-spawn failure - sessions PATCH bounded body + stable error contract; mcp liveError sanitized; runIsolatedUtilityCommand abortable (wired to req.signal) - file-access strips Windows long-path prefixes (false 403s); worktree branch-name and skills-install package-name validation Data layer: - session-reader: timestamps normalized, 256MiB parse-cache byte budget, 404-poll cache churn fix - subagent-history: stale progress can't clobber settled status, positional page reads (O(n^2)->O(n)), single-pass async collection - tool-presets returns null for custom sets; shortModel strips only thinking-level suffix; draft-store globalThis persistence; subagent-types lastUpdate stamp; project-ordering folded fallback Perf: displayModel/availableThinkingLevels memoized (composer no longer re-renders per token frame); ChatMinimap drag re-entrancy, epsilon-quantized ratios, transform-based viewport Gates: tsc clean, 472/472 tests, lint clean for touched files
107 lines
3.3 KiB
TypeScript
107 lines
3.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback, useEffect } from "react";
|
|
|
|
function playTone(ctx: AudioContext) {
|
|
const now = ctx.currentTime;
|
|
const freqs = [523.25, 659.25];
|
|
freqs.forEach((freq, i) => {
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.type = "sine";
|
|
osc.frequency.value = freq;
|
|
const t = now + i * 0.18;
|
|
gain.gain.setValueAtTime(0, t);
|
|
gain.gain.linearRampToValueAtTime(0.18, t + 0.02);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
|
|
osc.start(t);
|
|
osc.stop(t + 0.45);
|
|
// Connected WebAudio nodes are not garbage-collected; detach them once the
|
|
// tone finishes so each completion sound does not leak 4 graph nodes.
|
|
osc.onended = () => {
|
|
osc.disconnect();
|
|
gain.disconnect();
|
|
};
|
|
});
|
|
}
|
|
|
|
// One AudioContext per browser tab, shared by every hook instance. ChatWindow
|
|
// is keyed per session, so a per-hook context would be re-created on every
|
|
// session switch and Chrome caps live contexts (~6) — after which
|
|
// new AudioContext() throws and the completion sound dies permanently.
|
|
let sharedCtx: AudioContext | null = null;
|
|
function getSharedCtx(): AudioContext | null {
|
|
if (sharedCtx && sharedCtx.state !== "closed") return sharedCtx;
|
|
try {
|
|
sharedCtx = new AudioContext();
|
|
} catch {
|
|
return null;
|
|
}
|
|
return sharedCtx;
|
|
}
|
|
|
|
export function useAudio() {
|
|
const [enabled, setEnabled] = useState<boolean>(() => {
|
|
if (typeof window === "undefined") return true;
|
|
const stored = localStorage.getItem("omp-sound-enabled");
|
|
return stored === null ? true : stored === "true";
|
|
});
|
|
|
|
const enabledRef = useRef(enabled);
|
|
useEffect(() => { enabledRef.current = enabled; }, [enabled]);
|
|
|
|
// The settings dialog toggles the same preference; keep the live state in
|
|
// sync when it changes there.
|
|
useEffect(() => {
|
|
const onPrefChange = (e: Event) => {
|
|
const detail = (e as CustomEvent<boolean>).detail;
|
|
if (typeof detail !== "boolean") return;
|
|
enabledRef.current = detail;
|
|
setEnabled(detail);
|
|
};
|
|
window.addEventListener("omp-sound-pref-change", onPrefChange);
|
|
return () => window.removeEventListener("omp-sound-pref-change", onPrefChange);
|
|
}, []);
|
|
|
|
const unlockAudio = useCallback((force = false) => {
|
|
if (!force && !enabledRef.current) return;
|
|
const ctx = getSharedCtx();
|
|
if (!ctx || ctx.state !== "suspended") return;
|
|
ctx.resume().catch(() => {});
|
|
}, []);
|
|
|
|
const toggle = useCallback(() => {
|
|
const next = !enabledRef.current;
|
|
if (next) unlockAudio(true);
|
|
try {
|
|
localStorage.setItem("omp-sound-enabled", String(next));
|
|
} catch {
|
|
// Storage may be unavailable (private mode, quota); the in-memory
|
|
// preference still applies for this session.
|
|
}
|
|
setEnabled(next);
|
|
}, [unlockAudio]);
|
|
|
|
const playDone = useCallback(() => {
|
|
if (!enabledRef.current) return;
|
|
const ctx = getSharedCtx();
|
|
if (!ctx) return;
|
|
const play = () => {
|
|
try {
|
|
playTone(ctx);
|
|
} catch {
|
|
// AudioContext not available
|
|
}
|
|
};
|
|
if (ctx.state === "suspended") {
|
|
ctx.resume().then(play).catch(() => {});
|
|
return;
|
|
}
|
|
play();
|
|
}, []);
|
|
|
|
return { soundEnabled: enabled, onSoundToggle: toggle, playDoneSound: playDone, unlockAudio, soundEnabledRef: enabledRef };
|
|
}
|