Files
Khaled Ahmed 4e304ccaad fix(audit): resolve bugs, perf and UX issues
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
2026-09-01 17:36:13 +03:00

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 };
}