Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use tauri::menu::{Menu, MenuItemBuilder, PredefinedMenuItem, Submenu};
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};

mod backend;
mod codex;
Expand Down Expand Up @@ -42,12 +42,16 @@ pub fn run() {
let app_name = handle.package_info().name.clone();
let about_item = MenuItemBuilder::with_id("about", format!("About {app_name}"))
.build(handle)?;
let check_updates_item =
MenuItemBuilder::with_id("check_for_updates", "Check for Updates...")
.build(handle)?;
let app_menu = Submenu::with_items(
handle,
app_name.clone(),
true,
&[
&about_item,
&check_updates_item,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::services(handle, None)?,
&PredefinedMenuItem::separator(handle)?,
Expand Down Expand Up @@ -186,6 +190,9 @@ pub fn run() {
.center()
.build();
}
"check_for_updates" => {
let _ = app.emit("updater-check", ());
}
"file_close_window" | "window_close" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.close();
Expand Down
39 changes: 35 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import { playNotificationSound } from "./utils/notificationSounds";
import {
pickWorkspacePath,
} from "./services/tauri";
import { subscribeUpdaterCheck } from "./services/events";
import type {
AccessMode,
GitHubPullRequest,
Expand Down Expand Up @@ -266,10 +267,40 @@ function MainApp() {

const composerInputRef = useRef<HTMLTextAreaElement | null>(null);

const updater = useUpdater({ onDebug: addDebugEntry });
const {
state: updaterState,
startUpdate,
checkForUpdates,
dismiss: dismissUpdate,
} = useUpdater({ onDebug: addDebugEntry });
const isWindowFocused = useWindowFocusState();
const nextTestSoundIsError = useRef(false);

useEffect(() => {
let unlisten: (() => void) | null = null;
subscribeUpdaterCheck(() => {
void checkForUpdates({ announceNoUpdate: true });
})
.then((handler) => {
unlisten = handler;
})
.catch((error) => {
addDebugEntry({
id: `${Date.now()}-client-updater-menu-error`,
timestamp: Date.now(),
source: "error",
label: "updater/menu-error",
payload: error instanceof Error ? error.message : String(error),
});
});

return () => {
if (unlisten) {
unlisten();
}
};
}, [addDebugEntry, checkForUpdates]);

useAgentSoundNotifications({
enabled: appSettings.notificationSoundsEnabled,
isWindowFocused,
Expand Down Expand Up @@ -1331,9 +1362,9 @@ function MainApp() {
}
void listThreadsForWorkspace(workspace);
},
updaterState: updater.state,
onUpdate: updater.startUpdate,
onDismissUpdate: updater.dismiss,
updaterState,
onUpdate: startUpdate,
onDismissUpdate: dismissUpdate,
latestAgentRuns,
isLoadingLatestAgents,
localUsageSnapshot,
Expand Down
16 changes: 15 additions & 1 deletion src/features/update/components/UpdateToast.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { UpdateState } from "../hooks/useUpdater";
import { UpdateToast } from "./UpdateToast";
Expand Down Expand Up @@ -66,4 +66,18 @@ describe("UpdateToast", () => {
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(onUpdate).toHaveBeenCalledTimes(1);
});

it("renders latest state and allows dismiss", () => {
const onDismiss = vi.fn();
const state: UpdateState = { stage: "latest" };

const { container } = render(
<UpdateToast state={state} onUpdate={vi.fn()} onDismiss={onDismiss} />,
);
const scoped = within(container);

expect(scoped.getByText("You’re up to date.")).toBeTruthy();
fireEvent.click(scoped.getByRole("button", { name: "Dismiss" }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
10 changes: 10 additions & 0 deletions src/features/update/components/UpdateToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ export function UpdateToast({ state, onUpdate, onDismiss }: UpdateToastProps) {
</div>
</>
)}
{state.stage === "latest" && (
<div className="update-toast-inline">
<div className="update-toast-body update-toast-body-inline">
You’re up to date.
</div>
<button className="secondary" onClick={onDismiss}>
Dismiss
</button>
</div>
)}
{state.stage === "downloading" && (
<>
<div className="update-toast-body">
Expand Down
18 changes: 18 additions & 0 deletions src/features/update/hooks/useUpdater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ describe("useUpdater", () => {
expect(result.current.state.stage).toBe("idle");
});

it("announces when no update is available for manual checks", async () => {
vi.useFakeTimers();
checkMock.mockResolvedValue(null);
const { result } = renderHook(() => useUpdater({}));

await act(async () => {
await result.current.checkForUpdates({ announceNoUpdate: true });
});

expect(result.current.state.stage).toBe("latest");

await act(async () => {
vi.advanceTimersByTime(2000);
});

expect(result.current.state.stage).toBe("idle");
});

it("downloads and restarts when update is available", async () => {
const close = vi.fn();
const downloadAndInstall = vi.fn(async (onEvent) => {
Expand Down
34 changes: 30 additions & 4 deletions src/features/update/hooks/useUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type UpdateStage =
| "downloading"
| "installing"
| "restarting"
| "latest"
| "error";

type UpdateProgress = {
Expand All @@ -34,21 +35,40 @@ type UseUpdaterOptions = {
export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) {
const [state, setState] = useState<UpdateState>({ stage: "idle" });
const updateRef = useRef<Update | null>(null);
const latestTimeoutRef = useRef<number | null>(null);
const latestToastDurationMs = 2000;

const clearLatestTimeout = useCallback(() => {
if (latestTimeoutRef.current !== null) {
window.clearTimeout(latestTimeoutRef.current);
latestTimeoutRef.current = null;
}
}, []);

const resetToIdle = useCallback(async () => {
clearLatestTimeout();
const update = updateRef.current;
updateRef.current = null;
setState({ stage: "idle" });
await update?.close();
}, []);
}, [clearLatestTimeout]);

const checkForUpdates = useCallback(async () => {
const checkForUpdates = useCallback(async (options?: { announceNoUpdate?: boolean }) => {
let update: Awaited<ReturnType<typeof check>> | null = null;
try {
clearLatestTimeout();
setState({ stage: "checking" });
update = await check();
if (!update) {
setState({ stage: "idle" });
if (options?.announceNoUpdate) {
setState({ stage: "latest" });
latestTimeoutRef.current = window.setTimeout(() => {
latestTimeoutRef.current = null;
setState({ stage: "idle" });
}, latestToastDurationMs);
} else {
setState({ stage: "idle" });
}
return;
}

Expand All @@ -73,7 +93,7 @@ export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) {
await update?.close();
}
}
}, [onDebug]);
}, [clearLatestTimeout, onDebug]);

const startUpdate = useCallback(async () => {
const update = updateRef.current;
Expand Down Expand Up @@ -152,10 +172,16 @@ export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) {
void checkForUpdates();
}, [checkForUpdates, enabled]);

useEffect(() => {
return () => {
clearLatestTimeout();
};
}, [clearLatestTimeout]);

return {
state,
startUpdate,
checkForUpdates,
dismiss: resetToIdle,
};
}
8 changes: 8 additions & 0 deletions src/services/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,11 @@ export async function subscribeTerminalOutput(
onEvent(event.payload);
});
}

export async function subscribeUpdaterCheck(
onEvent: () => void,
): Promise<Unsubscribe> {
return listen("updater-check", () => {
onEvent();
});
}
11 changes: 11 additions & 0 deletions src/styles/update-toasts.css
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@
justify-content: flex-end;
}

.update-toast-inline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}

.update-toast-body-inline {
margin-bottom: 0;
}

@keyframes update-toast-in {
from {
opacity: 0;
Expand Down