Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
dad415c
feat(web): move update button to sidebar footer as dismissable pill
Noojuno Mar 15, 2026
5f41c53
refactor(web): extract SidebarUpdatePill into self-contained component
Noojuno Mar 15, 2026
161d78f
feat(web): restore arm64 warning Alert UI in update pill
Noojuno Mar 15, 2026
05a9165
chore(web): remove test mocks from SidebarUpdatePill
Noojuno Mar 15, 2026
b0b60be
feat(desktop): add manual check-for-updates from web settings UI
AriajSarkar Mar 15, 2026
40c1777
refactor: use queryOptions pattern with real-time sync and multi-purp…
AriajSarkar Mar 16, 2026
17de2fc
feat(web): add tooltip to update button in settings
AriajSarkar Mar 17, 2026
1051bb8
fix(settings): remove redundant getCheckForUpdateButtonLabel, use act…
AriajSarkar Mar 24, 2026
8541519
fix: tooltip fallback returns 'Up to date' for idle/up-to-date states…
AriajSarkar Mar 24, 2026
f11e84a
feat(web): add about settings section for updates
shivamhwp Mar 28, 2026
f2b79c9
refine settings about layout
shivamhwp Mar 28, 2026
89b546f
Merge remote-tracking branch 'upstream/main' into update-overhaul
shivamhwp Mar 28, 2026
0bfc742
chore(web): clean up sidebar imports after merge
shivamhwp Mar 28, 2026
cf33c2c
Update About card description for available updates
shivamhwp Mar 28, 2026
513aef2
Fix about update card install action
shivamhwp Mar 28, 2026
c01b791
Fix sidebar context clicks and update status handling
shivamhwp Mar 28, 2026
1dd3783
Merge remote-tracking branch 'upstream/main' into update-overhaul
shivamhwp Mar 29, 2026
b38aa2b
chore(web): clean up sidebar after main merge
shivamhwp Mar 29, 2026
0febe9b
Merge About settings into General and redirect old route
shivamhwp Mar 29, 2026
480a7ff
Refine desktop update state caching
shivamhwp Mar 29, 2026
35c3d02
Remove desktop update error highlighting helper
shivamhwp Mar 29, 2026
1e6d5f2
Merge origin/main into update-overhaul
juliusmarminge Mar 29, 2026
524135c
Refine desktop update UI and install flow
juliusmarminge Mar 29, 2026
868f6c5
rm redirect route
juliusmarminge Mar 29, 2026
b98abaf
rm hideHeader
juliusmarminge Mar 29, 2026
2928da6
archive
juliusmarminge Mar 29, 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
25 changes: 22 additions & 3 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 GET_WS_URL_CHANNEL = "desktop:get-ws-url";
const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
const STATE_DIR = Path.join(BASE_DIR, "userdata");
Expand Down Expand Up @@ -756,26 +758,28 @@ function shouldEnableAutoUpdates(): boolean {
);
}

async function checkForUpdates(reason: string): Promise<void> {
if (isQuitting || !updaterConfigured || updateCheckInFlight) return;
async function checkForUpdates(reason: string): Promise<boolean> {
if (isQuitting || !updaterConfigured || updateCheckInFlight) return false;
if (updateState.status === "downloading" || updateState.status === "downloaded") {
console.info(
`[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`,
);
return;
return false;
}
updateCheckInFlight = true;
setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString()));
console.info(`[desktop-updater] Checking for updates (${reason})...`);

try {
await autoUpdater.checkForUpdates();
return true;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setUpdateState(
reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()),
);
console.error(`[desktop-updater] Failed to check for updates: ${message}`);
return true;
} finally {
updateCheckInFlight = false;
}
Expand Down Expand Up @@ -1263,6 +1267,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;
}
const checked = await checkForUpdates("web-ui");
return {
checked,
state: updateState,
} satisfies DesktopUpdateCheckResult;
});
}

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 GET_WS_URL_CHANNEL = "desktop:get-ws-url";
Expand All @@ -35,6 +36,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
55 changes: 10 additions & 45 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
FolderIcon,
GitPullRequestIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
SquarePenIcon,
TerminalIcon,
Expand Down Expand Up @@ -78,12 +77,10 @@ import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
import {
getArm64IntelBuildWarningDescription,
getDesktopUpdateActionError,
getDesktopUpdateButtonTooltip,
getDesktopUpdateInstallConfirmationMessage,
isDesktopUpdateButtonDisabled,
resolveDesktopUpdateButtonAction,
shouldShowArm64IntelBuildWarning,
shouldHighlightDesktopUpdateError,
shouldShowDesktopUpdateButton,
shouldToastDesktopUpdateActionResult,
} from "./desktopUpdate.logic";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
Expand Down Expand Up @@ -120,6 +117,7 @@ import {
sortProjectsForSidebar,
sortThreadsForSidebar,
} from "./Sidebar.logic";
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useSettings, useUpdateSettings } from "~/hooks/useSettings";

Expand Down Expand Up @@ -1661,12 +1659,6 @@ export default function Sidebar() {
};
}, []);

const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState);

const desktopUpdateTooltip = desktopUpdateState
? getDesktopUpdateButtonTooltip(desktopUpdateState)
: "Update available";

const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState);
const desktopUpdateButtonAction = desktopUpdateState
? resolveDesktopUpdateButtonAction(desktopUpdateState)
Expand All @@ -1677,17 +1669,6 @@ export default function Sidebar() {
desktopUpdateState && showArm64IntelBuildWarning
? getArm64IntelBuildWarningDescription(desktopUpdateState)
: null;
const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled
? "cursor-not-allowed opacity-60"
: "hover:bg-accent hover:text-foreground";
const desktopUpdateButtonClasses =
desktopUpdateState?.status === "downloaded"
? "text-emerald-500"
: desktopUpdateState?.status === "downloading"
? "text-sky-400"
: shouldHighlightDesktopUpdateError(desktopUpdateState)
? "text-rose-500 animate-pulse"
: "text-amber-500 animate-pulse";
const newThreadShortcutLabel =
shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ??
shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions);
Expand Down Expand Up @@ -1728,6 +1709,10 @@ export default function Sidebar() {
}

if (desktopUpdateButtonAction === "install") {
const confirmed = window.confirm(
getDesktopUpdateInstallConfirmationMessage(desktopUpdateState),
);
if (!confirmed) return;
void bridge
.installUpdate()
.then((result) => {
Expand Down Expand Up @@ -1795,30 +1780,9 @@ export default function Sidebar() {
return (
<>
{isElectron ? (
<>
<SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
{wordmark}
{showDesktopUpdateButton && (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
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}`}
onClick={handleDesktopUpdateButtonClick}
>
<RocketIcon className="size-3.5" />
</button>
}
/>
<TooltipPopup side="bottom">{desktopUpdateTooltip}</TooltipPopup>
</Tooltip>
)}
</SidebarHeader>
</>
<SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
{wordmark}
</SidebarHeader>
) : (
<SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">
{wordmark}
Expand Down Expand Up @@ -2006,6 +1970,7 @@ export default function Sidebar() {

<SidebarSeparator />
<SidebarFooter className="p-2">
<SidebarUpdatePill />
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
Expand Down
115 changes: 95 additions & 20 deletions apps/web/src/components/desktopUpdate.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest";
import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";

import {
canCheckForUpdate,
getArm64IntelBuildWarningDescription,
getDesktopUpdateActionError,
getDesktopUpdateButtonTooltip,
getDesktopUpdateInstallConfirmationMessage,
isDesktopUpdateButtonDisabled,
resolveDesktopUpdateButtonAction,
shouldHighlightDesktopUpdateError,
shouldShowArm64IntelBuildWarning,
shouldShowDesktopUpdateButton,
shouldToastDesktopUpdateActionResult,
Expand Down Expand Up @@ -69,6 +70,16 @@ describe("desktop update button state", () => {
expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry");
});

it("prefers install when a downloaded version already exists", () => {
const state: DesktopUpdateState = {
...baseState,
status: "available",
availableVersion: "1.1.0",
downloadedVersion: "1.1.0",
};
expect(resolveDesktopUpdateButtonAction(state)).toBe("install");
});

it("hides the button for non-actionable check errors", () => {
const state: DesktopUpdateState = {
...baseState,
Expand Down Expand Up @@ -169,25 +180,6 @@ describe("desktop update UI helpers", () => {
).toBe(false);
});

it("highlights only actionable updater errors", () => {
expect(
shouldHighlightDesktopUpdateError({
...baseState,
status: "error",
errorContext: "download",
canRetry: true,
}),
).toBe(true);
expect(
shouldHighlightDesktopUpdateError({
...baseState,
status: "error",
errorContext: "check",
canRetry: true,
}),
).toBe(false);
});

it("shows an Apple Silicon warning for Intel builds under Rosetta", () => {
const state: DesktopUpdateState = {
...baseState,
Expand All @@ -213,4 +205,87 @@ describe("desktop update UI helpers", () => {

expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update");
});

it("includes the downloaded version in the install confirmation copy", () => {
expect(
getDesktopUpdateInstallConfirmationMessage({
availableVersion: "1.1.0",
downloadedVersion: "1.1.1",
}),
).toContain("Install update 1.1.1 and restart T3 Code?");
});

it("falls back to generic install confirmation copy when no version is available", () => {
expect(
getDesktopUpdateInstallConfirmationMessage({
availableVersion: null,
downloadedVersion: null,
}),
).toContain("Install update and restart T3 Code?");
});
});

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 false once an update has been downloaded", () => {
expect(
canCheckForUpdate({
...baseState,
status: "downloaded",
availableVersion: "1.1.0",
downloadedVersion: "1.1.0",
}),
).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",
);
});
});
28 changes: 21 additions & 7 deletions apps/web/src/components/desktopUpdate.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,13 @@ export type DesktopUpdateButtonAction = "download" | "install" | "none";
export function resolveDesktopUpdateButtonAction(
state: DesktopUpdateState,
): DesktopUpdateButtonAction {
if (state.downloadedVersion) {
return "install";
}
if (state.status === "available") {
return "download";
}
if (state.status === "downloaded") {
return "install";
}
if (state.status === "error") {
if (state.errorContext === "install" && state.downloadedVersion) {
return "install";
}
if (state.errorContext === "download" && state.availableVersion) {
return "download";
}
Expand Down Expand Up @@ -76,7 +73,14 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string
}
return state.message ?? "Update failed";
}
return "Update available";
return "Up to date";
}

export function getDesktopUpdateInstallConfirmationMessage(
state: Pick<DesktopUpdateState, "availableVersion" | "downloadedVersion">,
): string {
const version = state.downloadedVersion ?? state.availableVersion;
return `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`;
}

export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null {
Expand All @@ -94,3 +98,13 @@ 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 !== "downloaded" &&
state.status !== "disabled"
);
}
Loading
Loading