From 8b95f4e351dc1a3c87f9dcf1cbef82f1570a5d4f Mon Sep 17 00:00:00 2001 From: 1337Crakker <1337Crakker@gmail.com> Date: Sat, 31 Oct 2026 19:36:26 +0400 Subject: [PATCH] fix(web): desktop update install asks only when work would be interrupted Installing a downloaded update always asked to confirm that running tasks would be interrupted, even with nothing running. Skip the prompt only when every backend the install stops is known idle: its thread list is live, no thread has live work, and no terminal is running a command. Anything unknown still asks. Co-Authored-By: Claude Opus 5.5 --- .../components/desktopUpdate.logic.test.ts | 45 +++++++++++ .../web/src/components/desktopUpdate.logic.ts | 37 ++++++++- .../components/settings/SettingsPanels.tsx | 11 +-- .../components/sidebar/SidebarUpdatePill.tsx | 8 +- apps/web/src/state/desktopHostedWork.ts | 80 +++++++++++++++++++ 5 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/state/desktopHostedWork.ts diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index c7d7c607ea238..3e0b0aa0f4851 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -3,9 +3,12 @@ import type { DesktopUpdateState } from "@t3tools/contracts"; import { canCheckForUpdate, + type DesktopHostedEnvironmentWork, + desktopUpdateInstallNeedsConfirmation, getDesktopUpdateButtonTooltip, getDesktopUpdateReleaseHistoryUrl, getDesktopUpdateReleaseUrl, + isDesktopHostedEnvironmentIdle, resolveDesktopUpdateButtonAction, } from "./desktopUpdate.logic"; @@ -90,3 +93,45 @@ describe("canCheckForUpdate", () => { ).toBe(true); }); }); + +describe("desktop update install confirmation", () => { + const idleThread = { + status: "completed", + activityRunStatus: null, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + } as const; + const idle: DesktopHostedEnvironmentWork = { + shellLive: true, + threads: [idleThread], + terminals: [{ hasRunningSubprocess: false }], + }; + + it.each<[string, DesktopHostedEnvironmentWork, boolean]>([ + ["no running thread or terminal command", idle, true], + [ + "a thread waiting on the user", + { ...idle, threads: [{ ...idleThread, status: "waiting" }] }, + false, + ], + [ + "a terminal running a command", + { ...idle, terminals: [{ hasRunningSubprocess: true }] }, + false, + ], + ["a cached or resyncing thread list", { ...idle, shellLive: false }, false], + ["terminals not loaded yet", { ...idle, terminals: null }, false], + ])("treats an environment with %s as idle: %s", (_case, environment, expected) => { + expect(isDesktopHostedEnvironmentIdle(environment)).toBe(expected); + }); + + it.each<[string, ReadonlyArray | null, ReadonlyArray | null, boolean]>([ + ["every running backend is idle", ["primary", "wsl"], ["primary", "wsl"], false], + ["a hosted environment is busy or unknown", null, ["primary"], true], + ["a running backend has no idle environment", ["primary"], ["primary", "wsl"], true], + ["the running backends cannot be read", ["primary"], null, true], + ["no running backend is reported", ["primary"], [], true], + ])("asks when %s: %s", (_case, idleBackendIds, runningBackendIds, expected) => { + expect(desktopUpdateInstallNeedsConfirmation(idleBackendIds, runningBackendIds)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 5277747ee6306..fce48a6056017 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -1,4 +1,9 @@ -import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; +import { + isLiveSubagentShell, + type DesktopUpdateActionResult, + type DesktopUpdateState, + type TerminalSummary, +} from "@t3tools/contracts"; export type DesktopUpdateButtonAction = "download" | "install" | "none"; @@ -127,6 +132,36 @@ export function getDesktopUpdateInstallConfirmationMessage( return `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`; } +export interface DesktopHostedEnvironmentWork { + /** The thread list reflects the server now, not a cache or a resync in flight. */ + readonly shellLive: boolean; + readonly threads: ReadonlyArray[0]>; + /** Null until the environment's terminal list has loaded. */ + readonly terminals: ReadonlyArray> | null; +} + +export function isDesktopHostedEnvironmentIdle(environment: DesktopHostedEnvironmentWork): boolean { + return ( + environment.shellLive && + environment.terminals !== null && + !environment.threads.some(isLiveSubagentShell) && + !environment.terminals.some((terminal) => terminal.hasRunningSubprocess) + ); +} + +/** Installing stops every backend this app runs, so only skip the prompt when each is known idle. */ +export function desktopUpdateInstallNeedsConfirmation( + idleBackendIds: ReadonlyArray | null, + runningBackendIds: ReadonlyArray | null, +): boolean { + return ( + idleBackendIds === null || + runningBackendIds === null || + runningBackendIds.length === 0 || + runningBackendIds.some((id) => !idleBackendIds.includes(id)) + ); +} + 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 dca3eabc868aa..7fbbbb70b3c4c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -33,7 +33,6 @@ import { canCheckForUpdate, getDesktopUpdateButtonTooltip, getDesktopUpdateDisabledReason, - getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, } from "../../components/desktopUpdate.logic"; @@ -58,6 +57,7 @@ import { scopedIdleCompaction } from "./scopedSettings"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { ThreadTitleInstructionsSettings } from "./ThreadTitleInstructionsSettings"; import { SnoozePresetsRow } from "./SnoozePresetsSettings"; +import { useConfirmDesktopUpdateInstall } from "../../state/desktopHostedWork"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, @@ -153,6 +153,9 @@ function AboutVersionSection() { ); // A second run would replace the first run's progress and local download. const updateRun = useUpdateEverythingRun(); + const confirmInstall = useConfirmDesktopUpdateInstall( + updateState !== null && resolveDesktopUpdateButtonAction(updateState) === "install", + ); const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; @@ -200,10 +203,8 @@ function AboutVersionSection() { setIsUpdateActionPending(true); let confirmed = false; try { - confirmed = await ensureLocalApi().dialogs.confirm( - getDesktopUpdateInstallConfirmationMessage( - updateState ?? { availableVersion: null, downloadedVersion: null }, - ), + confirmed = await confirmInstall( + updateState ?? { availableVersion: null, downloadedVersion: null }, ); } catch (error) { setIsUpdateActionPending(false); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 7e0ee498cece5..45a0661deb54e 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -6,7 +6,7 @@ import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime import { isElectron } from "../../env"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { cn } from "../../lib/utils"; -import { ensureLocalApi } from "../../localApi"; +import { useConfirmDesktopUpdateInstall } from "../../state/desktopHostedWork"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { @@ -16,7 +16,6 @@ import { getDesktopUpdateButtonTooltip, getDesktopUpdateDownloadedVersion, getDesktopUpdateIdleTooltip, - getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -224,6 +223,7 @@ function SidebarUpdateControl() { state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null; const everything = useUpdateEverything(localUpdateVersion); const runsEverything = everything.hasUpdates && !isDownloading; + const confirmInstall = useConfirmDesktopUpdateInstall(action === "install" && !runsEverything); const run = useUpdateEverythingRun(); const isRunningEverything = run !== null; const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ @@ -292,9 +292,7 @@ function SidebarUpdateControl() { if (action === "install") { let confirmed = false; try { - confirmed = await ensureLocalApi().dialogs.confirm( - getDesktopUpdateInstallConfirmationMessage(state), - ); + confirmed = await confirmInstall(state); } catch (error) { setIsActionPending(false); toastManager.add( diff --git a/apps/web/src/state/desktopHostedWork.ts b/apps/web/src/state/desktopHostedWork.ts new file mode 100644 index 0000000000000..5a4b5e1ab95e5 --- /dev/null +++ b/apps/web/src/state/desktopHostedWork.ts @@ -0,0 +1,80 @@ +import { useAtomValue } from "@effect/atom-react"; +import { arrayElementsEqual } from "@t3tools/client-runtime/state/entities"; +import { + AuthTerminalReadScope, + PRIMARY_LOCAL_ENVIRONMENT_ID, + type DesktopUpdateState, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/reactivity"; + +import { + desktopUpdateInstallNeedsConfirmation, + getDesktopUpdateInstallConfirmationMessage, + isDesktopHostedEnvironmentIdle, +} from "../components/desktopUpdate.logic"; +import { environmentCatalog } from "../connection/catalog"; +import { desktopLocalBackendId } from "../connection/desktopLocal"; +import { ensureLocalApi } from "../localApi"; +import { environmentSession, sessionHasScope } from "./session"; +import { environmentShell } from "./shell"; +import { terminalEnvironment } from "./terminal"; +import { environmentThreadShells } from "./threads"; + +let previousIdleBackendIds: ReadonlyArray = []; +/** Backend ids of the environments this desktop app runs, or null unless every one is known idle. */ +const idleDesktopBackendIdsAtom = Atom.make((get): ReadonlyArray | null => { + const catalog = get(environmentCatalog.catalogValueAtom); + if (!catalog.isReady) return null; + const idle: string[] = []; + for (const [environmentId, entry] of catalog.entries) { + const backendId = + entry.target._tag === "PrimaryConnectionTarget" + ? PRIMARY_LOCAL_ENVIRONMENT_ID + : desktopLocalBackendId(entry.target); + if (backendId === null) continue; + const shellLive = get(environmentShell.statusAtom(environmentId)) === "live"; + const canReadTerminals = sessionHasScope( + get(environmentSession.sessionStateAtom(environmentId)), + AuthTerminalReadScope, + ); + const terminals = + shellLive && canReadTerminals + ? AsyncResult.getOrElse( + get(terminalEnvironment.metadata({ environmentId, input: null })), + () => null, + ) + : null; + const threads = get(environmentThreadShells.environmentThreadsAtom(environmentId)); + if (!isDesktopHostedEnvironmentIdle({ shellLive, threads, terminals })) return null; + idle.push(backendId); + } + idle.sort(); + if (arrayElementsEqual(previousIdleBackendIds, idle)) return previousIdleBackendIds; + previousIdleBackendIds = idle; + return idle; +}).pipe(Atom.withLabel("web-desktop-idle-backend-ids")); + +const NO_IDLE_BACKENDS_ATOM = Atom.make | null>(null).pipe( + Atom.withLabel("web-desktop-idle-backend-ids:none"), +); + +function readRunningBackendIds(): ReadonlyArray | null { + try { + return window.desktopBridge?.getLocalEnvironmentBootstraps().map(({ id }) => id) ?? null; + } catch { + return null; + } +} + +/** Confirms a desktop update install, skipping the prompt only when nothing it stops is working. */ +export function useConfirmDesktopUpdateInstall(installable: boolean) { + // Watched only while an install is offered, so its terminal lists have loaded by the click. + const idleBackendIds = useAtomValue( + installable ? idleDesktopBackendIdsAtom : NO_IDLE_BACKENDS_ATOM, + ); + return async ( + state: Pick, + ): Promise => + !desktopUpdateInstallNeedsConfirmation(idleBackendIds, readRunningBackendIds()) || + ensureLocalApi().dialogs.confirm(getDesktopUpdateInstallConfirmationMessage(state)); +}