Skip to content

Commit fc2de32

Browse files
authored
Merge branch 'main' into codex/multi-remote
2 parents 4c41f20 + 8eb727a commit fc2de32

12 files changed

Lines changed: 1065 additions & 38 deletions

src-tauri/gen/apple/codex-monitor_iOS/Info.plist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,4 @@
4949
<key>NSMicrophoneUsageDescription</key>
5050
<string>Allow access to the microphone for dictation.</string>
5151
</dict>
52-
</plist>
52+
</plist>

src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,6 +1544,9 @@ function MainApp() {
15441544
backendMode: appSettings.backendMode,
15451545
activeWorkspace,
15461546
activeThreadId,
1547+
activeThreadIsProcessing: Boolean(
1548+
activeThreadId && threadStatusById[activeThreadId]?.isProcessing,
1549+
),
15471550
refreshThread,
15481551
});
15491552

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
// @vitest-environment jsdom
2+
import { act, renderHook } from "@testing-library/react";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
import { useRemoteThreadRefreshOnFocus } from "./useRemoteThreadRefreshOnFocus";
5+
6+
const windowListeners = new Map<string, Set<() => void>>();
7+
const listenMock = vi.fn<
8+
(eventName: string, handler: () => void) => Promise<() => void>
9+
>();
10+
11+
function registerWindowListener(eventName: string, handler: () => void) {
12+
const handlers = windowListeners.get(eventName) ?? new Set<() => void>();
13+
handlers.add(handler);
14+
windowListeners.set(eventName, handlers);
15+
return () => {
16+
handlers.delete(handler);
17+
};
18+
}
19+
20+
vi.mock("@tauri-apps/api/window", () => ({
21+
getCurrentWindow: () => ({
22+
listen: listenMock,
23+
}),
24+
}));
25+
26+
describe("useRemoteThreadRefreshOnFocus", () => {
27+
let visibilityState: DocumentVisibilityState;
28+
29+
beforeEach(() => {
30+
vi.useFakeTimers();
31+
windowListeners.clear();
32+
listenMock.mockReset();
33+
listenMock.mockImplementation(async (eventName: string, handler: () => void) =>
34+
registerWindowListener(eventName, handler),
35+
);
36+
visibilityState = "visible";
37+
Object.defineProperty(document, "visibilityState", {
38+
configurable: true,
39+
get: () => visibilityState,
40+
});
41+
});
42+
43+
afterEach(() => {
44+
vi.useRealTimers();
45+
});
46+
47+
it("refreshes the active remote thread on focus with debounce", () => {
48+
const refreshThread = vi.fn().mockResolvedValue(undefined);
49+
50+
renderHook(() =>
51+
useRemoteThreadRefreshOnFocus({
52+
backendMode: "remote",
53+
activeWorkspace: {
54+
id: "ws-1",
55+
name: "Workspace",
56+
path: "/tmp/ws-1",
57+
connected: true,
58+
settings: { sidebarCollapsed: false },
59+
},
60+
activeThreadId: "thread-1",
61+
refreshThread,
62+
}),
63+
);
64+
65+
act(() => {
66+
window.dispatchEvent(new Event("focus"));
67+
vi.advanceTimersByTime(499);
68+
});
69+
expect(refreshThread).not.toHaveBeenCalled();
70+
71+
act(() => {
72+
vi.advanceTimersByTime(1);
73+
});
74+
expect(refreshThread).toHaveBeenCalledWith("ws-1", "thread-1");
75+
});
76+
77+
it("does not drop a pending focus refresh when callback identity changes", async () => {
78+
const firstRefreshThread = vi.fn().mockResolvedValue(undefined);
79+
const secondRefreshThread = vi.fn().mockResolvedValue(undefined);
80+
81+
const { rerender } = renderHook(
82+
(props: { refreshThread: typeof firstRefreshThread }) =>
83+
useRemoteThreadRefreshOnFocus({
84+
backendMode: "remote",
85+
activeWorkspace: {
86+
id: "ws-1",
87+
name: "Workspace",
88+
path: "/tmp/ws-1",
89+
connected: true,
90+
settings: { sidebarCollapsed: false },
91+
},
92+
activeThreadId: "thread-1",
93+
refreshThread: props.refreshThread,
94+
}),
95+
{
96+
initialProps: { refreshThread: firstRefreshThread },
97+
},
98+
);
99+
100+
act(() => {
101+
window.dispatchEvent(new Event("focus"));
102+
vi.advanceTimersByTime(250);
103+
});
104+
105+
rerender({ refreshThread: secondRefreshThread });
106+
107+
await act(async () => {
108+
vi.advanceTimersByTime(250);
109+
await Promise.resolve();
110+
});
111+
112+
expect(firstRefreshThread).not.toHaveBeenCalled();
113+
expect(secondRefreshThread).toHaveBeenCalledTimes(1);
114+
expect(secondRefreshThread).toHaveBeenCalledWith("ws-1", "thread-1");
115+
});
116+
117+
it("refreshes when tauri focus event fires", async () => {
118+
const refreshThread = vi.fn().mockResolvedValue(undefined);
119+
120+
renderHook(() =>
121+
useRemoteThreadRefreshOnFocus({
122+
backendMode: "remote",
123+
activeWorkspace: {
124+
id: "ws-1",
125+
name: "Workspace",
126+
path: "/tmp/ws-1",
127+
connected: true,
128+
settings: { sidebarCollapsed: false },
129+
},
130+
activeThreadId: "thread-1",
131+
refreshThread,
132+
}),
133+
);
134+
135+
act(() => {
136+
for (const handler of windowListeners.get("tauri://focus") ?? []) {
137+
handler();
138+
}
139+
vi.advanceTimersByTime(500);
140+
});
141+
142+
await act(async () => {
143+
await Promise.resolve();
144+
});
145+
146+
expect(refreshThread).toHaveBeenCalledTimes(1);
147+
expect(refreshThread).toHaveBeenCalledWith("ws-1", "thread-1");
148+
});
149+
150+
it("cleans up late tauri listener registrations after unmount", async () => {
151+
let resolveFocus: (unlisten: () => void) => void = () => {};
152+
let resolveBlur: (unlisten: () => void) => void = () => {};
153+
const focusRegistration = new Promise<() => void>((resolve) => {
154+
resolveFocus = resolve;
155+
});
156+
const blurRegistration = new Promise<() => void>((resolve) => {
157+
resolveBlur = resolve;
158+
});
159+
listenMock.mockImplementation((eventName: string) => {
160+
if (eventName === "tauri://focus") {
161+
return focusRegistration;
162+
}
163+
if (eventName === "tauri://blur") {
164+
return blurRegistration;
165+
}
166+
return Promise.resolve(() => {});
167+
});
168+
169+
const unlistenFocus = vi.fn();
170+
const unlistenBlur = vi.fn();
171+
const refreshThread = vi.fn().mockResolvedValue(undefined);
172+
173+
const { unmount } = renderHook(() =>
174+
useRemoteThreadRefreshOnFocus({
175+
backendMode: "remote",
176+
activeWorkspace: {
177+
id: "ws-1",
178+
name: "Workspace",
179+
path: "/tmp/ws-1",
180+
connected: true,
181+
settings: { sidebarCollapsed: false },
182+
},
183+
activeThreadId: "thread-1",
184+
refreshThread,
185+
}),
186+
);
187+
188+
unmount();
189+
resolveFocus(unlistenFocus);
190+
resolveBlur(unlistenBlur);
191+
192+
await act(async () => {
193+
await Promise.resolve();
194+
await Promise.resolve();
195+
});
196+
197+
expect(unlistenFocus).toHaveBeenCalledTimes(1);
198+
expect(unlistenBlur).toHaveBeenCalledTimes(1);
199+
});
200+
201+
it("does not poll while processing and refreshes when visibility returns", async () => {
202+
const refreshThread = vi.fn().mockResolvedValue(undefined);
203+
204+
renderHook(() =>
205+
useRemoteThreadRefreshOnFocus({
206+
backendMode: "remote",
207+
activeWorkspace: {
208+
id: "ws-1",
209+
name: "Workspace",
210+
path: "/tmp/ws-1",
211+
connected: true,
212+
settings: { sidebarCollapsed: false },
213+
},
214+
activeThreadId: "thread-1",
215+
activeThreadIsProcessing: true,
216+
refreshThread,
217+
}),
218+
);
219+
220+
await act(async () => {
221+
vi.advanceTimersByTime(20_000);
222+
await Promise.resolve();
223+
});
224+
expect(refreshThread).toHaveBeenCalledTimes(0);
225+
226+
await act(async () => {
227+
visibilityState = "hidden";
228+
document.dispatchEvent(new Event("visibilitychange"));
229+
vi.advanceTimersByTime(20_000);
230+
await Promise.resolve();
231+
});
232+
expect(refreshThread).toHaveBeenCalledTimes(0);
233+
234+
await act(async () => {
235+
visibilityState = "visible";
236+
document.dispatchEvent(new Event("visibilitychange"));
237+
vi.advanceTimersByTime(500);
238+
await Promise.resolve();
239+
});
240+
expect(refreshThread).toHaveBeenCalledTimes(1);
241+
242+
await act(async () => {
243+
vi.advanceTimersByTime(20_000);
244+
await Promise.resolve();
245+
});
246+
expect(refreshThread).toHaveBeenCalledTimes(1);
247+
});
248+
249+
it("keeps a low-frequency poll for active remote threads when not processing", async () => {
250+
const refreshThread = vi.fn().mockResolvedValue(undefined);
251+
252+
renderHook(() =>
253+
useRemoteThreadRefreshOnFocus({
254+
backendMode: "remote",
255+
activeWorkspace: {
256+
id: "ws-1",
257+
name: "Workspace",
258+
path: "/tmp/ws-1",
259+
connected: true,
260+
settings: { sidebarCollapsed: false },
261+
},
262+
activeThreadId: "thread-1",
263+
activeThreadIsProcessing: false,
264+
refreshThread,
265+
}),
266+
);
267+
268+
await act(async () => {
269+
vi.advanceTimersByTime(11_999);
270+
await Promise.resolve();
271+
});
272+
expect(refreshThread).toHaveBeenCalledTimes(0);
273+
274+
await act(async () => {
275+
vi.advanceTimersByTime(1);
276+
await Promise.resolve();
277+
});
278+
expect(refreshThread).toHaveBeenCalledTimes(1);
279+
});
280+
});

0 commit comments

Comments
 (0)