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.",
+ };
+}