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