Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c693342
feat(desktop): add manual check-for-updates from web settings UI
AriajSarkar Mar 15, 2026
1c05006
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 15, 2026
def0d17
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 16, 2026
f57619d
refactor: use useQuery for update state instead of manual useEffect
AriajSarkar Mar 16, 2026
d50e306
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 16, 2026
e840966
refactor: use queryOptions pattern with real-time sync and multi-purp…
AriajSarkar Mar 16, 2026
7c4ac99
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 17, 2026
9875ec0
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 17, 2026
945774b
feat(web): add tooltip to update button in settings
AriajSarkar Mar 17, 2026
8d2480a
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 17, 2026
69e9ac7
Merge remote-tracking branch 'upstream/main' into feat/manual-update-…
AriajSarkar Mar 18, 2026
0f4cc76
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 18, 2026
3dd5250
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 19, 2026
4e5586b
Merge remote-tracking branch 'upstream/main' into feat/manual-update-…
AriajSarkar Mar 21, 2026
b2ce524
feat(desktop-update): improve update visibility and add restart confi…
AriajSarkar Mar 21, 2026
837ccff
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 23, 2026
29f1484
Merge upstream/main into feat/manual-update-check - resolve settings …
AriajSarkar Mar 24, 2026
a146f06
fix(settings): remove redundant getCheckForUpdateButtonLabel, use act…
AriajSarkar Mar 24, 2026
5d49448
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 24, 2026
b216503
fix: tooltip fallback returns 'Up to date' for idle/up-to-date states…
AriajSarkar Mar 24, 2026
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
17 changes: 17 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect";
import type {
DesktopTheme,
DesktopUpdateActionResult,
DesktopUpdateCheckResult,
DesktopUpdateState,
} from "@t3tools/contracts";
import { autoUpdater } from "electron-updater";
Expand Down Expand Up @@ -56,6 +57,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const UPDATE_CHECK_CHANNEL = "desktop:update-check";
const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
const STATE_DIR = Path.join(BASE_DIR, "userdata");
const DESKTOP_SCHEME = "t3";
Expand Down Expand Up @@ -1211,6 +1213,21 @@ function registerIpcHandlers(): void {
state: updateState,
} satisfies DesktopUpdateActionResult;
});

ipcMain.removeHandler(UPDATE_CHECK_CHANNEL);
ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => {
if (!updaterConfigured) {
return {
checked: false,
state: updateState,
} satisfies DesktopUpdateCheckResult;
}
await checkForUpdates("web-ui");
return {
checked: true,
state: updateState,
} satisfies DesktopUpdateCheckResult;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IPC handler reports checked: true when check was skipped

Low Severity

The UPDATE_CHECK_CHANNEL handler always returns { checked: true } after awaiting checkForUpdates("web-ui"), but checkForUpdates can silently return without performing any check — for example, when updateCheckInFlight is already true (race with auto-poll) or isQuitting is true. The client relies on checked being false to display an error, so a skipped check produces no user feedback at all.

Fix in Cursor Fix in Web

});
}

function getIconOption(): { icon: string } | Record<string, never> {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
const MENU_ACTION_CHANNEL = "desktop:menu-action";
const UPDATE_STATE_CHANNEL = "desktop:update-state";
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
const UPDATE_CHECK_CHANNEL = "desktop:update-check";
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
Expand All @@ -32,6 +33,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
};
},
getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),
checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),
installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),
onUpdateState: (listener) => {
Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {
ArrowDownToLineIcon,
ArrowLeftIcon,
ChevronRightIcon,
FolderIcon,
GitPullRequestIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
SquarePenIcon,
TerminalIcon,
Expand Down Expand Up @@ -1062,7 +1062,9 @@ export default function Sidebar() {
? "text-sky-400"
: shouldHighlightDesktopUpdateError(desktopUpdateState)
? "text-rose-500 animate-pulse"
: "text-amber-500 animate-pulse";
: desktopUpdateState?.status === "available"
? "text-emerald-500"
: "text-muted-foreground";
const newThreadShortcutLabel = useMemo(
() =>
shortcutLabelForCommand(keybindings, "chat.newLocal") ??
Expand Down Expand Up @@ -1106,6 +1108,11 @@ export default function Sidebar() {
}

if (desktopUpdateButtonAction === "install") {
const version = desktopUpdateState.downloadedVersion ?? desktopUpdateState.availableVersion;
const confirmed = window.confirm(
`Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`,
);
if (!confirmed) return;
void bridge
.installUpdate()
.then((result) => {
Expand Down Expand Up @@ -1185,10 +1192,10 @@ export default function Sidebar() {
aria-label={desktopUpdateTooltip}
aria-disabled={desktopUpdateButtonDisabled || undefined}
disabled={desktopUpdateButtonDisabled}
className={`inline-flex size-7 ml-auto mt-1.5 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`}
className={`inline-flex size-7 ml-auto mt-1.5 items-center justify-center rounded-md transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`}
onClick={handleDesktopUpdateButtonClick}
>
<RocketIcon className="size-3.5" />
<ArrowDownToLineIcon className="size-3.5" />
</button>
}
/>
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/components/desktopUpdate.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";

import {
canCheckForUpdate,
getArm64IntelBuildWarningDescription,
getDesktopUpdateActionError,
getDesktopUpdateButtonTooltip,
Expand Down Expand Up @@ -207,3 +208,57 @@ describe("desktop update UI helpers", () => {
expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update");
});
});

describe("canCheckForUpdate", () => {
it("returns false for null state", () => {
expect(canCheckForUpdate(null)).toBe(false);
});

it("returns false when updates are disabled", () => {
expect(canCheckForUpdate({ ...baseState, enabled: false, status: "disabled" })).toBe(false);
});

it("returns false while checking", () => {
expect(canCheckForUpdate({ ...baseState, status: "checking" })).toBe(false);
});

it("returns false while downloading", () => {
expect(canCheckForUpdate({ ...baseState, status: "downloading", downloadPercent: 50 })).toBe(
false,
);
});

it("returns true when idle", () => {
expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true);
});

it("returns true when up-to-date", () => {
expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(true);
});

it("returns true when an update is available", () => {
expect(
canCheckForUpdate({ ...baseState, status: "available", availableVersion: "1.1.0" }),
).toBe(true);
});

it("returns true on error so the user can retry", () => {
expect(
canCheckForUpdate({
...baseState,
status: "error",
errorContext: "check",
message: "network",
}),
).toBe(true);
});
});

describe("getDesktopUpdateButtonTooltip", () => {
it("returns 'Up to date' for non-actionable states", () => {
expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "idle" })).toBe("Up to date");
expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "up-to-date" })).toBe(
"Up to date",
);
});
});
9 changes: 8 additions & 1 deletion apps/web/src/components/desktopUpdate.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string
}
return state.message ?? "Update failed";
}
return "Update available";
return "Up to date";
}

export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null {
Expand All @@ -94,3 +94,10 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu
if (!state || state.status !== "error") return false;
return state.errorContext === "download" || state.errorContext === "install";
}

export function canCheckForUpdate(state: DesktopUpdateState | null): boolean {
if (!state || !state.enabled) return false;
return (
state.status !== "checking" && state.status !== "downloading" && state.status !== "disabled"
);
}
18 changes: 18 additions & 0 deletions apps/web/src/lib/desktopUpdateReactQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { queryOptions } from "@tanstack/react-query";

export const desktopUpdateQueryKeys = {
all: ["desktop", "update"] as const,
state: () => ["desktop", "update", "state"] as const,
};

export function desktopUpdateStateQueryOptions() {
return queryOptions({
queryKey: desktopUpdateQueryKeys.state(),
queryFn: async () => {
const bridge = window.desktopBridge;
if (!bridge || typeof bridge.getUpdateState !== "function") return null;
return bridge.getUpdateState();
},
staleTime: Infinity,
});
}
137 changes: 135 additions & 2 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react";
import { type ReactNode, useCallback, useState } from "react";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
import {
Expand All @@ -25,11 +25,17 @@ import {
} from "../components/ui/select";
import { SidebarTrigger } from "../components/ui/sidebar";
import { Switch } from "../components/ui/switch";
import {
canCheckForUpdate,
getDesktopUpdateButtonTooltip,
resolveDesktopUpdateButtonAction,
} from "../components/desktopUpdate.logic";
import { SidebarInset } from "../components/ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
import { desktopUpdateStateQueryOptions } from "../lib/desktopUpdateReactQuery";
import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { cn } from "../lib/utils";
import { ensureNativeApi, readNativeApi } from "../nativeApi";
Expand Down Expand Up @@ -58,6 +64,132 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;

function DesktopUpdateCheckSection() {
const queryClient = useQueryClient();
const updateStateQuery = useQuery(desktopUpdateStateQueryOptions());
const [checkError, setCheckError] = useState<string | null>(null);

const updateState = updateStateQuery.data ?? null;

useEffect(() => {
const bridge = window.desktopBridge;
if (!bridge || typeof bridge.onUpdateState !== "function") return;

const opts = desktopUpdateStateQueryOptions();
const unsubscribe = bridge.onUpdateState((nextState) => {
queryClient.setQueryData(opts.queryKey, nextState);
});

return () => {
unsubscribe();
};
}, [queryClient]);

const handleButtonClick = useCallback(() => {
const bridge = window.desktopBridge;
if (!bridge) return;
setCheckError(null);

const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
const opts = desktopUpdateStateQueryOptions();

if (action === "download") {
void bridge
.downloadUpdate()
.then((result) => {
queryClient.setQueryData(opts.queryKey, result.state);
})
.catch((error: unknown) => {
setCheckError(error instanceof Error ? error.message : "Download failed.");
});
return;
}

if (action === "install") {
const version = updateState?.downloadedVersion ?? updateState?.availableVersion;
const confirmed = window.confirm(
`Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`,
);
if (!confirmed) return;
void bridge
.installUpdate()
.then((result) => {
queryClient.setQueryData(opts.queryKey, result.state);
})
.catch((error: unknown) => {
setCheckError(error instanceof Error ? error.message : "Install failed.");
});
return;
}

if (typeof bridge.checkForUpdate !== "function") return;
void bridge
.checkForUpdate()
.then((result) => {
queryClient.setQueryData(opts.queryKey, result.state);
if (!result.checked) {
setCheckError(
result.state.message ?? "Automatic updates are not available in this build.",
);
}
})
.catch((error: unknown) => {
setCheckError(error instanceof Error ? error.message : "Update check failed.");
});
}, [queryClient, updateState]);

const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null;
const buttonDisabled = !canCheckForUpdate(updateState);

const actionLabel: Record<string, string> = { download: "Download", install: "Install" };
const statusLabel: Record<string, string> = {
checking: "Checking…",
downloading: "Downloading…",
"up-to-date": "Up to Date",
};
const buttonLabel =
actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates";

return (
<SettingsRow
title="Updates"
description={
updateState?.checkedAt
? `Last checked: ${new Date(updateState.checkedAt).toLocaleString()}`
: "Check for available updates."
}
control={
<Tooltip>
<TooltipTrigger
render={
<Button
size="xs"
variant="outline"
disabled={buttonDisabled}
onClick={handleButtonClick}
>
{buttonLabel}
</Button>
}
/>
{buttonTooltip ? <TooltipPopup>{buttonTooltip}</TooltipPopup> : null}
</Tooltip>
}
status={
<>
{checkError ? <p className="text-xs text-destructive">{checkError}</p> : null}
{updateState?.status === "error" && updateState.errorContext === "check" ? (
<p className="text-xs text-destructive">
{updateState.message ?? "Could not check for updates."}
</p>
) : null}
</>
}
/>
);
}

type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath";
type InstallProviderSettings = {
provider: ProviderKind;
Expand Down Expand Up @@ -976,6 +1108,7 @@ function SettingsRouteView() {
<code className="text-xs font-medium text-muted-foreground">{APP_VERSION}</code>
}
/>
{isElectron ? <DesktopUpdateCheckSection /> : null}
</SettingsSection>
</div>
</div>
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ export interface DesktopUpdateActionResult {
state: DesktopUpdateState;
}

export interface DesktopUpdateCheckResult {
checked: boolean;
state: DesktopUpdateState;
}

export interface DesktopBridge {
getWsUrl: () => string | null;
pickFolder: () => Promise<string | null>;
Expand All @@ -107,6 +112,7 @@ export interface DesktopBridge {
openExternal: (url: string) => Promise<boolean>;
onMenuAction: (listener: (action: string) => void) => () => void;
getUpdateState: () => Promise<DesktopUpdateState>;
checkForUpdate: () => Promise<DesktopUpdateCheckResult>;
downloadUpdate: () => Promise<DesktopUpdateActionResult>;
installUpdate: () => Promise<DesktopUpdateActionResult>;
onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;
Expand Down