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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions apps/web/src/components/desktopUpdate.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import type { DesktopUpdateState } from "@t3tools/contracts";

import {
canCheckForUpdate,
type DesktopHostedEnvironmentWork,
desktopUpdateInstallNeedsConfirmation,
getDesktopUpdateButtonTooltip,
getDesktopUpdateReleaseHistoryUrl,
getDesktopUpdateReleaseUrl,
isDesktopHostedEnvironmentIdle,
resolveDesktopUpdateButtonAction,
} from "./desktopUpdate.logic";

Expand Down Expand Up @@ -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<string> | null, ReadonlyArray<string> | 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);
});
});
37 changes: 36 additions & 1 deletion apps/web/src/components/desktopUpdate.logic.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<Parameters<typeof isLiveSubagentShell>[0]>;
/** Null until the environment's terminal list has loaded. */
readonly terminals: ReadonlyArray<Pick<TerminalSummary, "hasRunningSubprocess">> | 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<string> | null,
runningBackendIds: ReadonlyArray<string> | 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;
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {
canCheckForUpdate,
getDesktopUpdateButtonTooltip,
getDesktopUpdateDisabledReason,
getDesktopUpdateInstallConfirmationMessage,
isDesktopUpdateButtonDisabled,
resolveDesktopUpdateButtonAction,
} from "../../components/desktopUpdate.logic";
Expand All @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 3 additions & 5 deletions apps/web/src/components/sidebar/SidebarUpdatePill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -16,7 +16,6 @@ import {
getDesktopUpdateButtonTooltip,
getDesktopUpdateDownloadedVersion,
getDesktopUpdateIdleTooltip,
getDesktopUpdateInstallConfirmationMessage,
isDesktopUpdateButtonDisabled,
resolveDesktopUpdateButtonAction,
shouldShowArm64IntelBuildWarning,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(
Expand Down
80 changes: 80 additions & 0 deletions apps/web/src/state/desktopHostedWork.ts
Original file line number Diff line number Diff line change
@@ -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<string> = [];
/** Backend ids of the environments this desktop app runs, or null unless every one is known idle. */
const idleDesktopBackendIdsAtom = Atom.make((get): ReadonlyArray<string> | 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<ReadonlyArray<string> | null>(null).pipe(
Atom.withLabel("web-desktop-idle-backend-ids:none"),
);

function readRunningBackendIds(): ReadonlyArray<string> | 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<DesktopUpdateState, "availableVersion" | "downloadedVersion">,
): Promise<boolean> =>
!desktopUpdateInstallNeedsConfirmation(idleBackendIds, readRunningBackendIds()) ||
ensureLocalApi().dialogs.confirm(getDesktopUpdateInstallConfirmationMessage(state));
}
Loading