From d91a2962fef7bee1cfb527e9375010439fba9db2 Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 6 Sep 2026 02:04:03 -0400
Subject: [PATCH] fix(web): source control update toast stays up and shows
progress
Clicking Update now on the git or GitHub CLI toast closed it immediately
and waited in silence until the result toast arrived, while the Settings
page only showed a spinner for updates started from its own button.
The toast now stays open as the progress surface: it switches to a loading
state, mirrors the server's job message (queued, running, checking) while
the run is active, and finishes in place with the outcome. Closing it
mid-run still reports the outcome as its own toast. The Windows job
message now says the permission prompt may land in the taskbar, since
elevation requested by the background server never comes to the front.
Result wording is shared between the toast and Settings.
---
.../SourceControlToolMaintenance.ts | 6 +-
...olToolUpdateLaunchNotification.browser.tsx | 207 ++++++++++++++++++
...rceControlToolUpdateLaunchNotification.tsx | 153 ++++++++++---
.../settings/CompactVersionAdvisory.tsx | 41 ++--
.../lib/sourceControlToolUpdateCopy.test.ts | 49 +++++
.../src/lib/sourceControlToolUpdateCopy.ts | 66 ++++++
6 files changed, 466 insertions(+), 56 deletions(-)
create mode 100644 apps/web/src/components/SourceControlToolUpdateLaunchNotification.browser.tsx
create mode 100644 apps/web/src/lib/sourceControlToolUpdateCopy.test.ts
create mode 100644 apps/web/src/lib/sourceControlToolUpdateCopy.ts
diff --git a/apps/server/src/sourceControl/SourceControlToolMaintenance.ts b/apps/server/src/sourceControl/SourceControlToolMaintenance.ts
index 00ef9309..36a74767 100644
--- a/apps/server/src/sourceControl/SourceControlToolMaintenance.ts
+++ b/apps/server/src/sourceControl/SourceControlToolMaintenance.ts
@@ -235,7 +235,9 @@ export const make = Effect.fn("makeSourceControlToolMaintenance")(function* (
target,
operation,
status: "running",
- message: `${operation === "install" ? "Installing." : "Updating."}${platform === "win32" ? " Check for a Windows permission prompt." : ""}`,
+ // Elevation requested by this background process lands in the
+ // taskbar instead of on top of the app, so say where to look.
+ message: `${operation === "install" ? "Installing." : "Updating."}${platform === "win32" ? " Windows will ask for permission. If the prompt doesn't come to the front, click the flashing shield in the taskbar." : ""}`,
});
let updaterStarted = false;
for (const step of recipe.steps) {
@@ -293,7 +295,7 @@ export const make = Effect.fn("makeSourceControlToolMaintenance")(function* (
operation,
status: updaterStarted ? "started" : "succeeded",
message: updaterStarted
- ? "Finish the Windows installer, then rescan."
+ ? "Finish the Windows installer, then rescan. If it isn't showing, click the flashing shield in the taskbar."
: operation === "install"
? "Installed."
: "Update finished.",
diff --git a/apps/web/src/components/SourceControlToolUpdateLaunchNotification.browser.tsx b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.browser.tsx
new file mode 100644
index 00000000..5cfa011b
--- /dev/null
+++ b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.browser.tsx
@@ -0,0 +1,207 @@
+import "../index.css";
+
+import {
+ EnvironmentId,
+ type LocalApi,
+ type SourceControlDiscoveryResult,
+ type SourceControlSetupState,
+ type SourceControlToolUpdateResult,
+ type SourceControlToolVersionAdvisory,
+} from "@threadlines/contracts";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from "@tanstack/react-router";
+import * as Option from "effect/Option";
+import { useState, type ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import { page } from "vite-plus/test/browser";
+import { render } from "vitest-browser-react";
+
+import {
+ resetPrimaryEnvironmentDescriptorForTests,
+ writePrimaryEnvironmentDescriptor,
+} from "../environments/primary";
+import { resetSourceControlDiscoveryStateForTests } from "../lib/sourceControlDiscoveryState";
+import { __resetLocalApiForTests } from "../localApi";
+import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../rpc/atomRegistry";
+import { useStore } from "../store";
+import { SourceControlToolUpdateLaunchNotification } from "./SourceControlToolUpdateLaunchNotification";
+import { ToastProvider } from "./ui/toast";
+
+const ENVIRONMENT_ID = EnvironmentId.make("environment-update-toast");
+const IDLE_SETUP: SourceControlSetupState = {
+ tools: [],
+ githubAuth: { status: "idle", verificationUrl: null, userCode: null, message: null },
+};
+
+function discoveryWithGitHubAdvisory(
+ advisory: SourceControlToolVersionAdvisory,
+): SourceControlDiscoveryResult {
+ return {
+ versionControlSystems: [],
+ sourceControlProviders: [
+ {
+ kind: "github",
+ label: "GitHub",
+ executable: "gh",
+ status: "available",
+ version: Option.some("gh version 2.97.0"),
+ installHint: "Install GitHub CLI.",
+ detail: Option.none(),
+ auth: {
+ status: "authenticated",
+ account: Option.some("octocat"),
+ host: Option.some("github.com"),
+ detail: Option.none(),
+ },
+ versionAdvisory: advisory,
+ },
+ ],
+ };
+}
+
+const BEHIND_LATEST = discoveryWithGitHubAdvisory({
+ status: "behind_latest",
+ severity: "info",
+ currentVersion: "2.97.0",
+ latestVersion: "2.98.0",
+ recommendedVersion: "2.98.0",
+ checkedAt: null,
+ message: "A newer GitHub CLI version is available for this environment.",
+ notificationKey: "github-cli:2.98.0",
+ actions: [{ label: "Update now", kind: "runUpdate", target: "github-cli" }],
+});
+
+const CURRENT = discoveryWithGitHubAdvisory({
+ status: "current",
+ severity: "info",
+ currentVersion: "2.98.0",
+ latestVersion: "2.98.0",
+ recommendedVersion: null,
+ checkedAt: null,
+ message: null,
+ notificationKey: null,
+ actions: [],
+});
+
+function TestAppProviders({ children }: { children: ReactNode }) {
+ const [queryClient] = useState(
+ () => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
+ );
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function renderWithTestRouter(children: ReactNode) {
+ const rootRoute = createRootRoute({ component: () => children });
+ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/" });
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ return render();
+}
+
+describe("SourceControlToolUpdateLaunchNotification", () => {
+ let mounted: Awaited> | null = null;
+
+ beforeEach(async () => {
+ localStorage.clear();
+ document.body.innerHTML = "";
+ await __resetLocalApiForTests();
+ resetAppAtomRegistryForTests();
+ resetSourceControlDiscoveryStateForTests();
+ writePrimaryEnvironmentDescriptor({
+ environmentId: ENVIRONMENT_ID,
+ label: "Local",
+ platform: { os: "windows", arch: "x64" },
+ serverVersion: "0.1.0",
+ capabilities: { repositoryIdentity: false },
+ });
+ // No bootstrap yet means first-run setup is not pending, so the prompt may open.
+ useStore.setState({ activeEnvironmentId: ENVIRONMENT_ID, environmentStateById: {} } as never);
+ });
+
+ afterEach(async () => {
+ await mounted?.unmount();
+ mounted = null;
+ useStore.setState({ activeEnvironmentId: null, environmentStateById: {} } as never);
+ resetPrimaryEnvironmentDescriptorForTests();
+ resetSourceControlDiscoveryStateForTests();
+ resetAppAtomRegistryForTests();
+ await __resetLocalApiForTests();
+ Reflect.deleteProperty(window, "nativeApi");
+ document.body.innerHTML = "";
+ });
+
+ it("turns the prompt into live progress on Update now and finishes in place", async () => {
+ let resolveUpdate!: (result: SourceControlToolUpdateResult) => void;
+ const updateSourceControlTool = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveUpdate = resolve;
+ }),
+ );
+ let setup = IDLE_SETUP;
+ window.nativeApi = {
+ server: {
+ discoverSourceControl: async () => BEHIND_LATEST,
+ updateSourceControlTool,
+ getSourceControlSetup: async () => setup,
+ },
+ } as unknown as LocalApi;
+
+ mounted = await renderWithTestRouter(
+
+
+ ,
+ );
+
+ await expect
+ .element(page.getByText("GitHub update available"), { timeout: 5_000 })
+ .toBeVisible();
+ await page.getByRole("button", { name: "Update now" }).click();
+ expect(updateSourceControlTool).toHaveBeenCalledWith({ target: "github-cli" });
+
+ // Same toast, now the progress surface: no button, server's phase message.
+ await expect.element(page.getByText("Updating GitHub"), { timeout: 5_000 }).toBeVisible();
+ await expect
+ .element(page.getByText("GitHub update available"), { timeout: 5_000 })
+ .not.toBeInTheDocument();
+ setup = {
+ ...IDLE_SETUP,
+ tools: [
+ {
+ target: "github-cli",
+ operation: "update",
+ status: "running",
+ message: "Updating. Windows will ask for permission.",
+ },
+ ],
+ };
+ await expect
+ .element(page.getByText("Updating. Windows will ask for permission."), { timeout: 5_000 })
+ .toBeVisible();
+
+ resolveUpdate({
+ target: "github-cli",
+ operation: "update",
+ status: "succeeded",
+ previousVersion: "2.97.0",
+ currentVersion: "2.98.0",
+ discovery: CURRENT,
+ });
+ await expect.element(page.getByText("GitHub updated"), { timeout: 5_000 }).toBeVisible();
+ await expect.element(page.getByText("2.97.0 to 2.98.0"), { timeout: 5_000 }).toBeVisible();
+ });
+});
diff --git a/apps/web/src/components/SourceControlToolUpdateLaunchNotification.tsx b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.tsx
index 01347513..34242617 100644
--- a/apps/web/src/components/SourceControlToolUpdateLaunchNotification.tsx
+++ b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.tsx
@@ -1,10 +1,17 @@
import { useNavigate } from "@tanstack/react-router";
-import { useEffect, useMemo, useRef } from "react";
+import { isSourceControlToolBusy } from "@threadlines/client-runtime";
+import type { EnvironmentId, SourceControlToolUpdateTarget } from "@threadlines/contracts";
+import { useEffect, useMemo, useRef, useState } from "react";
import {
updateSourceControlTool,
useSourceControlDiscovery,
+ useSourceControlSetup,
} from "../lib/sourceControlDiscoveryState";
+import {
+ sourceControlToolUpdateErrorCopy,
+ sourceControlToolUpdateResultCopy,
+} from "../lib/sourceControlToolUpdateCopy";
import { useDismissedSourceControlToolAdvisoryKeys } from "../sourceControlToolAdvisoryDismissal";
import { useStore } from "../store";
import { useActiveEnvironmentFirstRunSetupPending } from "./chat/firstRunSetupState";
@@ -15,20 +22,55 @@ import {
} from "./SourceControlToolUpdateLaunchNotification.logic";
import { stackedThreadToast, toastManager } from "./ui/toast";
+const SOURCE_CONTROL_UPDATE_SUCCESS_VISIBLE_MS = 3_000;
+
const seenSourceControlToolNoticeSetKeys = new Set();
type SourceControlToolNoticeToastId = ReturnType;
interface ActiveSourceControlToolNoticeToast {
+ /** "prompt" follows the notice set and closes when it changes; "update" owns a run and finishes on its own. */
+ readonly kind: "prompt" | "update";
readonly key: string;
readonly toastId: SourceControlToolNoticeToastId;
}
+interface SourceControlToolUpdateInProgress {
+ readonly toastId: SourceControlToolNoticeToastId;
+ readonly environmentId: EnvironmentId;
+ readonly target: SourceControlToolUpdateTarget;
+}
+
+/**
+ * Mirrors the server's job message ("Updating. Windows will ask for
+ * permission…", "Checking installation.") into the running toast. Mounted only
+ * while an update toast is up, so the setup poll runs only then.
+ */
+function SourceControlToolUpdateToastProgress({
+ toastId,
+ environmentId,
+ target,
+}: SourceControlToolUpdateInProgress) {
+ const setup = useSourceControlSetup({ environmentId });
+ const job = setup.tools.find((tool) => tool.target === target);
+ const message = job && isSourceControlToolBusy(job.status) ? job.message : null;
+
+ useEffect(() => {
+ if (message) {
+ toastManager.update(toastId, { description: message });
+ }
+ }, [message, toastId]);
+
+ return null;
+}
+
export function SourceControlToolUpdateLaunchNotification() {
const navigate = useNavigate();
const activeEnvironmentId = useStore((state) => state.activeEnvironmentId);
const discovery = useSourceControlDiscovery({ environmentId: activeEnvironmentId });
const firstRunSetupPending = useActiveEnvironmentFirstRunSetupPending();
const activeToastRef = useRef(null);
+ const [updateInProgress, setUpdateInProgress] =
+ useState(null);
const { dismissedNotificationKeys, dismissNotificationKeys } =
useDismissedSourceControlToolAdvisoryKeys();
@@ -45,7 +87,7 @@ export function SourceControlToolUpdateLaunchNotification() {
useEffect(() => {
const activeToast = activeToastRef.current;
- if (activeToast && activeToast.key !== noticeSetKey) {
+ if (activeToast?.kind === "prompt" && activeToast.key !== noticeSetKey) {
toastManager.close(activeToast.toastId);
activeToastRef.current = null;
}
@@ -68,12 +110,15 @@ export function SourceControlToolUpdateLaunchNotification() {
const copy = sourceControlToolUpdateToastCopy(notices);
let toastId!: SourceControlToolNoticeToastId;
- const dismiss = () => {
- dismissNotificationKeys(dismissalKeys);
+ const release = () => {
if (activeToastRef.current?.toastId === toastId) {
activeToastRef.current = null;
}
};
+ const dismiss = () => {
+ dismissNotificationKeys(dismissalKeys);
+ release();
+ };
const openSettings = () => {
dismiss();
toastManager.close(toastId);
@@ -82,38 +127,88 @@ export function SourceControlToolUpdateLaunchNotification() {
const runUpdate = () => {
if (!directUpdateAction || !activeEnvironmentId) return;
const notice = notices[0]!;
- dismiss();
- toastManager.close(toastId);
+ const operation = directUpdateAction.operation;
- void updateSourceControlTool({
+ // The toast stays up as the progress surface, then finishes in place.
+ // If the user closes it mid-run, the outcome still gets its own toast.
+ let toastOpen = true;
+ activeToastRef.current = { kind: "update", key: noticeSetKey, toastId };
+ toastManager.update(toastId, {
+ type: "loading",
+ title: `${operation === "install" ? "Installing" : "Updating"} ${notice.label}`,
+ description: "Running the verified update command.",
+ timeout: 0,
+ actionProps: undefined,
+ data: {
+ hideCopyButton: true,
+ onClose: () => {
+ toastOpen = false;
+ release();
+ },
+ },
+ });
+ setUpdateInProgress({
+ toastId,
environmentId: activeEnvironmentId,
target: directUpdateAction.target,
- ...(directUpdateAction.operation ? { operation: directUpdateAction.operation } : {}),
+ });
+
+ const finish = (options: Parameters[0]) => {
+ setUpdateInProgress(null);
+ if (toastOpen) {
+ toastManager.update(toastId, options);
+ } else {
+ toastManager.add(options);
+ }
+ };
+
+ updateSourceControlTool({
+ environmentId: activeEnvironmentId,
+ target: directUpdateAction.target,
+ ...(operation ? { operation } : {}),
})
.then((result) => {
- toastManager.add({
- type: result.status === "succeeded" ? "success" : "info",
- title:
- result.status === "succeeded"
- ? `${notice.label} updated`
- : result.status === "started"
- ? `${notice.label} update started`
- : `${notice.label} is unchanged`,
- description:
- result.status === "succeeded"
- ? `${result.previousVersion ?? "Previous version"} to ${result.currentVersion ?? "updated"}`
- : result.status === "started"
- ? "The official installer is running. Finish any Windows permission prompt, then check again."
- : "The update command finished, but the detected version did not change.",
- });
+ const outcome = sourceControlToolUpdateResultCopy({ label: notice.label, result });
+ finish(
+ outcome.type === "success"
+ ? {
+ type: outcome.type,
+ title: outcome.title,
+ description: outcome.description,
+ timeout: 0,
+ actionProps: undefined,
+ data: {
+ hideCopyButton: true,
+ onClose: dismiss,
+ dismissAfterVisibleMs: SOURCE_CONTROL_UPDATE_SUCCESS_VISIBLE_MS,
+ },
+ }
+ : stackedThreadToast({
+ type: outcome.type,
+ title: outcome.title,
+ description: outcome.description,
+ timeout: 0,
+ actionProps: { children: "Settings", onClick: openSettings },
+ actionVariant: "outline",
+ data: { hideCopyButton: true, onClose: dismiss },
+ }),
+ );
})
.catch((error: unknown) => {
- toastManager.add(
+ const failure = sourceControlToolUpdateErrorCopy({
+ label: notice.label,
+ operation,
+ error,
+ });
+ finish(
stackedThreadToast({
type: "error",
- title: `Could not update ${notice.label}`,
- description:
- error instanceof Error ? error.message : "The verified update command failed.",
+ title: failure.title,
+ description: failure.description,
+ timeout: 0,
+ actionProps: { children: "Settings", onClick: openSettings },
+ actionVariant: "outline",
+ data: { hideCopyButton: true, onClose: dismiss },
}),
);
});
@@ -136,8 +231,8 @@ export function SourceControlToolUpdateLaunchNotification() {
},
}),
);
- activeToastRef.current = { key: noticeSetKey, toastId };
+ activeToastRef.current = { kind: "prompt", key: noticeSetKey, toastId };
}, [dismissNotificationKeys, firstRunSetupPending, navigate, noticeSetKey, notices]);
- return null;
+ return updateInProgress ? : null;
}
diff --git a/apps/web/src/components/settings/CompactVersionAdvisory.tsx b/apps/web/src/components/settings/CompactVersionAdvisory.tsx
index 1582e81c..3bd5fc37 100644
--- a/apps/web/src/components/settings/CompactVersionAdvisory.tsx
+++ b/apps/web/src/components/settings/CompactVersionAdvisory.tsx
@@ -21,6 +21,10 @@ import {
updateSourceControlTool,
useSourceControlSetup,
} from "../../lib/sourceControlDiscoveryState";
+import {
+ sourceControlToolUpdateErrorCopy,
+ sourceControlToolUpdateResultCopy,
+} from "../../lib/sourceControlToolUpdateCopy";
import { Button } from "../ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { ScrollArea } from "../ui/scroll-area";
@@ -114,36 +118,23 @@ export function CompactVersionAdvisory({
...(updateAction.operation ? { operation: updateAction.operation } : {}),
})
.then((result) => {
- toastManager.add({
- type: result.status === "succeeded" ? "success" : "info",
- title:
- result.status === "succeeded"
- ? result.operation === "install"
- ? `${label} installed`
- : `${label} updated`
- : result.status === "started"
- ? `${label} update started`
- : `${label} is unchanged`,
- description:
- result.status === "succeeded"
- ? result.operation === "install"
- ? result.currentVersion
- ? `Installed ${result.currentVersion}`
- : "Installed successfully."
- : `${result.previousVersion ?? "Previous version"} to ${result.currentVersion ?? "updated"}`
- : result.status === "started"
- ? "The official installer is running. Finish any Windows permission prompt, then check again."
- : `${packageManagerLabel(copyAction?.label)} completed, but the detected version did not change.`,
- });
+ toastManager.add(
+ sourceControlToolUpdateResultCopy({
+ label,
+ result,
+ managerLabel: packageManagerLabel(copyAction?.label),
+ }),
+ );
})
.catch((error: unknown) => {
- const operation = updateAction.operation ?? "update";
toastManager.add(
stackedThreadToast({
type: "error",
- title: `Could not ${operation === "install" ? "install" : "update"} ${label}`,
- description:
- error instanceof Error ? error.message : "The verified update command failed.",
+ ...sourceControlToolUpdateErrorCopy({
+ label,
+ operation: updateAction.operation,
+ error,
+ }),
}),
);
})
diff --git a/apps/web/src/lib/sourceControlToolUpdateCopy.test.ts b/apps/web/src/lib/sourceControlToolUpdateCopy.test.ts
new file mode 100644
index 00000000..78d27fb3
--- /dev/null
+++ b/apps/web/src/lib/sourceControlToolUpdateCopy.test.ts
@@ -0,0 +1,49 @@
+import type { SourceControlToolUpdateResult } from "@threadlines/contracts";
+import { describe, expect, it } from "vitest";
+
+import { sourceControlToolUpdateResultCopy } from "./sourceControlToolUpdateCopy";
+
+const discovery: SourceControlToolUpdateResult["discovery"] = {
+ versionControlSystems: [],
+ sourceControlProviders: [],
+};
+
+describe("sourceControlToolUpdateResultCopy", () => {
+ it("describes each outcome the server can report", () => {
+ const base = {
+ target: "github-cli",
+ operation: "update",
+ previousVersion: "2.97.0",
+ currentVersion: "2.98.0",
+ discovery,
+ } as const;
+
+ expect(
+ sourceControlToolUpdateResultCopy({
+ label: "GitHub CLI",
+ result: { ...base, status: "succeeded" },
+ }),
+ ).toEqual({
+ type: "success",
+ title: "GitHub CLI updated",
+ description: "2.97.0 to 2.98.0",
+ });
+ expect(
+ sourceControlToolUpdateResultCopy({
+ label: "Git",
+ result: { ...base, target: "git", status: "started" },
+ }).description,
+ ).toContain("flashing shield in the taskbar");
+ expect(
+ sourceControlToolUpdateResultCopy({
+ label: "GitHub CLI",
+ result: { ...base, status: "unchanged" },
+ managerLabel: "WinGet",
+ }),
+ ).toEqual({
+ type: "info",
+ title: "GitHub CLI is unchanged",
+ description: "WinGet completed, but the detected version did not change.",
+ });
+ });
+});
diff --git a/apps/web/src/lib/sourceControlToolUpdateCopy.ts b/apps/web/src/lib/sourceControlToolUpdateCopy.ts
new file mode 100644
index 00000000..141149c7
--- /dev/null
+++ b/apps/web/src/lib/sourceControlToolUpdateCopy.ts
@@ -0,0 +1,66 @@
+import type {
+ SourceControlToolUpdateOperation,
+ SourceControlToolUpdateResult,
+} from "@threadlines/contracts";
+
+/**
+ * Wording for the outcome of a source control tool update, shared by the
+ * launch toast and the Settings advisory so both describe a run the same way.
+ * Progress wording while the run is active comes from the server's job state.
+ */
+
+export interface SourceControlToolUpdateResultCopy {
+ readonly type: "success" | "info";
+ readonly title: string;
+ readonly description: string;
+}
+
+export function sourceControlToolUpdateResultCopy(input: {
+ readonly label: string;
+ readonly result: SourceControlToolUpdateResult;
+ /** "WinGet", "Homebrew"; falls back to a generic phrase. */
+ readonly managerLabel?: string;
+}): SourceControlToolUpdateResultCopy {
+ const { label, result } = input;
+ switch (result.status) {
+ case "succeeded":
+ return result.operation === "install"
+ ? {
+ type: "success",
+ title: `${label} installed`,
+ description: result.currentVersion
+ ? `Installed ${result.currentVersion}`
+ : "Installed successfully.",
+ }
+ : {
+ type: "success",
+ title: `${label} updated`,
+ description: `${result.previousVersion ?? "Previous version"} to ${result.currentVersion ?? "updated"}`,
+ };
+ case "started":
+ return {
+ type: "info",
+ title: `${label} update started`,
+ description:
+ "The official installer is running. If it isn't showing, click the flashing shield in the taskbar. Check again once it finishes.",
+ };
+ case "unchanged":
+ return {
+ type: "info",
+ title: `${label} is unchanged`,
+ description: `${input.managerLabel ?? "The update command"} completed, but the detected version did not change.`,
+ };
+ }
+}
+
+export function sourceControlToolUpdateErrorCopy(input: {
+ readonly label: string;
+ readonly operation: SourceControlToolUpdateOperation | undefined;
+ readonly error: unknown;
+}): { readonly title: string; readonly description: string } {
+ return {
+ title: `Could not ${input.operation === "install" ? "install" : "update"} ${input.label}`,
+ description:
+ input.error instanceof Error ? input.error.message : "The verified update command failed.",
+ };
+}