Skip to content

Commit de0f478

Browse files
committed
fix: keep reminders active across app routes
1 parent 98a2f5a commit de0f478

6 files changed

Lines changed: 615 additions & 254 deletions

File tree

apps/web/src/features/app/AppController.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { applyLocale, getPreferredLocale, persistLocale, type Locale } from "../
55
import { SettingsScreen } from "../../features/settings";
66
import WorkspaceScreen from "../../features/workspace/WorkspaceScreen";
77
import type { AppRoute, AppSettings } from "../../types/app";
8+
import { WorkbenchRuntimeCoordinator } from "./WorkbenchRuntimeCoordinator";
89
import {
910
cloneAppSettings,
1011
persistStoredAppSettings,
@@ -53,6 +54,10 @@ export default function AppController() {
5354

5455
return (
5556
<AuthGate locale={locale} onSelectLocale={onSelectLocale}>
57+
<WorkbenchRuntimeCoordinator
58+
locale={locale}
59+
appSettings={appSettings}
60+
/>
5661
<Routes>
5762
<Route path="/" element={<Navigate to="/workspace" replace />} />
5863
<Route
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
import taskCompleteSoundUrl from "../../assets/task-complete.wav";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3+
import { useRelaxState } from "@relax-state/react";
4+
import { useNavigate } from "react-router-dom";
5+
import type { XtermBaseHandle } from "../../components/terminal";
6+
import { createTranslator, type Locale } from "../../i18n";
7+
import { withFallback } from "../../services/http/client.ts";
8+
import {
9+
switchSession as switchSessionRequest,
10+
updateSession as updateSessionRequest,
11+
updateIdlePolicy as updateIdlePolicyRequest,
12+
} from "../../services/http/session.service.ts";
13+
import {
14+
activateWorkspace as activateWorkspaceRequest,
15+
} from "../../services/http/workspace.service.ts";
16+
import { cloneAppSettings } from "../../shared/app/settings";
17+
import { findPaneIdBySessionId } from "../../shared/utils/panes";
18+
import {
19+
isForegroundActiveStatus,
20+
parseNumericId,
21+
restoreVisibleStatus,
22+
toBackgroundStatus,
23+
} from "../../shared/utils/session";
24+
import {
25+
applyWorkbenchUiState,
26+
} from "../../shared/utils/workspace";
27+
import { workbenchState } from "../../state/workbench";
28+
import type {
29+
Tab,
30+
WorkbenchState,
31+
} from "../../state/workbench-core.ts";
32+
import type { AppSettings } from "../../types/app";
33+
import {
34+
type AgentRuntimeRefs,
35+
} from "../agents";
36+
import {
37+
isCompletionReminderBackgroundCase,
38+
notifyCompletionReminder,
39+
playCompletionReminderSound,
40+
} from "../workspace/completion-reminders";
41+
import { createWorkspaceSessionActions } from "../workspace/session-actions";
42+
import { useWorkspaceTransportSync } from "../workspace/workspace-sync-hooks";
43+
import {
44+
applyAppSettingsToTabs,
45+
summarizeWorkbenchSettingsSync,
46+
} from "./workbench-settings-sync";
47+
48+
const withServiceFallback = async <T,>(
49+
operation: () => Promise<T>,
50+
fallback: T,
51+
): Promise<T> => withFallback(operation, fallback);
52+
53+
type WorkbenchRuntimeCoordinatorProps = {
54+
appSettings: AppSettings;
55+
locale: Locale;
56+
};
57+
58+
export const WorkbenchRuntimeCoordinator = ({
59+
appSettings,
60+
locale,
61+
}: WorkbenchRuntimeCoordinatorProps) => {
62+
const navigate = useNavigate();
63+
const [state, setState] = useRelaxState(workbenchState);
64+
const stateRef = useRef(state);
65+
const [isWindowFocused, setIsWindowFocused] = useState(() => (
66+
typeof document === "undefined" ? true : document.hasFocus()
67+
));
68+
const [isDocumentVisible, setIsDocumentVisible] = useState(() => (
69+
typeof document === "undefined" ? true : document.visibilityState === "visible"
70+
));
71+
const completionReminderAudioRef = useRef<HTMLAudioElement | null>(null);
72+
const draftPromptInputRefs = useRef(new Map<string, HTMLInputElement | null>());
73+
const agentTerminalRefs = useRef(new Map<string, XtermBaseHandle | null>());
74+
const agentTerminalQueueRef = useRef(new Map<string, Promise<void>>());
75+
const agentPaneSizeRef = useRef(new Map<string, { cols: number; rows: number }>());
76+
const agentRuntimeSizeRef = useRef(new Map<string, { cols: number; rows: number }>());
77+
const agentResizeStateRef = useRef(new Map<string, {
78+
inflight: boolean;
79+
pending?: { cols: number; rows: number };
80+
}>());
81+
const agentTitleTrackerRef = useRef(new Map<string, {
82+
draftSessionId?: string;
83+
buffer: string;
84+
locked: boolean;
85+
}>());
86+
const runningAgentKeysRef = useRef(new Set<string>());
87+
const agentStartupStateRef = useRef(new Map<string, {
88+
token: number;
89+
startedAt: number;
90+
lastEventAt: number;
91+
sawOutput: boolean;
92+
sawReady: boolean;
93+
exited: boolean;
94+
}>());
95+
const agentStartupTokenRef = useRef(0);
96+
const t = useMemo(() => createTranslator(locale), [locale]);
97+
98+
const agentRuntimeRefs = useMemo<AgentRuntimeRefs>(() => ({
99+
draftPromptInputRefs,
100+
agentTerminalRefs,
101+
agentTerminalQueueRef,
102+
agentPaneSizeRef,
103+
agentRuntimeSizeRef,
104+
agentResizeStateRef,
105+
agentTitleTrackerRef,
106+
runningAgentKeysRef,
107+
agentStartupStateRef,
108+
agentStartupTokenRef,
109+
}), []);
110+
111+
useEffect(() => {
112+
stateRef.current = state;
113+
}, [state]);
114+
115+
useEffect(() => {
116+
const audio = new Audio(taskCompleteSoundUrl);
117+
audio.preload = "auto";
118+
completionReminderAudioRef.current = audio;
119+
return () => {
120+
completionReminderAudioRef.current = null;
121+
};
122+
}, []);
123+
124+
useEffect(() => {
125+
if (typeof window === "undefined" || typeof document === "undefined") {
126+
return;
127+
}
128+
129+
const syncVisibility = () => {
130+
setIsWindowFocused(document.hasFocus());
131+
setIsDocumentVisible(document.visibilityState === "visible");
132+
};
133+
134+
syncVisibility();
135+
window.addEventListener("focus", syncVisibility);
136+
window.addEventListener("blur", syncVisibility);
137+
document.addEventListener("visibilitychange", syncVisibility);
138+
return () => {
139+
window.removeEventListener("focus", syncVisibility);
140+
window.removeEventListener("blur", syncVisibility);
141+
document.removeEventListener("visibilitychange", syncVisibility);
142+
};
143+
}, []);
144+
145+
const updateState = useCallback((updater: (current: WorkbenchState) => WorkbenchState) => {
146+
const next = updater(stateRef.current);
147+
stateRef.current = next;
148+
setState(next);
149+
}, [setState]);
150+
151+
const updateTab = useCallback((tabId: string, updater: (tab: Tab) => Tab) => {
152+
updateState((current) => ({
153+
...current,
154+
tabs: current.tabs.map((tab) => (tab.id === tabId ? updater(tab) : tab)),
155+
}));
156+
}, [updateState]);
157+
158+
const syncSessionPatch = useCallback(async (
159+
tabId: string,
160+
sessionId: string,
161+
patch: {
162+
status?: string;
163+
last_active_at?: number;
164+
claude_session_id?: string;
165+
},
166+
) => {
167+
const backendSessionId = parseNumericId(sessionId);
168+
if (backendSessionId === null) return;
169+
await withServiceFallback(() => updateSessionRequest(tabId, backendSessionId, patch), null);
170+
}, []);
171+
172+
const switchWorkspaceSessionFromReminder = useCallback((tabId: string, sessionId: string) => {
173+
const currentState = stateRef.current;
174+
const targetTabSnapshot = currentState.tabs.find((tab) => tab.id === tabId);
175+
const previousTabSnapshot = currentState.tabs.find((tab) => tab.id === currentState.activeTabId);
176+
const previousSession = previousTabSnapshot?.sessions.find((session) => session.id === previousTabSnapshot.activeSessionId);
177+
const nextSession = targetTabSnapshot?.sessions.find((session) => session.id === sessionId);
178+
if (!targetTabSnapshot || !nextSession) {
179+
return;
180+
}
181+
182+
const nextActiveAt = Date.now();
183+
updateState((current) => {
184+
const targetTab = current.tabs.find((tab) => tab.id === tabId);
185+
if (!targetTab) return current;
186+
187+
const targetPaneId = findPaneIdBySessionId(targetTab.paneLayout, sessionId) ?? targetTab.activePaneId;
188+
const previousActiveTabId = current.activeTabId;
189+
return {
190+
...current,
191+
activeTabId: tabId,
192+
overlay: {
193+
...current.overlay,
194+
visible: false,
195+
},
196+
tabs: current.tabs.map((tab) => {
197+
if (tab.id === tabId) {
198+
return {
199+
...tab,
200+
activeSessionId: sessionId,
201+
activePaneId: targetPaneId,
202+
viewingArchiveId: undefined,
203+
sessions: tab.sessions.map((session) => {
204+
if (session.id === sessionId) {
205+
return {
206+
...session,
207+
unread: 0,
208+
status: restoreVisibleStatus(session),
209+
lastActiveAt: nextActiveAt,
210+
};
211+
}
212+
if (session.id === tab.activeSessionId) {
213+
return { ...session, status: toBackgroundStatus(session.status) };
214+
}
215+
return session;
216+
}),
217+
};
218+
}
219+
220+
if (tab.id === previousActiveTabId) {
221+
return {
222+
...tab,
223+
sessions: tab.sessions.map((session) => (
224+
session.id === tab.activeSessionId
225+
? { ...session, status: toBackgroundStatus(session.status) }
226+
: session
227+
)),
228+
};
229+
}
230+
231+
return tab;
232+
}),
233+
};
234+
});
235+
236+
const backendSessionId = parseNumericId(sessionId);
237+
if (backendSessionId !== null) {
238+
void switchSessionRequest(tabId, backendSessionId).catch(() => {
239+
// Frontend state already switched optimistically.
240+
});
241+
}
242+
243+
if (currentState.activeTabId !== tabId) {
244+
void withServiceFallback(() => activateWorkspaceRequest(tabId), null).then((uiState) => {
245+
if (!uiState) return;
246+
updateState((current) => applyWorkbenchUiState(current, uiState));
247+
});
248+
}
249+
250+
if (previousTabSnapshot && previousSession && isForegroundActiveStatus(previousSession.status)) {
251+
void syncSessionPatch(previousTabSnapshot.id, previousSession.id, { status: "background" });
252+
}
253+
254+
void syncSessionPatch(tabId, sessionId, {
255+
status: restoreVisibleStatus(nextSession),
256+
last_active_at: nextActiveAt,
257+
});
258+
259+
navigate(`/workspace/${tabId}`);
260+
}, [navigate, syncSessionPatch, updateState]);
261+
262+
const onCompletionReminder = useCallback(async ({
263+
workspaceId,
264+
workspaceTitle,
265+
sessionId,
266+
sessionTitle,
267+
}: {
268+
workspaceId: string;
269+
workspaceTitle: string;
270+
sessionId: string;
271+
sessionTitle: string;
272+
}) => {
273+
if (!appSettings.completionNotifications.enabled) {
274+
return;
275+
}
276+
277+
const currentState = stateRef.current;
278+
const isBackgroundCase = isCompletionReminderBackgroundCase(
279+
{
280+
workspaceId,
281+
workspaceTitle,
282+
sessionId,
283+
sessionTitle,
284+
},
285+
{
286+
activeWorkspaceId: currentState.activeTabId,
287+
activeSessionId: currentState.tabs.find((tab) => tab.id === currentState.activeTabId)?.activeSessionId,
288+
documentVisible: isDocumentVisible,
289+
windowFocused: isWindowFocused,
290+
},
291+
);
292+
293+
if (appSettings.completionNotifications.onlyWhenBackground && !isBackgroundCase) {
294+
return;
295+
}
296+
297+
await playCompletionReminderSound(completionReminderAudioRef.current);
298+
await notifyCompletionReminder({
299+
title: sessionTitle,
300+
body: t("completionNotificationBody", { workspaceTitle }),
301+
onClick: () => {
302+
switchWorkspaceSessionFromReminder(workspaceId, sessionId);
303+
},
304+
});
305+
}, [appSettings.completionNotifications, isDocumentVisible, isWindowFocused, switchWorkspaceSessionFromReminder, t]);
306+
307+
const {
308+
markSessionIdle,
309+
refreshTabFromBackend,
310+
settleSessionAfterExit,
311+
} = createWorkspaceSessionActions({
312+
appSettings,
313+
locale,
314+
t,
315+
stateRef,
316+
updateTab,
317+
withServiceFallback,
318+
addToast: () => {},
319+
onCompletionReminder,
320+
});
321+
322+
useWorkspaceTransportSync({
323+
agentRuntimeRefs,
324+
bootstrapReady: state.tabs.length > 0,
325+
refreshTabFromBackend,
326+
markSessionIdle,
327+
settleSessionAfterExit,
328+
syncSessionPatch,
329+
stateRef,
330+
t,
331+
updateState,
332+
});
333+
334+
const settingsSyncFingerprint = useMemo(() => state.tabs.map((tab) => [
335+
tab.id,
336+
tab.agent.provider,
337+
tab.agent.command,
338+
tab.idlePolicy.enabled ? "1" : "0",
339+
String(tab.idlePolicy.idleMinutes),
340+
String(tab.idlePolicy.maxActive),
341+
tab.idlePolicy.pressure ? "1" : "0",
342+
].join(":")).join("|"), [state.tabs]);
343+
344+
useEffect(() => {
345+
const normalized = cloneAppSettings(appSettings);
346+
const { agentWorkspaceIds, idlePolicyWorkspaceIds } = summarizeWorkbenchSettingsSync(
347+
stateRef.current.tabs,
348+
normalized,
349+
);
350+
351+
if (agentWorkspaceIds.length === 0 && idlePolicyWorkspaceIds.length === 0) {
352+
return;
353+
}
354+
355+
updateState((current) => ({
356+
...current,
357+
tabs: applyAppSettingsToTabs(current.tabs, normalized),
358+
}));
359+
360+
idlePolicyWorkspaceIds.forEach((workspaceId) => {
361+
void updateIdlePolicyRequest(workspaceId, normalized.idlePolicy).catch(() => {
362+
// Best effort sync; in-memory settings remain source of truth if backend lags.
363+
});
364+
});
365+
}, [appSettings, settingsSyncFingerprint, updateState]);
366+
367+
return null;
368+
};

0 commit comments

Comments
 (0)