refactor: cleanup

This commit is contained in:
zhom committed 2026-09-09 10:09:14 +04:00
1 parent 598d3bd513
commit dd42d46753
249 files changed
+67417 -6659

No files matched your search

+225
View File
@@ -0,0 +1,225 @@
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
type AgentRunView,
type AgentStep,
getAgentRun,
isRunOver,
onAgentStatus,
onAgentStep,
onAgentStream,
startAgentRunEvents,
stopAgentRunEvents,
} from "@/lib/agent";
/**
* One run, watched.
*
* Two sources feed the same list and they overlap on purpose. The transcript is
* READ FIRST, over HTTP, so a page opened halfway through a run shows the steps
* that already happened instead of an empty panel waiting for the next one. The
* stream then replays the same transcript and continues live, and both are
* merged by step index — the index is the server's own ordinal, so a replayed
* step and its live twin collapse into one row rather than doubling it.
*/
export interface AgentRunSnapshot {
/** The run as last described, or null before the first read. */
run: AgentRunView | null;
/** Everything it has done, in server order. */
steps: AgentStep[];
/** True only while the first read of a newly watched run is in flight. */
isLoading: boolean;
/** The last read failure, as a backend error envelope. */
error: unknown;
/** Whether steps are currently arriving. */
streamConnected: boolean;
}
export interface UseAgentRunResult extends AgentRunSnapshot {
/** Re-read the run and its transcript. */
refresh: () => Promise<void>;
}
const EMPTY: AgentRunSnapshot = {
run: null,
steps: [],
isLoading: false,
error: null,
streamConnected: false,
};
/**
* Which run each mounted panel wants streamed, coalesced into one decision.
*
* The desktop streams a single run, and React runs an unmounting panel's
* cleanup and the next panel's effect back to back. Sending a stop and a start
* as two separate commands makes that swap a race: they are handled on the Rust
* side as independent tasks, so a stop landing second kills the stream the new
* panel just asked for and the run sits under "steps are not arriving" while
* nothing reconnects. Coalescing into a microtask means only the settled wish
* is ever sent — and `start_agent_run_events` already replaces whatever it was
* watching, so switching runs is one command, not two.
*/
const wishes = new Map<string, string>();
let applied: string | null = null;
let applyScheduled = false;
function applyWishes() {
let next: string | null = null;
for (const wish of wishes.values()) next = wish;
if (next === applied) return;
applied = next;
if (next === null) {
void stopAgentRunEvents().catch((error: unknown) => {
console.error("Failed to stop the agent step stream:", error);
});
return;
}
void startAgentRunEvents(next).catch((error: unknown) => {
// A signed-out or unentitled desktop refuses the subscription. The steps
// already read over HTTP still render, so this is logged, not shown.
console.error("Failed to subscribe to agent run events:", error);
});
}
function scheduleApplyWishes() {
if (applyScheduled) return;
applyScheduled = true;
queueMicrotask(() => {
applyScheduled = false;
applyWishes();
});
}
function mergeStep(steps: AgentStep[], incoming: AgentStep): AgentStep[] {
const next = steps.filter((step) => step.index !== incoming.index);
next.push(incoming);
next.sort((a, b) => a.index - b.index);
return next;
}
/**
* @param runId the run to watch, or null to watch nothing. Changing it swaps
* the whole subscription: the desktop streams one run at a time, because the
* page only ever shows one.
*/
export function useAgentRun(runId: string | null): UseAgentRunResult {
const [snapshot, setSnapshot] = useState<AgentRunSnapshot>(EMPTY);
const consumerId = useId();
// Every async continuation checks this before it writes. React runs mount
// effects twice in development and a user can switch runs faster than a read
// completes, so without it a stale transcript lands on top of a newer one.
const generation = useRef(0);
const load = useCallback(
async (id: string, forGeneration: number, withSpinner: boolean) => {
if (withSpinner) {
setSnapshot((prev) => ({ ...prev, isLoading: true, error: null }));
}
try {
const detail = await getAgentRun(id);
if (generation.current !== forGeneration) return;
const { transcript, ...run } = detail;
setSnapshot((prev) => ({
...prev,
run,
// The transcript is authoritative for what has already happened.
// Live steps that arrived while this read was in flight keep their
// place, because merging is by index either way.
steps: (transcript ?? []).reduce(mergeStep, prev.steps),
isLoading: false,
error: null,
}));
} catch (error) {
if (generation.current !== forGeneration) return;
// A background refresh that fails leaves the previous steps on screen:
// a run's history does not stop being true because one read did.
setSnapshot((prev) => ({
...prev,
isLoading: false,
error: withSpinner ? error : prev.error,
}));
}
},
[],
);
useEffect(() => {
const forGeneration = ++generation.current;
if (!runId) {
setSnapshot(EMPTY);
wishes.delete(consumerId);
scheduleApplyWishes();
return;
}
setSnapshot({ ...EMPTY, isLoading: true });
const offs: Promise<() => void>[] = [
onAgentStep((event) => {
if (generation.current !== forGeneration || event.runId !== runId) {
return;
}
if (!event.step || typeof event.step.index !== "number") return;
setSnapshot((prev) => ({
...prev,
steps: mergeStep(prev.steps, event.step),
}));
}),
onAgentStatus((event) => {
if (generation.current !== forGeneration || event.runId !== runId) {
return;
}
setSnapshot((prev) => ({
...prev,
run: prev.run
? {
...prev.run,
status: event.status,
closeReason: event.closeReason ?? prev.run.closeReason,
errorCode: event.errorCode ?? prev.run.errorCode,
result: event.result ?? prev.run.result,
}
: prev.run,
}));
// A finished run has final counters — tokens, cost, ended_at — that the
// status frame does not carry, so the view is re-read once rather than
// left showing the figures it had a second before the end.
if (isRunOver({ status: event.status })) {
void load(runId, forGeneration, false);
}
}),
onAgentStream((status) => {
if (generation.current !== forGeneration || status.runId !== runId) {
return;
}
setSnapshot((prev) => ({ ...prev, streamConnected: status.connected }));
}),
];
// The transcript read and the subscription start together. The read is what
// fills the panel; the stream is what keeps it filling.
void load(runId, forGeneration, true);
wishes.set(consumerId, runId);
scheduleApplyWishes();
return () => {
generation.current += 1;
for (const off of offs) {
void off.then((unlisten) => {
unlisten();
});
}
wishes.delete(consumerId);
scheduleApplyWishes();
};
}, [runId, load, consumerId]);
const refresh = useCallback(async () => {
if (!runId) return;
await load(runId, generation.current, true);
}, [runId, load]);
return { ...snapshot, refresh };
}
+1 -3
View File
@@ -67,9 +67,7 @@ export function useAutoHeight<T extends HTMLElement = HTMLDivElement>(
const ro = new ResizeObserver(() => {
const next = measure();
requestAnimationFrame(() => {
setHeight(next);
});
setHeight((previous) => (previous === next ? previous : next));
});
ro.observe(el);
+30 -6
View File
@@ -1,7 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import type { Event as TauriEvent } from "@tauri-apps/api/event";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import i18n from "@/i18n";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import { isOnboardingActive } from "@/lib/onboarding-signal";
@@ -58,6 +58,11 @@ export function useBrowserDownload() {
);
const [downloadProgress, setDownloadProgress] =
useState<DownloadProgress | null>(null);
// Last non-terminal stage seen per browser-version. The backend's terminal
// "error" event does not say which phase failed, and only a failed
// extraction deletes the archive, so the phase it interrupted is what
// decides which message the user gets.
const lastDownloadStageRef = useRef(new Map<string, string>());
const formatTime = useCallback((seconds: number): string => {
if (seconds < 60) {
@@ -247,12 +252,15 @@ export function useBrowserDownload() {
// Ensure the long-running download toast is dismissed, and show a finite error toast
dismissToast(`download-${browserStr}-${version}`);
// Same id as the progress listener's error toast, so the concrete
// backend message replaces the generic one instead of stacking on it.
showErrorToast(
i18n.t("browserDownload.toast.downloadFailed", {
browser: browserName,
version,
}),
{
id: `download-error-${browserStr}-${version}`,
description: errorMessage,
duration: 8000,
},
@@ -304,15 +312,22 @@ export function useBrowserDownload() {
const progress = event.payload;
setDownloadProgress(progress);
const downloadKey = `${progress.browser}-${progress.version}`;
const interruptedStage =
lastDownloadStageRef.current.get(downloadKey);
if (
progress.stage === "downloading" ||
progress.stage === "extracting" ||
progress.stage === "verifying"
) {
lastDownloadStageRef.current.set(downloadKey, progress.stage);
setDownloadingBrowsers((prev) => {
if (prev.has(progress.browser)) return prev;
return new Set(prev).add(progress.browser);
});
} else {
lastDownloadStageRef.current.delete(downloadKey);
}
const browserName = getBrowserDisplayName(progress.browser);
@@ -390,14 +405,23 @@ export function useBrowserDownload() {
// During first-run onboarding the welcome dialog surfaces a
// concrete setup error itself, so suppress the global toast.
if (!isOnboardingActive()) {
const wasExtracting = interruptedStage === "extracting";
showErrorToast(
i18n.t("browserDownload.toast.extractionFailed", {
browser: browserName,
version: progress.version,
}),
i18n.t(
wasExtracting
? "browserDownload.toast.extractionFailed"
: "browserDownload.toast.downloadFailed",
{
browser: browserName,
version: progress.version,
},
),
{
id: `download-error-${progress.browser}-${progress.version}`,
description: i18n.t(
"browserDownload.toast.extractionFailedDescription",
wasExtracting
? "browserDownload.toast.extractionFailedDescription"
: "browserDownload.toast.downloadFailedDescription",
),
},
);
+42
View File
@@ -0,0 +1,42 @@
"use client";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from "react";
type InputModality = "pointer" | "keyboard";
const InputModalityContext = createContext<InputModality>("keyboard");
export function InputModalityProvider({ children }: { children: ReactNode }) {
const [modality, setModality] = useState<InputModality>("keyboard");
useEffect(() => {
const onPointerDown = () => setModality("pointer");
const onKeyDown = (event: KeyboardEvent) => {
if (!["Shift", "Control", "Alt", "Meta"].includes(event.key)) {
setModality("keyboard");
}
};
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown, true);
return () => {
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown, true);
};
}, []);
return (
<InputModalityContext.Provider value={modality}>
{children}
</InputModalityContext.Provider>
);
}
export function useInputModality() {
return useContext(InputModalityContext);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useEffect, useRef } from "react";
/** Up, up, down, down, left, right, left, right, B, A. */
const SEQUENCE = [
"ArrowUp",
"ArrowUp",
"ArrowDown",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"ArrowLeft",
"ArrowRight",
"b",
"a",
];
/** A pause longer than this between keys starts the code over. */
const STEP_WINDOW_MS = 2000;
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return (
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
target.isContentEditable
);
}
/**
* Calls `onUnlock` once per completed code. Keys typed into a field never
* count, and a held modifier never counts, so a shortcut that shares a letter
* cannot advance the sequence by accident.
*/
export function useKonamiCode(onUnlock: () => void) {
const unlock = useRef(onUnlock);
unlock.current = onUnlock;
const progress = useRef(0);
const lastKeyAt = useRef(0);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isTyping(event.target)) {
progress.current = 0;
return;
}
const now = Date.now();
if (now - lastKeyAt.current > STEP_WINDOW_MS) progress.current = 0;
lastKeyAt.current = now;
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (key === SEQUENCE[progress.current]) {
progress.current += 1;
} else {
// A wrong key may still be the first key of a fresh attempt.
progress.current = key === SEQUENCE[0] ? 1 : 0;
}
if (progress.current === SEQUENCE.length) {
progress.current = 0;
unlock.current();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, []);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { listen } from "@tauri-apps/api/event";
import { createContext, useContext, useEffect, useState } from "react";
export type LaunchStage =
| "queued"
| "preparing"
| "network"
| "extensions"
| "starting"
| "running"
| "failed";
export interface LaunchEvent {
id: string;
stage: LaunchStage;
timestamp: number;
error?: string | null;
}
const LaunchActivity = createContext<Record<string, LaunchEvent[]>>({});
export function LaunchActivityProvider({
children,
}: {
children: React.ReactNode;
}) {
const [activity, setActivity] = useState<Record<string, LaunchEvent[]>>({});
useEffect(() => {
let active = true;
const subscription = listen<LaunchEvent>(
"profile-launch-stage",
({ payload }) => {
if (!active) return;
setActivity((previous) => {
const next = { ...previous };
next[payload.id] =
payload.stage === "queued"
? [payload]
: [...(previous[payload.id] ?? []), payload].slice(-16);
// This is a recent-operation receipt, not an unbounded log store.
const ids = Object.keys(next);
if (ids.length > 100) {
ids.sort(
(a, b) =>
(next[a].at(-1)?.timestamp ?? 0) -
(next[b].at(-1)?.timestamp ?? 0),
);
delete next[ids[0]];
}
return next;
});
},
);
return () => {
active = false;
void subscription.then((unlisten) => unlisten());
};
}, []);
return (
<LaunchActivity.Provider value={activity}>
{children}
</LaunchActivity.Provider>
);
}
export function useLaunchActivity(profileId: string) {
return useContext(LaunchActivity)[profileId] ?? [];
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
import type { BrowserProfile } from "@/types";
export function useProfileReferences(enabled: boolean) {
const [profiles, setProfiles] = useState<BrowserProfile[] | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!enabled) return;
let active = true;
let revision = 0;
const load = async () => {
const request = ++revision;
try {
const result = await invoke<BrowserProfile[]>("list_browser_profiles");
if (active && request === revision) {
setProfiles(result);
setFailed(false);
}
} catch {
if (active && request === revision) {
setProfiles(null);
setFailed(true);
}
}
};
const subscription = listen("profiles-changed", () => void load());
void load();
return () => {
active = false;
void subscription.then((unlisten) => unlisten());
};
}, [enabled]);
return { profiles, failed };
}
+42 -8
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getRemoteHandoffStates,
onRemoteHandoffChanged,
@@ -21,10 +21,19 @@ import {
*/
export function useRemoteHandoff() {
const [states, setStates] = useState<Record<string, RemoteHandoffState>>({});
const [ready, setReady] = useState(false);
const revision = useRef(0);
const mounted = useRef(false);
const refresh = useCallback(async () => {
const requestedRevision = ++revision.current;
try {
setStates(await getRemoteHandoffStates());
const snapshot = await getRemoteHandoffStates();
// An older command response must never replace a newer handoff event.
if (mounted.current && revision.current === requestedRevision) {
setStates(snapshot);
setReady(true);
}
} catch (error) {
// Not signed in, or the app is still starting. The backend gate still
// applies; the button is simply not pre-disabled.
@@ -33,12 +42,32 @@ export function useRemoteHandoff() {
}, []);
useEffect(() => {
void refresh();
const unlisten = onRemoteHandoffChanged(setStates);
return () => {
void unlisten.then((off) => {
off();
mounted.current = true;
let cancelled = false;
let unlisten: (() => void) | undefined;
void onRemoteHandoffChanged((snapshot) => {
if (cancelled) return;
++revision.current;
setStates(snapshot);
setReady(true);
})
.then((off) => {
if (cancelled) {
off();
return;
}
unlisten = off;
void refresh();
})
.catch((error) => {
console.warn("Could not listen for remote handoffs:", error);
if (!cancelled) void refresh();
});
return () => {
cancelled = true;
mounted.current = false;
++revision.current;
unlisten?.();
};
}, [refresh]);
@@ -47,5 +76,10 @@ export function useRemoteHandoff() {
[states],
);
return { handoffStates: states, handoffFor, refreshHandoff: refresh };
return {
handoffStates: states,
handoffFor,
handoffStatesReady: ready,
refreshHandoff: refresh,
};
}
+19 -1
View File
@@ -19,6 +19,24 @@ export function useSyncSessions() {
}
}, []);
/**
* Fold an authoritative session record into the list.
*
* The pause, hold and arrange commands all return the session as the backend
* now holds it. Applying that return value alongside the event means a
* control never has to show what it hopes happened, and a dropped event
* cannot leave a button lying about the state.
*/
const applySession = useCallback((session: SyncSessionInfo) => {
setSessions((prev) => {
const idx = prev.findIndex((s) => s.id === session.id);
if (idx < 0) return [...prev, session];
const next = [...prev];
next[idx] = session;
return next;
});
}, []);
useEffect(() => {
let changedUnlisten: (() => void) | undefined;
let endedUnlisten: (() => void) | undefined;
@@ -85,5 +103,5 @@ export function useSyncSessions() {
[sessions],
);
return { sessions, getProfileSyncInfo, loadSessions };
return { sessions, getProfileSyncInfo, loadSessions, applySession };
}
+65
View File
@@ -0,0 +1,65 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import i18n from "@/i18n";
import type { TrashedProfileSummary } from "@/types";
/**
* The trash as the backend reports it, kept current by the `trash-changed`
* event every trash mutation (delete, restore, purge, expiry sweep) emits.
*/
export function useTrashEvents() {
const [entries, setEntries] = useState<TrashedProfileSummary[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadEntries = useCallback(async () => {
try {
const listed = await invoke<TrashedProfileSummary[]>(
"list_trashed_profiles",
);
setEntries(listed);
setError(null);
} catch (err: unknown) {
console.error("Failed to load trash:", err);
setError(
i18n.t("trash.loadFailed", {
error: err instanceof Error ? err.message : String(err),
}),
);
}
}, []);
useEffect(() => {
let trashUnlisten: (() => void) | undefined;
let cancelled = false;
const setupListeners = async () => {
try {
await loadEntries();
const unlisten = await listen("trash-changed", () => {
void loadEntries();
});
// The effect may already be torn down by the time listen() resolves.
if (cancelled) {
unlisten();
} else {
trashUnlisten = unlisten;
}
} catch (err) {
console.error("Failed to set up trash event listener:", err);
} finally {
if (!cancelled) setIsLoading(false);
}
};
void setupListeners();
return () => {
cancelled = true;
if (trashUnlisten) trashUnlisten();
};
}, [loadEntries]);
return { entries, isLoading, error, reload: loadEntries };
}
+15
View File
@@ -1,4 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
interface UseWayfernTermsReturn {
@@ -35,6 +36,20 @@ export function useWayfernTerms(): UseWayfernTermsReturn {
void checkTerms();
}, [checkTerms]);
// The backend announces every acceptance, including ones the dialog did
// not drive (the REST API, an automation session), so the gate lifts
// without a restart.
useEffect(() => {
let active = true;
const subscription = listen("wayfern-terms-accepted", () => {
if (active) void checkTerms();
});
return () => {
active = false;
void subscription.then((unlisten) => unlisten());
};
}, [checkTerms]);
return {
termsAccepted,
isLoading,