From dad415c97fea52474105771ce91f966619897956 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Sun, 15 Mar 2026 23:37:48 +1300 Subject: [PATCH 01/23] feat(web): move update button to sidebar footer as dismissable pill Move the desktop update button from the sidebar header to the footer, displayed as a pill above the Settings button. The pill shows contextual states: "Update available", "Downloading (X%)", and "Restart to update". Add a dismiss button that hides the notification until the next app launch. --- apps/web/src/components/Sidebar.tsx | 101 +++++++++++++++++++++------- 1 file changed, 76 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af60ce7d29..93c894c2ca 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,14 +2,16 @@ import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, + DownloadIcon, FolderIcon, GitPullRequestIcon, PlusIcon, - RocketIcon, + RotateCwIcon, SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, + XIcon, } from "lucide-react"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; @@ -66,7 +68,6 @@ import { isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, - shouldHighlightDesktopUpdateError, shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; @@ -350,6 +351,7 @@ export default function Sidebar() { const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const [updateDismissed, setUpdateDismissed] = useState(false); const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); @@ -1425,7 +1427,7 @@ export default function Sidebar() { }; }, []); - const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); + const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState) && !updateDismissed; const desktopUpdateTooltip = desktopUpdateState ? getDesktopUpdateButtonTooltip(desktopUpdateState) @@ -1452,9 +1454,12 @@ export default function Sidebar() { : shouldHighlightDesktopUpdateError(desktopUpdateState) ? "text-rose-500 animate-pulse" : "text-amber-500 animate-pulse"; - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal") ?? - shortcutLabelForCommand(keybindings, "chat.new"); + const newThreadShortcutLabel = useMemo( + () => + shortcutLabelForCommand(keybindings, "chat.newLocal") ?? + shortcutLabelForCommand(keybindings, "chat.new"), + [keybindings], + ); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -1562,25 +1567,6 @@ export default function Sidebar() { <> {wordmark} - {showDesktopUpdateButton && ( - - - - - } - /> - {desktopUpdateTooltip} - - )} ) : ( @@ -1767,6 +1753,71 @@ export default function Sidebar() { + {showDesktopUpdateButton && ( +
+
+
+ + + {desktopUpdateButtonAction === "install" ? ( + <> + + Restart to update + + ) : desktopUpdateState?.status === "downloading" ? ( + <> + + + Downloading + {typeof desktopUpdateState.downloadPercent === "number" + ? ` (${Math.floor(desktopUpdateState.downloadPercent)}%)` + : "…"} + + + ) : ( + <> + + Update available + + )} + + } + /> + {desktopUpdateTooltip} + + {desktopUpdateButtonAction === "download" && ( + + setUpdateDismissed(true)} + > + + + } + /> + Dismiss until next launch + + )} +
+
+ )} Date: Sun, 15 Mar 2026 23:58:47 +1300 Subject: [PATCH 02/23] refactor(web): extract SidebarUpdatePill into self-contained component Move all desktop update UI (pill + arm64 warning) into a single SidebarUpdatePill component that owns its own state subscription, action handlers, and dismiss logic. Removes ~220 lines from Sidebar.tsx. --- apps/web/src/components/Sidebar.tsx | 227 +----------------- .../components/sidebar/SidebarUpdatePill.tsx | 197 +++++++++++++++ 2 files changed, 199 insertions(+), 225 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarUpdatePill.tsx diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 93c894c2ca..fed17d6846 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,16 +2,12 @@ import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, - DownloadIcon, FolderIcon, GitPullRequestIcon, PlusIcon, - RotateCwIcon, SettingsIcon, SquarePenIcon, TerminalIcon, - TriangleAlertIcon, - XIcon, } from "lucide-react"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; @@ -33,7 +29,6 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { DEFAULT_MODEL_BY_PROVIDER, - type DesktopUpdateState, ProjectId, ThreadId, type GitStatusResult, @@ -61,18 +56,6 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { - getArm64IntelBuildWarningDescription, - getDesktopUpdateActionError, - getDesktopUpdateButtonTooltip, - isDesktopUpdateButtonDisabled, - resolveDesktopUpdateButtonAction, - shouldShowArm64IntelBuildWarning, - shouldShowDesktopUpdateButton, - shouldToastDesktopUpdateActionResult, -} from "./desktopUpdate.logic"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; -import { Button } from "./ui/button"; import { Collapsible, CollapsibleContent } from "./ui/collapsible"; import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -103,6 +86,7 @@ import { sortProjectsForSidebar, sortThreadsForSidebar, } from "./Sidebar.logic"; +import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; @@ -350,8 +334,6 @@ export default function Sidebar() { const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); - const [desktopUpdateState, setDesktopUpdateState] = useState(null); - const [updateDismissed, setUpdateDismissed] = useState(false); const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); @@ -1394,66 +1376,6 @@ export default function Sidebar() { }; }, [clearSelection, selectedThreadIds.size]); - useEffect(() => { - if (!isElectron) return; - const bridge = window.desktopBridge; - if ( - !bridge || - typeof bridge.getUpdateState !== "function" || - typeof bridge.onUpdateState !== "function" - ) { - return; - } - - let disposed = false; - let receivedSubscriptionUpdate = false; - const unsubscribe = bridge.onUpdateState((nextState) => { - if (disposed) return; - receivedSubscriptionUpdate = true; - setDesktopUpdateState(nextState); - }); - - void bridge - .getUpdateState() - .then((nextState) => { - if (disposed || receivedSubscriptionUpdate) return; - setDesktopUpdateState(nextState); - }) - .catch(() => undefined); - - return () => { - disposed = true; - unsubscribe(); - }; - }, []); - - const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState) && !updateDismissed; - - const desktopUpdateTooltip = desktopUpdateState - ? getDesktopUpdateButtonTooltip(desktopUpdateState) - : "Update available"; - - const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); - const desktopUpdateButtonAction = desktopUpdateState - ? resolveDesktopUpdateButtonAction(desktopUpdateState) - : "none"; - const showArm64IntelBuildWarning = - isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); - const arm64IntelBuildWarningDescription = - 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 = useMemo( () => shortcutLabelForCommand(keybindings, "chat.newLocal") ?? @@ -1461,64 +1383,6 @@ export default function Sidebar() { [keybindings], ); - const handleDesktopUpdateButtonClick = useCallback(() => { - const bridge = window.desktopBridge; - if (!bridge || !desktopUpdateState) return; - if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; - - if (desktopUpdateButtonAction === "download") { - void bridge - .downloadUpdate() - .then((result) => { - if (result.completed) { - toastManager.add({ - type: "success", - title: "Update downloaded", - description: "Restart the app from the update button to install it.", - }); - } - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add({ - type: "error", - title: "Could not download update", - description: actionError, - }); - }) - .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); - }); - return; - } - - if (desktopUpdateButtonAction === "install") { - void bridge - .installUpdate() - .then((result) => { - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add({ - type: "error", - title: "Could not install update", - description: actionError, - }); - }) - .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); - }); - } - }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - const expandThreadListForProject = useCallback((projectId: ProjectId) => { setExpandedThreadListsByProject((current) => { if (current.has(projectId)) return current; @@ -1580,29 +1444,6 @@ export default function Sidebar() { ) : ( <> - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - - - - ) : null} - - - ) : null}
@@ -1753,71 +1594,7 @@ export default function Sidebar() { - {showDesktopUpdateButton && ( -
-
-
- - - {desktopUpdateButtonAction === "install" ? ( - <> - - Restart to update - - ) : desktopUpdateState?.status === "downloading" ? ( - <> - - - Downloading - {typeof desktopUpdateState.downloadPercent === "number" - ? ` (${Math.floor(desktopUpdateState.downloadPercent)}%)` - : "…"} - - - ) : ( - <> - - Update available - - )} - - } - /> - {desktopUpdateTooltip} - - {desktopUpdateButtonAction === "download" && ( - - setUpdateDismissed(true)} - > - - - } - /> - Dismiss until next launch - - )} -
-
- )} + (null); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (!isElectron) return; + const bridge = window.desktopBridge; + if ( + !bridge || + typeof bridge.getUpdateState !== "function" || + typeof bridge.onUpdateState !== "function" + ) { + return; + } + + let disposed = false; + let receivedSubscriptionUpdate = false; + const unsubscribe = bridge.onUpdateState((nextState) => { + if (disposed) return; + receivedSubscriptionUpdate = true; + setState(nextState); + }); + + void bridge + .getUpdateState() + .then((nextState) => { + if (disposed || receivedSubscriptionUpdate) return; + setState(nextState); + }) + .catch(() => undefined); + + return () => { + disposed = true; + unsubscribe(); + }; + }, []); + + const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; + const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available"; + const disabled = isDesktopUpdateButtonDisabled(state); + const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; + + const showArm64Warning = isElectron && shouldShowArm64IntelBuildWarning(state); + const arm64Description = + state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null; + + const handleAction = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge || !state) return; + if (disabled || action === "none") return; + + if (action === "download") { + void bridge + .downloadUpdate() + .then((result) => { + if (result.completed) { + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); + } + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add({ + type: "error", + title: "Could not download update", + description: actionError, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + return; + } + + if (action === "install") { + void bridge + .installUpdate() + .then((result) => { + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add({ + type: "error", + title: "Could not install update", + description: actionError, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + } + }, [action, disabled, state]); + + if (!visible && !showArm64Warning) return null; + + return ( +
+ {showArm64Warning && arm64Description && ( +
+ + {arm64Description} +
+ )} + {visible && ( +
+
+ + + {action === "install" ? ( + <> + + Restart to update + + ) : state?.status === "downloading" ? ( + <> + + + Downloading + {typeof state.downloadPercent === "number" + ? ` (${Math.floor(state.downloadPercent)}%)` + : "…"} + + + ) : ( + <> + + Update available + + )} + + } + /> + {tooltip} + + {action === "download" && ( + + setDismissed(true)} + > + + + } + /> + Dismiss until next launch + + )} +
+ )} +
+ ); +} From 161d78f16479ea770ae5efa93feeea95f0ec4f52 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Mon, 16 Mar 2026 00:06:41 +1300 Subject: [PATCH 03/23] feat(web): restore arm64 warning Alert UI in update pill Use the original Alert component with title and description for the arm64 Intel build warning, remove redundant action button since the update pill handles it, and match sidebar font size with text-xs. --- .../components/sidebar/SidebarUpdatePill.tsx | 98 +++++++++++-------- 1 file changed, 59 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 15378fad21..d28ab096cf 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -13,51 +13,70 @@ import { shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "../desktopUpdate.logic"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export function SidebarUpdatePill() { - const [state, setState] = useState(null); + // TODO: REMOVE - hardcoded for testing arm64 warning + const [state, setState] = useState({ + enabled: true, + status: "available", + currentVersion: "1.0.0", + availableVersion: "1.2.3", + downloadedVersion: null, + downloadPercent: null, + checkedAt: new Date().toISOString(), + message: null, + errorContext: null, + hostArch: "arm64", + appArch: "x64", + runningUnderArm64Translation: true, + canRetry: false, + }); const [dismissed, setDismissed] = useState(false); - useEffect(() => { - if (!isElectron) return; - const bridge = window.desktopBridge; - if ( - !bridge || - typeof bridge.getUpdateState !== "function" || - typeof bridge.onUpdateState !== "function" - ) { - return; - } - - let disposed = false; - let receivedSubscriptionUpdate = false; - const unsubscribe = bridge.onUpdateState((nextState) => { - if (disposed) return; - receivedSubscriptionUpdate = true; - setState(nextState); - }); + // TODO: REMOVE - disabled for testing + // useEffect(() => { + // if (!isElectron) return; + // const bridge = window.desktopBridge; + // if ( + // !bridge || + // typeof bridge.getUpdateState !== "function" || + // typeof bridge.onUpdateState !== "function" + // ) { + // return; + // } + // + // let disposed = false; + // let receivedSubscriptionUpdate = false; + // const unsubscribe = bridge.onUpdateState((nextState) => { + // if (disposed) return; + // receivedSubscriptionUpdate = true; + // setState(nextState); + // }); + // + // void bridge + // .getUpdateState() + // .then((nextState) => { + // if (disposed || receivedSubscriptionUpdate) return; + // setState(nextState); + // }) + // .catch(() => undefined); + // + // return () => { + // disposed = true; + // unsubscribe(); + // }; + // }, []); - void bridge - .getUpdateState() - .then((nextState) => { - if (disposed || receivedSubscriptionUpdate) return; - setState(nextState); - }) - .catch(() => undefined); - - return () => { - disposed = true; - unsubscribe(); - }; - }, []); - - const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; + // TODO: REMOVE - bypassed isElectron for testing + const visible = /* isElectron && */ shouldShowDesktopUpdateButton(state) && !dismissed; const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available"; const disabled = isDesktopUpdateButtonDisabled(state); const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; - const showArm64Warning = isElectron && shouldShowArm64IntelBuildWarning(state); + // TODO: REMOVE - bypassed isElectron for testing + const showArm64Warning = /* isElectron && */ shouldShowArm64IntelBuildWarning(state); const arm64Description = state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null; @@ -124,10 +143,11 @@ export function SidebarUpdatePill() { return (
{showArm64Warning && arm64Description && ( -
- - {arm64Description} -
+ + + Intel build on Apple Silicon + {arm64Description} + )} {visible && (
Date: Mon, 16 Mar 2026 00:08:23 +1300 Subject: [PATCH 04/23] chore(web): remove test mocks from SidebarUpdatePill Restore real bridge subscription and isElectron checks that were hardcoded for local testing. --- .../components/sidebar/SidebarUpdatePill.tsx | 88 ++++++++----------- 1 file changed, 35 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index d28ab096cf..3b78f1b06f 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -17,66 +17,48 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export function SidebarUpdatePill() { - // TODO: REMOVE - hardcoded for testing arm64 warning - const [state, setState] = useState({ - enabled: true, - status: "available", - currentVersion: "1.0.0", - availableVersion: "1.2.3", - downloadedVersion: null, - downloadPercent: null, - checkedAt: new Date().toISOString(), - message: null, - errorContext: null, - hostArch: "arm64", - appArch: "x64", - runningUnderArm64Translation: true, - canRetry: false, - }); + const [state, setState] = useState(null); const [dismissed, setDismissed] = useState(false); - // TODO: REMOVE - disabled for testing - // useEffect(() => { - // if (!isElectron) return; - // const bridge = window.desktopBridge; - // if ( - // !bridge || - // typeof bridge.getUpdateState !== "function" || - // typeof bridge.onUpdateState !== "function" - // ) { - // return; - // } - // - // let disposed = false; - // let receivedSubscriptionUpdate = false; - // const unsubscribe = bridge.onUpdateState((nextState) => { - // if (disposed) return; - // receivedSubscriptionUpdate = true; - // setState(nextState); - // }); - // - // void bridge - // .getUpdateState() - // .then((nextState) => { - // if (disposed || receivedSubscriptionUpdate) return; - // setState(nextState); - // }) - // .catch(() => undefined); - // - // return () => { - // disposed = true; - // unsubscribe(); - // }; - // }, []); + useEffect(() => { + if (!isElectron) return; + const bridge = window.desktopBridge; + if ( + !bridge || + typeof bridge.getUpdateState !== "function" || + typeof bridge.onUpdateState !== "function" + ) { + return; + } + + let disposed = false; + let receivedSubscriptionUpdate = false; + const unsubscribe = bridge.onUpdateState((nextState) => { + if (disposed) return; + receivedSubscriptionUpdate = true; + setState(nextState); + }); + + void bridge + .getUpdateState() + .then((nextState) => { + if (disposed || receivedSubscriptionUpdate) return; + setState(nextState); + }) + .catch(() => undefined); + + return () => { + disposed = true; + unsubscribe(); + }; + }, []); - // TODO: REMOVE - bypassed isElectron for testing - const visible = /* isElectron && */ shouldShowDesktopUpdateButton(state) && !dismissed; + const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available"; const disabled = isDesktopUpdateButtonDisabled(state); const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; - // TODO: REMOVE - bypassed isElectron for testing - const showArm64Warning = /* isElectron && */ shouldShowArm64IntelBuildWarning(state); + const showArm64Warning = isElectron && shouldShowArm64IntelBuildWarning(state); const arm64Description = state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null; From b0b60be26a79db30c794e005d2ee31190da70249 Mon Sep 17 00:00:00 2001 From: Ariaj Sarkar Date: Sun, 15 Mar 2026 21:48:57 +0530 Subject: [PATCH 05/23] feat(desktop): add manual check-for-updates from web settings UI Add checkForUpdate IPC channel so the web renderer can trigger an update check on demand. Surface a Check for Updates button in Settings > About (desktop only) with contextual labels and error handling. - Add DesktopUpdateCheckResult type and DesktopBridge.checkForUpdate() - Wire UPDATE_CHECK_CHANNEL in preload and main process IPC handler - Add canCheckForUpdate/getCheckForUpdateButtonLabel logic helpers - Add DesktopUpdateCheckSection component in settings About section - Guard IPC handler when updater is not configured (local/dev builds) - Add 15 unit tests for new logic functions Closes #1107 --- apps/desktop/src/main.ts | 17 + apps/desktop/src/preload.ts | 2 + .../components/desktopUpdate.logic.test.ts | 101 +++ .../web/src/components/desktopUpdate.logic.ts | 17 + apps/web/src/routes/_chat.settings.tsx | 792 ++++++++++++++++++ packages/contracts/src/ipc.ts | 6 + 6 files changed, 935 insertions(+) create mode 100644 apps/web/src/routes/_chat.settings.tsx diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c718a31272..d44243ff70 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; import type { DesktopTheme, DesktopUpdateActionResult, + DesktopUpdateCheckResult, DesktopUpdateState, } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; @@ -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"); @@ -1238,6 +1240,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; + }); } function getIconOption(): { icon: string } | Record { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2fb7e3a1db..3d59db1714 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -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"; @@ -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) => { diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 984eebd6b1..ac06c8924a 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; import { + canCheckForUpdate, getArm64IntelBuildWarningDescription, + getCheckForUpdateButtonLabel, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, isDesktopUpdateButtonDisabled, @@ -207,3 +209,102 @@ 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("getCheckForUpdateButtonLabel", () => { + it("returns the default label for null state", () => { + expect(getCheckForUpdateButtonLabel(null)).toBe("Check for Updates"); + }); + + it("returns 'Checking…' while checking", () => { + expect(getCheckForUpdateButtonLabel({ ...baseState, status: "checking" })).toBe("Checking…"); + }); + + it("returns 'Up to Date' when up-to-date", () => { + expect(getCheckForUpdateButtonLabel({ ...baseState, status: "up-to-date" })).toBe("Up to Date"); + }); + + it("returns the available version when an update is available", () => { + expect( + getCheckForUpdateButtonLabel({ + ...baseState, + status: "available", + availableVersion: "1.2.0", + }), + ).toBe("Update Available: 1.2.0"); + }); + + it("returns 'Downloading…' while downloading", () => { + expect( + getCheckForUpdateButtonLabel({ ...baseState, status: "downloading", downloadPercent: 30 }), + ).toBe("Downloading…"); + }); + + it("returns 'Update Ready to Install' when downloaded", () => { + expect( + getCheckForUpdateButtonLabel({ + ...baseState, + status: "downloaded", + downloadedVersion: "1.2.0", + }), + ).toBe("Update Ready to Install"); + }); + + it("returns the default label for idle and error states", () => { + expect(getCheckForUpdateButtonLabel({ ...baseState, status: "idle" })).toBe( + "Check for Updates", + ); + expect( + getCheckForUpdateButtonLabel({ + ...baseState, + status: "error", + errorContext: "check", + message: "fail", + }), + ).toBe("Check for Updates"); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index faf30883cc..6d6ff01549 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -94,3 +94,20 @@ 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" + ); +} + +export function getCheckForUpdateButtonLabel(state: DesktopUpdateState | null): string { + if (!state) return "Check for Updates"; + if (state.status === "checking") return "Checking…"; + if (state.status === "up-to-date") return "Up to Date"; + if (state.status === "available") return `Update Available: ${state.availableVersion ?? ""}`; + if (state.status === "downloading") return "Downloading…"; + if (state.status === "downloaded") return "Update Ready to Install"; + return "Check for Updates"; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx new file mode 100644 index 0000000000..f3bcc87939 --- /dev/null +++ b/apps/web/src/routes/_chat.settings.tsx @@ -0,0 +1,792 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useState } from "react"; +import { type ProviderKind, type DesktopUpdateState } from "@t3tools/contracts"; +import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; +import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { isElectron } from "../env"; +import { useTheme } from "../hooks/useTheme"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { ensureNativeApi } from "../nativeApi"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../components/ui/select"; +import { Switch } from "../components/ui/switch"; +import { APP_VERSION } from "../branding"; +import { canCheckForUpdate, getCheckForUpdateButtonLabel } from "../components/desktopUpdate.logic"; +import { SidebarInset } from "~/components/ui/sidebar"; + +const THEME_OPTIONS = [ + { + value: "system", + label: "System", + description: "Match your OS appearance setting.", + }, + { + value: "light", + label: "Light", + description: "Always use the light theme.", + }, + { + value: "dark", + label: "Dark", + description: "Always use the dark theme.", + }, +] as const; + +const MODEL_PROVIDER_SETTINGS: Array<{ + provider: ProviderKind; + title: string; + description: string; + placeholder: string; + example: string; +}> = [ + { + provider: "codex", + title: "Codex", + description: "Save additional Codex model slugs for the picker and `/model` command.", + placeholder: "your-codex-model-slug", + example: "gpt-6.7-codex-ultra-preview", + }, +] as const; + +const TIMESTAMP_FORMAT_LABELS = { + locale: "System default", + "12-hour": "12-hour", + "24-hour": "24-hour", +} as const; + +function getCustomModelsForProvider( + settings: ReturnType["settings"], + provider: ProviderKind, +) { + switch (provider) { + case "codex": + default: + return settings.customCodexModels; + } +} + +function getDefaultCustomModelsForProvider( + defaults: ReturnType["defaults"], + provider: ProviderKind, +) { + switch (provider) { + case "codex": + default: + return defaults.customCodexModels; + } +} + +function patchCustomModels(provider: ProviderKind, models: string[]) { + switch (provider) { + case "codex": + default: + return { customCodexModels: models }; + } +} + +function DesktopUpdateCheckSection() { + const [updateState, setUpdateState] = useState(null); + const [checkError, setCheckError] = useState(null); + + useEffect(() => { + const bridge = window.desktopBridge; + if ( + !bridge || + typeof bridge.getUpdateState !== "function" || + typeof bridge.onUpdateState !== "function" + ) { + return; + } + + let disposed = false; + let receivedSubscriptionUpdate = false; + const unsubscribe = bridge.onUpdateState((nextState) => { + if (disposed) return; + receivedSubscriptionUpdate = true; + setUpdateState(nextState); + }); + + void bridge + .getUpdateState() + .then((nextState) => { + if (disposed || receivedSubscriptionUpdate) return; + setUpdateState(nextState); + }) + .catch(() => undefined); + + return () => { + disposed = true; + unsubscribe(); + }; + }, []); + + const handleCheckForUpdate = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.checkForUpdate !== "function") return; + setCheckError(null); + + void bridge + .checkForUpdate() + .then((result) => { + setUpdateState(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."); + }); + }, []); + + const buttonLabel = getCheckForUpdateButtonLabel(updateState); + const buttonDisabled = !canCheckForUpdate(updateState); + + return ( +
+
+
+

Updates

+

+ {updateState?.checkedAt + ? `Last checked: ${new Date(updateState.checkedAt).toLocaleString()}` + : "Check for available updates."} +

+
+ +
+ + {checkError ?

{checkError}

: null} + + {updateState?.status === "error" && updateState.errorContext === "check" ? ( +

+ {updateState.message ?? "Could not check for updates."} +

+ ) : null} +
+ ); +} + +function SettingsRouteView() { + const { theme, setTheme, resolvedTheme } = useTheme(); + const { settings, defaults, updateSettings } = useAppSettings(); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); + const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const [customModelInputByProvider, setCustomModelInputByProvider] = useState< + Record + >({ + codex: "", + }); + const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< + Partial> + >({}); + + const codexBinaryPath = settings.codexBinaryPath; + const codexHomePath = settings.codexHomePath; + const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; + const availableEditors = serverConfigQuery.data?.availableEditors; + + const openKeybindingsFile = useCallback(() => { + if (!keybindingsConfigPath) return; + setOpenKeybindingsError(null); + setIsOpeningKeybindings(true); + const api = ensureNativeApi(); + const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); + if (!editor) { + setOpenKeybindingsError("No available editors found."); + setIsOpeningKeybindings(false); + return; + } + void api.shell + .openInEditor(keybindingsConfigPath, editor) + .catch((error) => { + setOpenKeybindingsError( + error instanceof Error ? error.message : "Unable to open keybindings file.", + ); + }) + .finally(() => { + setIsOpeningKeybindings(false); + }); + }, [availableEditors, keybindingsConfigPath]); + + const addCustomModel = useCallback( + (provider: ProviderKind) => { + const customModelInput = customModelInputByProvider[provider]; + const customModels = getCustomModelsForProvider(settings, provider); + const normalized = normalizeModelSlug(customModelInput, provider); + if (!normalized) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "Enter a model slug.", + })); + return; + } + if (getModelOptions(provider).some((option) => option.slug === normalized)) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "That model is already built in.", + })); + return; + } + if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, + })); + return; + } + if (customModels.includes(normalized)) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "That custom model is already saved.", + })); + return; + } + + updateSettings(patchCustomModels(provider, [...customModels, normalized])); + setCustomModelInputByProvider((existing) => ({ + ...existing, + [provider]: "", + })); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: null, + })); + }, + [customModelInputByProvider, settings, updateSettings], + ); + + const removeCustomModel = useCallback( + (provider: ProviderKind, slug: string) => { + const customModels = getCustomModelsForProvider(settings, provider); + updateSettings( + patchCustomModels( + provider, + customModels.filter((model) => model !== slug), + ), + ); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: null, + })); + }, + [settings, updateSettings], + ); + + return ( + +
+ {isElectron && ( +
+ + Settings + +
+ )} + +
+
+
+

Settings

+

+ Configure app-level preferences for this device. +

+
+ +
+
+

Appearance

+

+ Choose how T3 Code looks across the app. +

+
+ +
+
+ {THEME_OPTIONS.map((option) => { + const selected = theme === option.value; + return ( + + ); + })} +
+ +

+ Active theme: {resolvedTheme} +

+ +
+
+

Timestamp format

+

+ System default follows your browser or OS time format. 12-hour{" "} + and 24-hour force the hour cycle. +

+
+ +
+ + {settings.timestampFormat !== defaults.timestampFormat ? ( +
+ +
+ ) : null} +
+
+ +
+
+

Codex App Server

+

+ These overrides apply to new sessions and let you use a non-default Codex install. +

+
+ +
+ + + + +
+
+

Binary source

+

+ {codexBinaryPath || "PATH"} +

+
+ +
+
+
+ +
+
+

Models

+

+ Save additional provider model slugs so they appear in the chat model picker and + `/model` command suggestions. +

+
+ +
+ {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { + const provider = providerSettings.provider; + const customModels = getCustomModelsForProvider(settings, provider); + const customModelInput = customModelInputByProvider[provider]; + const customModelError = customModelErrorByProvider[provider] ?? null; + return ( +
+
+

+ {providerSettings.title} +

+

+ {providerSettings.description} +

+
+ +
+
+ + + +
+ + {customModelError ? ( +

{customModelError}

+ ) : null} + +
+
+

Saved custom models: {customModels.length}

+ {customModels.length > 0 ? ( + + ) : null} +
+ + {customModels.length > 0 ? ( +
+ {customModels.map((slug) => ( +
+ + {slug} + + +
+ ))} +
+ ) : ( +
+ No custom models saved yet. +
+ )} +
+
+
+ ); + })} +
+
+ +
+
+

Threads

+

+ Choose the default workspace mode for newly created draft threads. +

+
+ +
+
+

Default to New worktree

+

+ New threads start in New worktree mode instead of Local. +

+
+ + updateSettings({ + defaultThreadEnvMode: checked ? "worktree" : "local", + }) + } + aria-label="Default new threads to New worktree mode" + /> +
+ + {settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ( +
+ +
+ ) : null} +
+ +
+
+

Responses

+

+ Control how assistant output is rendered during a turn. +

+
+ +
+
+

Stream assistant messages

+

+ Show token-by-token output while a response is in progress. +

+
+ + updateSettings({ + enableAssistantStreaming: Boolean(checked), + }) + } + aria-label="Stream assistant messages" + /> +
+ + {settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? ( +
+ +
+ ) : null} +
+ +
+
+

Keybindings

+

+ Open the persisted keybindings.json file to edit advanced bindings + directly. +

+
+ +
+
+
+

Config file path

+

+ {keybindingsConfigPath ?? "Resolving keybindings path..."} +

+
+ +
+ +

+ Opens in your preferred editor selection. +

+ {openKeybindingsError ? ( +

{openKeybindingsError}

+ ) : null} +
+
+ +
+
+

Safety

+

+ Additional guardrails for destructive local actions. +

+
+ +
+
+

Confirm thread deletion

+

+ Ask for confirmation before deleting a thread and its chat history. +

+
+ + updateSettings({ + confirmThreadDelete: Boolean(checked), + }) + } + aria-label="Confirm thread deletion" + /> +
+ + {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( +
+ +
+ ) : null} +
+
+
+

About

+

+ Application version and environment information. +

+
+ +
+
+
+

Version

+

+ Current version of the application. +

+
+ {APP_VERSION} +
+ + {isElectron ? : null} +
+
+
+
+
+
+ ); +} + +export const Route = createFileRoute("/_chat/settings")({ + component: SettingsRouteView, +}); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 0443128dd5..5585e7f309 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -101,6 +101,11 @@ export interface DesktopUpdateActionResult { state: DesktopUpdateState; } +export interface DesktopUpdateCheckResult { + checked: boolean; + state: DesktopUpdateState; +} + export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; @@ -113,6 +118,7 @@ export interface DesktopBridge { openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; + checkForUpdate: () => Promise; downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; From 40c1777e8753569d1d50b0051c3395f269b6aa68 Mon Sep 17 00:00:00 2001 From: Ariaj Sarkar Date: Tue, 17 Mar 2026 01:11:39 +0530 Subject: [PATCH 06/23] refactor: use queryOptions pattern with real-time sync and multi-purpose button --- .../components/desktopUpdate.logic.test.ts | 8 +- .../web/src/components/desktopUpdate.logic.ts | 4 +- apps/web/src/lib/desktopUpdateReactQuery.ts | 18 ++++ apps/web/src/routes/_chat.settings.tsx | 85 +++++++++++-------- 4 files changed, 73 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/lib/desktopUpdateReactQuery.ts diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index ac06c8924a..a37601d81c 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -268,14 +268,14 @@ describe("getCheckForUpdateButtonLabel", () => { expect(getCheckForUpdateButtonLabel({ ...baseState, status: "up-to-date" })).toBe("Up to Date"); }); - it("returns the available version when an update is available", () => { + it("returns 'Download Update' when an update is available", () => { expect( getCheckForUpdateButtonLabel({ ...baseState, status: "available", availableVersion: "1.2.0", }), - ).toBe("Update Available: 1.2.0"); + ).toBe("Download Update"); }); it("returns 'Downloading…' while downloading", () => { @@ -284,14 +284,14 @@ describe("getCheckForUpdateButtonLabel", () => { ).toBe("Downloading…"); }); - it("returns 'Update Ready to Install' when downloaded", () => { + it("returns 'Install Update' when downloaded", () => { expect( getCheckForUpdateButtonLabel({ ...baseState, status: "downloaded", downloadedVersion: "1.2.0", }), - ).toBe("Update Ready to Install"); + ).toBe("Install Update"); }); it("returns the default label for idle and error states", () => { diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 6d6ff01549..cc6ae95d65 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -106,8 +106,8 @@ export function getCheckForUpdateButtonLabel(state: DesktopUpdateState | null): if (!state) return "Check for Updates"; if (state.status === "checking") return "Checking…"; if (state.status === "up-to-date") return "Up to Date"; - if (state.status === "available") return `Update Available: ${state.availableVersion ?? ""}`; + if (state.status === "available") return "Download Update"; if (state.status === "downloading") return "Downloading…"; - if (state.status === "downloaded") return "Update Ready to Install"; + if (state.status === "downloaded") return "Install Update"; return "Check for Updates"; } diff --git a/apps/web/src/lib/desktopUpdateReactQuery.ts b/apps/web/src/lib/desktopUpdateReactQuery.ts new file mode 100644 index 0000000000..ba1db17987 --- /dev/null +++ b/apps/web/src/lib/desktopUpdateReactQuery.ts @@ -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, + }); +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index f3bcc87939..2ae963752d 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useState } from "react"; -import { type ProviderKind, type DesktopUpdateState } from "@t3tools/contracts"; +import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; @@ -20,7 +20,12 @@ import { } from "../components/ui/select"; import { Switch } from "../components/ui/switch"; import { APP_VERSION } from "../branding"; -import { canCheckForUpdate, getCheckForUpdateButtonLabel } from "../components/desktopUpdate.logic"; +import { + canCheckForUpdate, + getCheckForUpdateButtonLabel, + resolveDesktopUpdateButtonAction, +} from "../components/desktopUpdate.logic"; +import { desktopUpdateStateQueryOptions } from "../lib/desktopUpdateReactQuery"; import { SidebarInset } from "~/components/ui/sidebar"; const THEME_OPTIONS = [ @@ -94,50 +99,63 @@ function patchCustomModels(provider: ProviderKind, models: string[]) { } function DesktopUpdateCheckSection() { - const [updateState, setUpdateState] = useState(null); + const queryClient = useQueryClient(); + const updateStateQuery = useQuery(desktopUpdateStateQueryOptions()); const [checkError, setCheckError] = useState(null); + const updateState = updateStateQuery.data ?? null; + useEffect(() => { const bridge = window.desktopBridge; - if ( - !bridge || - typeof bridge.getUpdateState !== "function" || - typeof bridge.onUpdateState !== "function" - ) { - return; - } + if (!bridge || typeof bridge.onUpdateState !== "function") return; - let disposed = false; - let receivedSubscriptionUpdate = false; + const opts = desktopUpdateStateQueryOptions(); const unsubscribe = bridge.onUpdateState((nextState) => { - if (disposed) return; - receivedSubscriptionUpdate = true; - setUpdateState(nextState); + queryClient.setQueryData(opts.queryKey, nextState); }); - void bridge - .getUpdateState() - .then((nextState) => { - if (disposed || receivedSubscriptionUpdate) return; - setUpdateState(nextState); - }) - .catch(() => undefined); - return () => { - disposed = true; unsubscribe(); }; - }, []); + }, [queryClient]); - const handleCheckForUpdate = useCallback(() => { + const handleButtonClick = useCallback(() => { const bridge = window.desktopBridge; - if (!bridge || typeof bridge.checkForUpdate !== "function") return; + 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") { + 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) => { - setUpdateState(result.state); + queryClient.setQueryData(opts.queryKey, result.state); if (!result.checked) { setCheckError( result.state.message ?? "Automatic updates are not available in this build.", @@ -147,7 +165,7 @@ function DesktopUpdateCheckSection() { .catch((error: unknown) => { setCheckError(error instanceof Error ? error.message : "Update check failed."); }); - }, []); + }, [queryClient, updateState]); const buttonLabel = getCheckForUpdateButtonLabel(updateState); const buttonDisabled = !canCheckForUpdate(updateState); @@ -163,12 +181,7 @@ function DesktopUpdateCheckSection() { : "Check for available updates."}

-
From 17de2fca9266c4fb2411417ca07c35c8e3c2e4ae Mon Sep 17 00:00:00 2001 From: Ariaj Sarkar Date: Tue, 17 Mar 2026 22:03:27 +0530 Subject: [PATCH 07/23] feat(web): add tooltip to update button in settings --- apps/web/src/routes/_chat.settings.tsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 2ae963752d..26bb5e9216 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -23,9 +23,11 @@ import { APP_VERSION } from "../branding"; import { canCheckForUpdate, getCheckForUpdateButtonLabel, + getDesktopUpdateButtonTooltip, resolveDesktopUpdateButtonAction, } from "../components/desktopUpdate.logic"; import { desktopUpdateStateQueryOptions } from "../lib/desktopUpdateReactQuery"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { SidebarInset } from "~/components/ui/sidebar"; const THEME_OPTIONS = [ @@ -169,6 +171,7 @@ function DesktopUpdateCheckSection() { const buttonLabel = getCheckForUpdateButtonLabel(updateState); const buttonDisabled = !canCheckForUpdate(updateState); + const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; return (
@@ -181,9 +184,21 @@ function DesktopUpdateCheckSection() { : "Check for available updates."}

- + + + {buttonLabel} + + } + /> + {buttonTooltip ? {buttonTooltip} : null} +
{checkError ?

{checkError}

: null} From 1051bb8bbefb87c05275ff81ef61a3bd8b7b4c96 Mon Sep 17 00:00:00 2001 From: Ariaj Sarkar Date: Tue, 24 Mar 2026 23:07:23 +0530 Subject: [PATCH 08/23] fix(settings): remove redundant getCheckForUpdateButtonLabel, use action/status maps for button label with tooltip from getDesktopUpdateButtonTooltip --- .../components/desktopUpdate.logic.test.ts | 56 +- .../web/src/components/desktopUpdate.logic.ts | 10 - apps/web/src/routes/_chat.settings.tsx | 1291 ++++++++++------- 3 files changed, 787 insertions(+), 570 deletions(-) diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index a37601d81c..cbb6f56fdd 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -4,7 +4,6 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/con import { canCheckForUpdate, getArm64IntelBuildWarningDescription, - getCheckForUpdateButtonLabel, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, isDesktopUpdateButtonDisabled, @@ -255,56 +254,11 @@ describe("canCheckForUpdate", () => { }); }); -describe("getCheckForUpdateButtonLabel", () => { - it("returns the default label for null state", () => { - expect(getCheckForUpdateButtonLabel(null)).toBe("Check for Updates"); - }); - - it("returns 'Checking…' while checking", () => { - expect(getCheckForUpdateButtonLabel({ ...baseState, status: "checking" })).toBe("Checking…"); - }); - - it("returns 'Up to Date' when up-to-date", () => { - expect(getCheckForUpdateButtonLabel({ ...baseState, status: "up-to-date" })).toBe("Up to Date"); - }); - - it("returns 'Download Update' when an update is available", () => { - expect( - getCheckForUpdateButtonLabel({ - ...baseState, - status: "available", - availableVersion: "1.2.0", - }), - ).toBe("Download Update"); - }); - - it("returns 'Downloading…' while downloading", () => { - expect( - getCheckForUpdateButtonLabel({ ...baseState, status: "downloading", downloadPercent: 30 }), - ).toBe("Downloading…"); - }); - - it("returns 'Install Update' when downloaded", () => { - expect( - getCheckForUpdateButtonLabel({ - ...baseState, - status: "downloaded", - downloadedVersion: "1.2.0", - }), - ).toBe("Install Update"); - }); - - it("returns the default label for idle and error states", () => { - expect(getCheckForUpdateButtonLabel({ ...baseState, status: "idle" })).toBe( - "Check for Updates", +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", ); - expect( - getCheckForUpdateButtonLabel({ - ...baseState, - status: "error", - errorContext: "check", - message: "fail", - }), - ).toBe("Check for Updates"); }); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index cc6ae95d65..d4fb0b5044 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -101,13 +101,3 @@ export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { state.status !== "checking" && state.status !== "downloading" && state.status !== "disabled" ); } - -export function getCheckForUpdateButtonLabel(state: DesktopUpdateState | null): string { - if (!state) return "Check for Updates"; - if (state.status === "checking") return "Checking…"; - if (state.status === "up-to-date") return "Up to Date"; - if (state.status === "available") return "Download Update"; - if (state.status === "downloading") return "Downloading…"; - if (state.status === "downloaded") return "Install Update"; - return "Check for Updates"; -} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 26bb5e9216..99e2ecabbb 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,15 +1,20 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useState } from "react"; -import { type ProviderKind } from "@t3tools/contracts"; +import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-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 { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; -import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { isElectron } from "../env"; -import { useTheme } from "../hooks/useTheme"; -import { serverConfigQueryOptions } from "../lib/serverReactQuery"; -import { ensureNativeApi } from "../nativeApi"; +import { + getAppModelOptions, + getCustomModelsForProvider, + MAX_CUSTOM_MODEL_LENGTH, + MODEL_PROVIDER_SETTINGS, + patchCustomModels, + useAppSettings, +} from "../appSettings"; +import { APP_VERSION } from "../branding"; import { Button } from "../components/ui/button"; +import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; import { Input } from "../components/ui/input"; import { Select, @@ -18,17 +23,22 @@ import { SelectTrigger, SelectValue, } from "../components/ui/select"; +import { SidebarTrigger } from "../components/ui/sidebar"; import { Switch } from "../components/ui/switch"; -import { APP_VERSION } from "../branding"; import { canCheckForUpdate, - getCheckForUpdateButtonLabel, 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 { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -import { SidebarInset } from "~/components/ui/sidebar"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { cn } from "../lib/utils"; +import { ensureNativeApi, readNativeApi } from "../nativeApi"; const THEME_OPTIONS = [ { @@ -48,58 +58,12 @@ const THEME_OPTIONS = [ }, ] as const; -const MODEL_PROVIDER_SETTINGS: Array<{ - provider: ProviderKind; - title: string; - description: string; - placeholder: string; - example: string; -}> = [ - { - provider: "codex", - title: "Codex", - description: "Save additional Codex model slugs for the picker and `/model` command.", - placeholder: "your-codex-model-slug", - example: "gpt-6.7-codex-ultra-preview", - }, -] as const; - const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", "24-hour": "24-hour", } as const; -function getCustomModelsForProvider( - settings: ReturnType["settings"], - provider: ProviderKind, -) { - switch (provider) { - case "codex": - default: - return settings.customCodexModels; - } -} - -function getDefaultCustomModelsForProvider( - defaults: ReturnType["defaults"], - provider: ProviderKind, -) { - switch (provider) { - case "codex": - default: - return defaults.customCodexModels; - } -} - -function patchCustomModels(provider: ProviderKind, models: string[]) { - switch (provider) { - case "codex": - default: - return { customCodexModels: models }; - } -} - function DesktopUpdateCheckSection() { const queryClient = useQueryClient(); const updateStateQuery = useQuery(desktopUpdateStateQueryOptions()); @@ -142,6 +106,11 @@ function DesktopUpdateCheckSection() { } 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) => { @@ -169,21 +138,28 @@ function DesktopUpdateCheckSection() { }); }, [queryClient, updateState]); - const buttonLabel = getCheckForUpdateButtonLabel(updateState); - const buttonDisabled = !canCheckForUpdate(updateState); + const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; + const buttonDisabled = !canCheckForUpdate(updateState); + + const actionLabel: Record = { download: "Download", install: "Install" }; + const statusLabel: Record = { + checking: "Checking…", + downloading: "Downloading…", + "up-to-date": "Up to Date", + }; + const buttonLabel = + actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; return ( -
-
-
-

Updates

-

- {updateState?.checkedAt - ? `Last checked: ${new Date(updateState.checkedAt).toLocaleString()}` - : "Check for available updates."} -

-
+ {buttonTooltip ? {buttonTooltip} : null} -
+ } + status={ + <> + {checkError ?

{checkError}

: null} + {updateState?.status === "error" && updateState.errorContext === "check" ? ( +

+ {updateState.message ?? "Could not check for updates."} +

+ ) : null} + + } + /> + ); +} - {checkError ?

{checkError}

: null} +type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; +type InstallProviderSettings = { + provider: ProviderKind; + title: string; + binaryPathKey: InstallBinarySettingsKey; + binaryPlaceholder: string; + binaryDescription: ReactNode; + homePathKey?: "codexHomePath"; + homePlaceholder?: string; + homeDescription?: ReactNode; +}; - {updateState?.status === "error" && updateState.errorContext === "check" ? ( -

- {updateState.message ?? "Could not check for updates."} -

- ) : null} +const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ + { + provider: "codex", + title: "Codex", + binaryPathKey: "codexBinaryPath", + binaryPlaceholder: "Codex binary path", + binaryDescription: ( + <> + Leave blank to use codex from your PATH. + + ), + homePathKey: "codexHomePath", + homePlaceholder: "CODEX_HOME", + homeDescription: "Optional custom Codex home and config directory.", + }, + { + provider: "claudeAgent", + title: "Claude", + binaryPathKey: "claudeBinaryPath", + binaryPlaceholder: "Claude binary path", + binaryDescription: ( + <> + Leave blank to use claude from your PATH. + + ), + }, +]; + +function SettingsSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
+ {children} +
+
+ ); +} + +function SettingsRow({ + title, + description, + status, + resetAction, + control, + children, + onClick, +}: { + title: string; + description: string; + status?: ReactNode; + resetAction?: ReactNode; + control?: ReactNode; + children?: ReactNode; + onClick?: () => void; +}) { + return ( +
+
+
+
+

{title}

+ + {resetAction} + +
+

{description}

+ {status ?
{status}
: null} +
+ {control ? ( +
+ {control} +
+ ) : null} +
+ {children}
); } +function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + { + event.stopPropagation(); + onClick(); + }} + > + + + } + /> + Reset to default + + ); +} + function SettingsRouteView() { - const { theme, setTheme, resolvedTheme } = useTheme(); - const { settings, defaults, updateSettings } = useAppSettings(); + const { theme, setTheme } = useTheme(); + const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const [openInstallProviders, setOpenInstallProviders] = useState>({ + codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), + claudeAgent: Boolean(settings.claudeBinaryPath), + }); + const [selectedCustomModelProvider, setSelectedCustomModelProvider] = + useState("codex"); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ codex: "", + claudeAgent: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); + const [showAllCustomModels, setShowAllCustomModels] = useState(false); const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const claudeBinaryPath = settings.claudeBinaryPath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + const gitTextGenerationModelOptions = getAppModelOptions( + "codex", + settings.customCodexModels, + settings.textGenerationModel, + ); + const currentGitTextGenerationModel = + settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; + const defaultGitTextGenerationModel = + defaults.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; + const isGitTextGenerationModelDirty = + currentGitTextGenerationModel !== defaultGitTextGenerationModel; + const selectedGitTextGenerationModelLabel = + gitTextGenerationModelOptions.find((option) => option.slug === currentGitTextGenerationModel) + ?.name ?? currentGitTextGenerationModel; + const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( + (providerSettings) => providerSettings.provider === selectedCustomModelProvider, + )!; + const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; + const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; + const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length; + const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => + getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({ + key: `${providerSettings.provider}:${slug}`, + provider: providerSettings.provider, + providerTitle: providerSettings.title, + slug, + })), + ); + const visibleCustomModelRows = showAllCustomModels + ? savedCustomModelRows + : savedCustomModelRows.slice(0, 5); + const isInstallSettingsDirty = + settings.claudeBinaryPath !== defaults.claudeBinaryPath || + settings.codexBinaryPath !== defaults.codexBinaryPath || + settings.codexHomePath !== defaults.codexHomePath; + const changedSettingLabels = [ + ...(theme !== "system" ? ["Theme"] : []), + ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), + ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming + ? ["Assistant output"] + : []), + ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []), + ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete + ? ["Delete confirmation"] + : []), + ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []), + ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 + ? ["Custom models"] + : []), + ...(isInstallSettingsDirty ? ["Provider installs"] : []), + ]; + const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; setOpenKeybindingsError(null); @@ -319,495 +485,602 @@ function SettingsRouteView() { [settings, updateSettings], ); + async function restoreDefaults() { + if (changedSettingLabels.length === 0) return; + + const api = readNativeApi(); + const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm( + ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( + "\n", + ), + ); + if (!confirmed) return; + + setTheme("system"); + resetSettings(); + setOpenInstallProviders({ + codex: false, + claudeAgent: false, + }); + setSelectedCustomModelProvider("codex"); + setCustomModelInputByProvider({ + codex: "", + claudeAgent: "", + }); + setCustomModelErrorByProvider({}); + } + return (
+ {!isElectron && ( +
+
+ + Settings +
+ +
+
+
+ )} + {isElectron && (
Settings +
+ +
)}
-
-
-

Settings

-

- Configure app-level preferences for this device. -

-
- -
-
-

Appearance

-

- Choose how T3 Code looks across the app. -

-
- -
-
- {THEME_OPTIONS.map((option) => { - const selected = theme === option.value; - return ( - - ); - })} -
+
+ + setTheme("system")} /> + ) : null + } + control={ + + } + /> -

- Active theme: {resolvedTheme} -

- -
-
-

Timestamp format

-

- System default follows your browser or OS time format. 12-hour{" "} - and 24-hour force the hour cycle. -

-
+ + updateSettings({ + timestampFormat: defaults.timestampFormat, + }) + } + /> + ) : null + } + control={ -
+ } + /> - {settings.timestampFormat !== defaults.timestampFormat ? ( -
- -
- ) : null} -
-
- -
-
-

Codex App Server

-

- These overrides apply to new sessions and let you use a non-default Codex install. -

-
- -
- - - - -
-
-

Binary source

-

- {codexBinaryPath || "PATH"} -

-
- +
+ + {selectedCustomModelError ? ( +

{selectedCustomModelError}

+ ) : null} - -
- - {customModelError ? ( -

{customModelError}

- ) : null} - -
-
-

Saved custom models: {customModels.length}

- {customModels.length > 0 ? ( - - ) : null} + + {row.providerTitle} + + + {row.slug} + +
- - {customModels.length > 0 ? ( -
- {customModels.map((slug) => ( -
- - {slug} - - -
- ))} -
- ) : ( -
- No custom models saved yet. -
- )} -
+ ))}
-
- ); - })} -
- - -
-
-

Threads

-

- Choose the default workspace mode for newly created draft threads. -

-
-
-
-

Default to New worktree

-

- New threads start in New worktree mode instead of Local. -

+ {savedCustomModelRows.length > 5 ? ( + + ) : null} +
+ ) : null}
- - updateSettings({ - defaultThreadEnvMode: checked ? "worktree" : "local", - }) - } - aria-label="Default new threads to New worktree mode" - /> -
+ + - {settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ( -
- -
- ) : null} - - -
-
-

Responses

-

- Control how assistant output is rendered during a turn. -

-
+ + { + updateSettings({ + claudeBinaryPath: defaults.claudeBinaryPath, + codexBinaryPath: defaults.codexBinaryPath, + codexHomePath: defaults.codexHomePath, + }); + setOpenInstallProviders({ + codex: false, + claudeAgent: false, + }); + }} + /> + ) : null + } + > +
+
+ {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { + const isOpen = openInstallProviders[providerSettings.provider]; + const isDirty = + providerSettings.provider === "codex" + ? settings.codexBinaryPath !== defaults.codexBinaryPath || + settings.codexHomePath !== defaults.codexHomePath + : settings.claudeBinaryPath !== defaults.claudeBinaryPath; + const binaryPathValue = + providerSettings.binaryPathKey === "claudeBinaryPath" + ? claudeBinaryPath + : codexBinaryPath; -
-
-

Stream assistant messages

-

- Show token-by-token output while a response is in progress. -

-
- - updateSettings({ - enableAssistantStreaming: Boolean(checked), - }) - } - aria-label="Stream assistant messages" - /> -
+ return ( + + setOpenInstallProviders((existing) => ({ + ...existing, + [providerSettings.provider]: open, + })) + } + > +
+ - {settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? ( -
- + +
+
+ + + {providerSettings.homePathKey ? ( + + ) : null} +
+
+
+
+ + ); + })} +
- ) : null} -
- -
-
-

Keybindings

-

- Open the persisted keybindings.json file to edit advanced bindings - directly. -

-
+ -
-
-
-

Config file path

-

+ + {keybindingsConfigPath ?? "Resolving keybindings path..."} -

-
+ + {openKeybindingsError ? ( + {openKeybindingsError} + ) : ( + Opens in your preferred editor. + )} + + } + control={ -
+ } + /> -

- Opens in your preferred editor selection. -

- {openKeybindingsError ? ( -

{openKeybindingsError}

- ) : null} -
-
- -
-
-

Safety

-

- Additional guardrails for destructive local actions. -

-
- -
-
-

Confirm thread deletion

-

- Ask for confirmation before deleting a thread and its chat history. -

-
- - updateSettings({ - confirmThreadDelete: Boolean(checked), - }) - } - aria-label="Confirm thread deletion" - /> -
- - {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( -
- -
- ) : null} -
-
-
-

About

-

- Application version and environment information. -

-
- -
-
-
-

Version

-

- Current version of the application. -

-
+ {APP_VERSION} -
- - {isElectron ? : null} -
-
+ } + /> + {isElectron ? : null} +
From 85415191eda31a1fd6ed4c808096948b1f85c86e Mon Sep 17 00:00:00 2001 From: Ariaj Sarkar Date: Tue, 24 Mar 2026 23:38:37 +0530 Subject: [PATCH 09/23] fix: tooltip fallback returns 'Up to date' for idle/up-to-date states instead of misleading 'Update available' --- apps/web/src/components/desktopUpdate.logic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index d4fb0b5044..df9139908c 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -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 { From f11e84addc24c81e440914c3211a51ca80ce4d2c Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 29 Mar 2026 04:25:06 +0530 Subject: [PATCH 10/23] feat(web): add about settings section for updates --- .../components/settings/SettingsPanels.tsx | 148 +++ .../settings/SettingsSidebarNav.tsx | 5 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_chat.settings.tsx | 1093 ----------------- apps/web/src/routes/settings.about.tsx | 7 + 5 files changed, 179 insertions(+), 1095 deletions(-) delete mode 100644 apps/web/src/routes/_chat.settings.tsx create mode 100644 apps/web/src/routes/settings.about.tsx diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bb149c00a4..79a76e2ddb 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -22,12 +22,19 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; +import { + canCheckForUpdate, + getDesktopUpdateButtonTooltip, + resolveDesktopUpdateButtonAction, +} from "../../components/desktopUpdate.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; +import { isElectron } from "../../env"; import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; +import { desktopUpdateStateQueryOptions } from "../../lib/desktopUpdateReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery"; import { MAX_CUSTOM_MODEL_LENGTH, @@ -299,6 +306,132 @@ function SettingsPageContainer({ children }: { children: ReactNode }) { ); } +function DesktopUpdateCheckSection() { + const queryClient = useQueryClient(); + const updateStateQuery = useQuery(desktopUpdateStateQueryOptions()); + const [checkError, setCheckError] = useState(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 = { download: "Download", install: "Install" }; + const statusLabel: Record = { + checking: "Checking…", + downloading: "Downloading…", + "up-to-date": "Up to Date", + }; + const buttonLabel = + actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; + + return ( + + + {buttonLabel} + + } + /> + {buttonTooltip ? {buttonTooltip} : null} + + } + status={ + <> + {checkError ?

{checkError}

: null} + {updateState?.status === "error" && updateState.errorContext === "check" ? ( +

+ {updateState.message ?? "Could not check for updates."} +

+ ) : null} + + } + /> + ); +} + export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); const settings = useSettings(); @@ -1269,6 +1402,21 @@ export function GeneralSettingsPanel() { ); } +export function AboutSettingsPanel() { + return ( + + }> + {APP_VERSION}} + /> + {isElectron ? : null} + + + ); +} + export function ArchivedThreadsPanel() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index ffca1e2092..0d22896c15 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -1,5 +1,5 @@ import type { ComponentType } from "react"; -import { ArchiveIcon, ArrowLeftIcon, Settings2Icon } from "lucide-react"; +import { ArchiveIcon, ArrowLeftIcon, InfoIcon, Settings2Icon } from "lucide-react"; import { useNavigate } from "@tanstack/react-router"; import { @@ -12,7 +12,7 @@ import { SidebarSeparator, } from "../ui/sidebar"; -export type SettingsSectionPath = "/settings/general" | "/settings/archived"; +export type SettingsSectionPath = "/settings/general" | "/settings/about" | "/settings/archived"; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ label: string; @@ -20,6 +20,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ icon: ComponentType<{ className?: string }>; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, + { label: "About", to: "/settings/about", icon: InfoIcon }, { label: "Archived threads", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 77b1b15842..43966bc8ce 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' +import { Route as SettingsAboutRouteImport } from './routes/settings.about' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' @@ -30,6 +31,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) +const SettingsAboutRoute = SettingsAboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => SettingsRoute, +} as any) const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ id: '/general', path: '/general', @@ -50,12 +56,14 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/settings': typeof SettingsRouteWithChildren '/$threadId': typeof ChatThreadIdRoute + '/settings/about': typeof SettingsAboutRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute } export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren '/$threadId': typeof ChatThreadIdRoute + '/settings/about': typeof SettingsAboutRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute '/': typeof ChatIndexRoute @@ -65,6 +73,7 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/settings': typeof SettingsRouteWithChildren '/_chat/$threadId': typeof ChatThreadIdRoute + '/settings/about': typeof SettingsAboutRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute '/_chat/': typeof ChatIndexRoute @@ -75,12 +84,14 @@ export interface FileRouteTypes { | '/' | '/settings' | '/$threadId' + | '/settings/about' | '/settings/archived' | '/settings/general' fileRoutesByTo: FileRoutesByTo to: | '/settings' | '/$threadId' + | '/settings/about' | '/settings/archived' | '/settings/general' | '/' @@ -89,6 +100,7 @@ export interface FileRouteTypes { | '/_chat' | '/settings' | '/_chat/$threadId' + | '/settings/about' | '/settings/archived' | '/settings/general' | '/_chat/' @@ -122,6 +134,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } + '/settings/about': { + id: '/settings/about' + path: '/about' + fullPath: '/settings/about' + preLoaderRoute: typeof SettingsAboutRouteImport + parentRoute: typeof SettingsRoute + } '/settings/general': { id: '/settings/general' path: '/general' @@ -159,11 +178,13 @@ const ChatRouteChildren: ChatRouteChildren = { const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { + SettingsAboutRoute: typeof SettingsAboutRoute SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsGeneralRoute: typeof SettingsGeneralRoute } const SettingsRouteChildren: SettingsRouteChildren = { + SettingsAboutRoute: SettingsAboutRoute, SettingsArchivedRoute: SettingsArchivedRoute, SettingsGeneralRoute: SettingsGeneralRoute, } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx deleted file mode 100644 index 99e2ecabbb..0000000000 --- a/apps/web/src/routes/_chat.settings.tsx +++ /dev/null @@ -1,1093 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-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 { - getAppModelOptions, - getCustomModelsForProvider, - MAX_CUSTOM_MODEL_LENGTH, - MODEL_PROVIDER_SETTINGS, - patchCustomModels, - useAppSettings, -} from "../appSettings"; -import { APP_VERSION } from "../branding"; -import { Button } from "../components/ui/button"; -import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; -import { Input } from "../components/ui/input"; -import { - Select, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} 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"; - -const THEME_OPTIONS = [ - { - value: "system", - label: "System", - description: "Match your OS appearance setting.", - }, - { - value: "light", - label: "Light", - description: "Always use the light theme.", - }, - { - value: "dark", - label: "Dark", - description: "Always use the dark theme.", - }, -] as const; - -const TIMESTAMP_FORMAT_LABELS = { - locale: "System default", - "12-hour": "12-hour", - "24-hour": "24-hour", -} as const; - -function DesktopUpdateCheckSection() { - const queryClient = useQueryClient(); - const updateStateQuery = useQuery(desktopUpdateStateQueryOptions()); - const [checkError, setCheckError] = useState(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 = { download: "Download", install: "Install" }; - const statusLabel: Record = { - checking: "Checking…", - downloading: "Downloading…", - "up-to-date": "Up to Date", - }; - const buttonLabel = - actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; - - return ( - - - {buttonLabel} - - } - /> - {buttonTooltip ? {buttonTooltip} : null} - - } - status={ - <> - {checkError ?

{checkError}

: null} - {updateState?.status === "error" && updateState.errorContext === "check" ? ( -

- {updateState.message ?? "Could not check for updates."} -

- ) : null} - - } - /> - ); -} - -type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; -type InstallProviderSettings = { - provider: ProviderKind; - title: string; - binaryPathKey: InstallBinarySettingsKey; - binaryPlaceholder: string; - binaryDescription: ReactNode; - homePathKey?: "codexHomePath"; - homePlaceholder?: string; - homeDescription?: ReactNode; -}; - -const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ - { - provider: "codex", - title: "Codex", - binaryPathKey: "codexBinaryPath", - binaryPlaceholder: "Codex binary path", - binaryDescription: ( - <> - Leave blank to use codex from your PATH. - - ), - homePathKey: "codexHomePath", - homePlaceholder: "CODEX_HOME", - homeDescription: "Optional custom Codex home and config directory.", - }, - { - provider: "claudeAgent", - title: "Claude", - binaryPathKey: "claudeBinaryPath", - binaryPlaceholder: "Claude binary path", - binaryDescription: ( - <> - Leave blank to use claude from your PATH. - - ), - }, -]; - -function SettingsSection({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

- {title} -

-
- {children} -
-
- ); -} - -function SettingsRow({ - title, - description, - status, - resetAction, - control, - children, - onClick, -}: { - title: string; - description: string; - status?: ReactNode; - resetAction?: ReactNode; - control?: ReactNode; - children?: ReactNode; - onClick?: () => void; -}) { - return ( -
-
-
-
-

{title}

- - {resetAction} - -
-

{description}

- {status ?
{status}
: null} -
- {control ? ( -
- {control} -
- ) : null} -
- {children} -
- ); -} - -function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { - return ( - - { - event.stopPropagation(); - onClick(); - }} - > - - - } - /> - Reset to default - - ); -} - -function SettingsRouteView() { - const { theme, setTheme } = useTheme(); - const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); - const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); - const [openKeybindingsError, setOpenKeybindingsError] = useState(null); - const [openInstallProviders, setOpenInstallProviders] = useState>({ - codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), - claudeAgent: Boolean(settings.claudeBinaryPath), - }); - const [selectedCustomModelProvider, setSelectedCustomModelProvider] = - useState("codex"); - const [customModelInputByProvider, setCustomModelInputByProvider] = useState< - Record - >({ - codex: "", - claudeAgent: "", - }); - const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< - Partial> - >({}); - const [showAllCustomModels, setShowAllCustomModels] = useState(false); - - const codexBinaryPath = settings.codexBinaryPath; - const codexHomePath = settings.codexHomePath; - const claudeBinaryPath = settings.claudeBinaryPath; - const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; - const availableEditors = serverConfigQuery.data?.availableEditors; - - const gitTextGenerationModelOptions = getAppModelOptions( - "codex", - settings.customCodexModels, - settings.textGenerationModel, - ); - const currentGitTextGenerationModel = - settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; - const defaultGitTextGenerationModel = - defaults.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; - const isGitTextGenerationModelDirty = - currentGitTextGenerationModel !== defaultGitTextGenerationModel; - const selectedGitTextGenerationModelLabel = - gitTextGenerationModelOptions.find((option) => option.slug === currentGitTextGenerationModel) - ?.name ?? currentGitTextGenerationModel; - const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( - (providerSettings) => providerSettings.provider === selectedCustomModelProvider, - )!; - const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; - const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; - const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length; - const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => - getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({ - key: `${providerSettings.provider}:${slug}`, - provider: providerSettings.provider, - providerTitle: providerSettings.title, - slug, - })), - ); - const visibleCustomModelRows = showAllCustomModels - ? savedCustomModelRows - : savedCustomModelRows.slice(0, 5); - const isInstallSettingsDirty = - settings.claudeBinaryPath !== defaults.claudeBinaryPath || - settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath; - const changedSettingLabels = [ - ...(theme !== "system" ? ["Theme"] : []), - ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), - ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming - ? ["Assistant output"] - : []), - ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []), - ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete - ? ["Delete confirmation"] - : []), - ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []), - ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 - ? ["Custom models"] - : []), - ...(isInstallSettingsDirty ? ["Provider installs"] : []), - ]; - - const openKeybindingsFile = useCallback(() => { - if (!keybindingsConfigPath) return; - setOpenKeybindingsError(null); - setIsOpeningKeybindings(true); - const api = ensureNativeApi(); - const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); - if (!editor) { - setOpenKeybindingsError("No available editors found."); - setIsOpeningKeybindings(false); - return; - } - void api.shell - .openInEditor(keybindingsConfigPath, editor) - .catch((error) => { - setOpenKeybindingsError( - error instanceof Error ? error.message : "Unable to open keybindings file.", - ); - }) - .finally(() => { - setIsOpeningKeybindings(false); - }); - }, [availableEditors, keybindingsConfigPath]); - - const addCustomModel = useCallback( - (provider: ProviderKind) => { - const customModelInput = customModelInputByProvider[provider]; - const customModels = getCustomModelsForProvider(settings, provider); - const normalized = normalizeModelSlug(customModelInput, provider); - if (!normalized) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: "Enter a model slug.", - })); - return; - } - if (getModelOptions(provider).some((option) => option.slug === normalized)) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: "That model is already built in.", - })); - return; - } - if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, - })); - return; - } - if (customModels.includes(normalized)) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: "That custom model is already saved.", - })); - return; - } - - updateSettings(patchCustomModels(provider, [...customModels, normalized])); - setCustomModelInputByProvider((existing) => ({ - ...existing, - [provider]: "", - })); - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: null, - })); - }, - [customModelInputByProvider, settings, updateSettings], - ); - - const removeCustomModel = useCallback( - (provider: ProviderKind, slug: string) => { - const customModels = getCustomModelsForProvider(settings, provider); - updateSettings( - patchCustomModels( - provider, - customModels.filter((model) => model !== slug), - ), - ); - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: null, - })); - }, - [settings, updateSettings], - ); - - async function restoreDefaults() { - if (changedSettingLabels.length === 0) return; - - const api = readNativeApi(); - const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm( - ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( - "\n", - ), - ); - if (!confirmed) return; - - setTheme("system"); - resetSettings(); - setOpenInstallProviders({ - codex: false, - claudeAgent: false, - }); - setSelectedCustomModelProvider("codex"); - setCustomModelInputByProvider({ - codex: "", - claudeAgent: "", - }); - setCustomModelErrorByProvider({}); - } - - return ( - -
- {!isElectron && ( -
-
- - Settings -
- -
-
-
- )} - - {isElectron && ( -
- - Settings - -
- -
-
- )} - -
-
- - setTheme("system")} /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - timestampFormat: defaults.timestampFormat, - }) - } - /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - enableAssistantStreaming: defaults.enableAssistantStreaming, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - enableAssistantStreaming: Boolean(checked), - }) - } - aria-label="Stream assistant messages" - /> - } - /> - - - updateSettings({ - defaultThreadEnvMode: defaults.defaultThreadEnvMode, - }) - } - /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - confirmThreadDelete: defaults.confirmThreadDelete, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - confirmThreadDelete: Boolean(checked), - }) - } - aria-label="Confirm thread deletion" - /> - } - /> - - - - - updateSettings({ - textGenerationModel: defaults.textGenerationModel, - }) - } - /> - ) : null - } - control={ - - } - /> - - 0 ? ( - { - updateSettings({ - customCodexModels: defaults.customCodexModels, - customClaudeModels: defaults.customClaudeModels, - }); - setCustomModelErrorByProvider({}); - setShowAllCustomModels(false); - }} - /> - ) : null - } - > -
-
- - { - const value = event.target.value; - setCustomModelInputByProvider((existing) => ({ - ...existing, - [selectedCustomModelProvider]: value, - })); - if (selectedCustomModelError) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [selectedCustomModelProvider]: null, - })); - } - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - addCustomModel(selectedCustomModelProvider); - }} - placeholder={selectedCustomModelProviderSettings.example} - spellCheck={false} - /> - -
- - {selectedCustomModelError ? ( -

{selectedCustomModelError}

- ) : null} - - {totalCustomModels > 0 ? ( -
-
- {visibleCustomModelRows.map((row) => ( -
- - {row.providerTitle} - - - {row.slug} - - -
- ))} -
- - {savedCustomModelRows.length > 5 ? ( - - ) : null} -
- ) : null} -
-
-
- - - { - updateSettings({ - claudeBinaryPath: defaults.claudeBinaryPath, - codexBinaryPath: defaults.codexBinaryPath, - codexHomePath: defaults.codexHomePath, - }); - setOpenInstallProviders({ - codex: false, - claudeAgent: false, - }); - }} - /> - ) : null - } - > -
-
- {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { - const isOpen = openInstallProviders[providerSettings.provider]; - const isDirty = - providerSettings.provider === "codex" - ? settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath - : settings.claudeBinaryPath !== defaults.claudeBinaryPath; - const binaryPathValue = - providerSettings.binaryPathKey === "claudeBinaryPath" - ? claudeBinaryPath - : codexBinaryPath; - - return ( - - setOpenInstallProviders((existing) => ({ - ...existing, - [providerSettings.provider]: open, - })) - } - > -
- - - -
-
- - - {providerSettings.homePathKey ? ( - - ) : null} -
-
-
-
-
- ); - })} -
-
-
- - - - {keybindingsConfigPath ?? "Resolving keybindings path..."} - - {openKeybindingsError ? ( - {openKeybindingsError} - ) : ( - Opens in your preferred editor. - )} - - } - control={ - - } - /> - - {APP_VERSION} - } - /> - {isElectron ? : null} -
-
-
-
-
- ); -} - -export const Route = createFileRoute("/_chat/settings")({ - component: SettingsRouteView, -}); diff --git a/apps/web/src/routes/settings.about.tsx b/apps/web/src/routes/settings.about.tsx new file mode 100644 index 0000000000..bde58198ea --- /dev/null +++ b/apps/web/src/routes/settings.about.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { AboutSettingsPanel } from "../components/settings/SettingsPanels"; + +export const Route = createFileRoute("/settings/about")({ + component: AboutSettingsPanel, +}); From f2b79c9bbfaa517469535c72a44ec9d9b71ac7ff Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 29 Mar 2026 04:36:45 +0530 Subject: [PATCH 11/23] refine settings about layout --- .../components/settings/SettingsPanels.tsx | 95 ++++++++++--------- .../settings/SettingsSidebarNav.tsx | 2 +- apps/web/src/routeTree.gen.ts | 26 ++--- 3 files changed, 62 insertions(+), 61 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 79a76e2ddb..7c1db56cbc 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -212,22 +212,26 @@ function SettingsSection({ title, icon, headerAction, + hideHeader = false, children, }: { title: string; icon?: ReactNode; headerAction?: ReactNode; + hideHeader?: boolean; children: ReactNode; }) { return (
-
-

- {icon} - {title} -

- {headerAction} -
+ {!hideHeader ? ( +
+

+ {icon} + {title} +

+ {headerAction} +
+ ) : null}
{children}
@@ -243,7 +247,7 @@ function SettingsRow({ control, children, }: { - title: string; + title: ReactNode; description: string; status?: ReactNode; resetAction?: ReactNode; @@ -306,10 +310,9 @@ function SettingsPageContainer({ children }: { children: ReactNode }) { ); } -function DesktopUpdateCheckSection() { +function AboutVersionSection() { const queryClient = useQueryClient(); const updateStateQuery = useQuery(desktopUpdateStateQueryOptions()); - const [checkError, setCheckError] = useState(null); const updateState = updateStateQuery.data ?? null; @@ -330,7 +333,6 @@ function DesktopUpdateCheckSection() { const handleButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge) return; - setCheckError(null); const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; const opts = desktopUpdateStateQueryOptions(); @@ -342,7 +344,11 @@ function DesktopUpdateCheckSection() { queryClient.setQueryData(opts.queryKey, result.state); }) .catch((error: unknown) => { - setCheckError(error instanceof Error ? error.message : "Download failed."); + toastManager.add({ + type: "error", + title: "Could not download update", + description: error instanceof Error ? error.message : "Download failed.", + }); }); return; } @@ -359,7 +365,11 @@ function DesktopUpdateCheckSection() { queryClient.setQueryData(opts.queryKey, result.state); }) .catch((error: unknown) => { - setCheckError(error instanceof Error ? error.message : "Install failed."); + toastManager.add({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "Install failed.", + }); }); return; } @@ -370,13 +380,20 @@ function DesktopUpdateCheckSection() { .then((result) => { queryClient.setQueryData(opts.queryKey, result.state); if (!result.checked) { - setCheckError( - result.state.message ?? "Automatic updates are not available in this build.", - ); + toastManager.add({ + type: "error", + title: "Could not check for updates", + description: + result.state.message ?? "Automatic updates are not available in this build.", + }); } }) .catch((error: unknown) => { - setCheckError(error instanceof Error ? error.message : "Update check failed."); + toastManager.add({ + type: "error", + title: "Could not check for updates", + description: error instanceof Error ? error.message : "Update check failed.", + }); }); }, [queryClient, updateState]); @@ -395,12 +412,13 @@ function DesktopUpdateCheckSection() { return ( + Version + {APP_VERSION} + } + description="Current version of the application." control={ {buttonTooltip} : null} } - status={ - <> - {checkError ?

{checkError}

: null} - {updateState?.status === "error" && updateState.errorContext === "check" ? ( -

- {updateState.message ?? "Could not check for updates."} -

- ) : null} - - } /> ); } @@ -731,7 +739,7 @@ export function GeneralSettingsPanel() { : null; return ( - + } /> - - {APP_VERSION}} - /> ); @@ -1405,13 +1407,12 @@ export function GeneralSettingsPanel() { export function AboutSettingsPanel() { return ( - }> - {APP_VERSION}} - /> - {isElectron ? : null} + } hideHeader> + {isElectron ? ( + + ) : ( + + )} ); @@ -1472,7 +1473,7 @@ export function ArchivedThreadsPanel() { return ( {archivedGroups.length === 0 ? ( - + diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 0d22896c15..59a649f757 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -20,8 +20,8 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ icon: ComponentType<{ className?: string }>; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, + { label: "Archived", to: "/settings/archived", icon: ArchiveIcon }, { label: "About", to: "/settings/about", icon: InfoIcon }, - { label: "Archived threads", to: "/settings/archived", icon: ArchiveIcon }, ]; export function SettingsSidebarNav({ pathname }: { pathname: string }) { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 43966bc8ce..6d88b458de 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -12,9 +12,9 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' -import { Route as SettingsAboutRouteImport } from './routes/settings.about' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as SettingsAboutRouteImport } from './routes/settings.about' import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' const SettingsRoute = SettingsRouteImport.update({ @@ -31,11 +31,6 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) -const SettingsAboutRoute = SettingsAboutRouteImport.update({ - id: '/about', - path: '/about', - getParentRoute: () => SettingsRoute, -} as any) const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ id: '/general', path: '/general', @@ -46,6 +41,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const SettingsAboutRoute = SettingsAboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => SettingsRoute, +} as any) const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ id: '/$threadId', path: '/$threadId', @@ -134,13 +134,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } - '/settings/about': { - id: '/settings/about' - path: '/about' - fullPath: '/settings/about' - preLoaderRoute: typeof SettingsAboutRouteImport - parentRoute: typeof SettingsRoute - } '/settings/general': { id: '/settings/general' path: '/general' @@ -155,6 +148,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/settings/about': { + id: '/settings/about' + path: '/about' + fullPath: '/settings/about' + preLoaderRoute: typeof SettingsAboutRouteImport + parentRoute: typeof SettingsRoute + } '/_chat/$threadId': { id: '/_chat/$threadId' path: '/$threadId' From 0bfc742e3ebc2a1e74a66f4d3b5f6e557c21f669 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 29 Mar 2026 04:38:19 +0530 Subject: [PATCH 12/23] chore(web): clean up sidebar imports after merge --- apps/web/src/components/Sidebar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e1287c8a04..2e909725df 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -56,7 +56,6 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { Collapsible, CollapsibleContent } from "./ui/collapsible"; import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { From cf33c2c09dfb03a404334405b5db9c146e451b8f Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 29 Mar 2026 04:55:11 +0530 Subject: [PATCH 13/23] Update About card description for available updates --- apps/web/src/components/settings/SettingsPanels.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 7c1db56cbc..5e62d0e714 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -409,6 +409,10 @@ function AboutVersionSection() { }; const buttonLabel = actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; + const description = + action === "download" || action === "install" + ? "Update available !" + : "Current version of the application."; return ( {APP_VERSION} } - description="Current version of the application." + description={description} control={ Date: Sun, 29 Mar 2026 05:05:32 +0530 Subject: [PATCH 14/23] Fix about update card install action --- apps/web/src/components/desktopUpdate.logic.test.ts | 10 ++++++++++ apps/web/src/components/desktopUpdate.logic.ts | 9 +++------ apps/web/src/components/settings/SettingsPanels.tsx | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index e22692bee3..b73519c974 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -70,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, diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 01067aa9ee..3a8ce375aa 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -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"; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5e62d0e714..1aa8d19287 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -429,7 +429,7 @@ function AboutVersionSection() { render={ - } - /> - {desktopUpdateTooltip} - - )} - - + + {wordmark} + ) : ( {wordmark} diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 38a6c97e76..84bde53048 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -6,6 +6,7 @@ import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, + getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -204,6 +205,24 @@ 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", () => { diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 850afb9a7b..38983c810b 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -76,6 +76,13 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string return "Up to date"; } +export function getDesktopUpdateInstallConfirmationMessage( + state: Pick, +): 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 { if (!result.accepted || result.completed) return null; if (typeof result.state.message !== "string") return null; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c999d13ec2..347b01db63 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -25,6 +25,7 @@ import { APP_VERSION } from "../../branding"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, + getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, } from "../../components/desktopUpdate.logic"; @@ -352,9 +353,10 @@ function AboutVersionSection() { } 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.`, + getDesktopUpdateInstallConfirmationMessage( + updateState ?? { availableVersion: null, downloadedVersion: null }, + ), ); if (!confirmed) return; void bridge diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 105f555a8b..2f9aec112a 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -11,6 +11,7 @@ import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, + getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -71,6 +72,8 @@ export function SidebarUpdatePill() { } if (action === "install") { + const confirmed = window.confirm(getDesktopUpdateInstallConfirmationMessage(state)); + if (!confirmed) return; void bridge .installUpdate() .then((result) => { @@ -97,7 +100,7 @@ export function SidebarUpdatePill() { if (!visible && !showArm64Warning) return null; return ( -
+
{showArm64Warning && arm64Description && ( @@ -107,11 +110,11 @@ export function SidebarUpdatePill() { )} {visible && (
-
+
{action === "install" ? ( @@ -156,7 +159,7 @@ export function SidebarUpdatePill() {