diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index b954be18..4c7d2add 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -205,7 +205,7 @@ it.effect("reports implemented tools separately from locally available executabl checkedAt: github.versionAdvisory?.checkedAt ?? null, message: "This GitHub CLI version can briefly open terminal windows during background telemetry on Windows and is below the recommended security-fix release.", - notificationKey: "github-cli:security:2.97.0", + notificationKey: "github-cli:2.98.0", actions: [ { label: "Update now", diff --git a/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.test.ts b/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.test.ts index a2d487d7..c3e62c99 100644 --- a/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.test.ts +++ b/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.test.ts @@ -374,3 +374,42 @@ it.effect("turns latest-release fetch failures into a null version", () => { assert.strictEqual(requestCount, 1); }).pipe(Effect.provide(httpLayer)); }); + +it.effect("keys update notifications on the latest release, security floor or not", () => + Effect.gen(function* () { + const item = (version: string): SourceControlProviderDiscoveryItem => ({ + kind: "github", + label: "GitHub", + executable: "gh", + status: "available", + version: Option.some(`gh version ${version}`), + installHint: "Install GitHub CLI.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("octocat"), + host: Option.some("github.com"), + detail: Option.none(), + }, + }); + const enrich = (version: string) => + withSourceControlToolVersionAdvisory({ + platform: "darwin", + packageManager: "homebrew", + canRunUpdate: true, + latestVersionResolver: () => Effect.succeed("2.98.0"), + item: item(version), + }); + + const behindLatest = yield* enrich("2.97.0"); + assert.strictEqual(behindLatest.versionAdvisory?.status, "behind_latest"); + assert.strictEqual(behindLatest.versionAdvisory?.notificationKey, "github-cli:2.98.0"); + + const belowSecurityFloor = yield* enrich("2.92.0"); + assert.strictEqual(belowSecurityFloor.versionAdvisory?.status, "recommended_update"); + assert.strictEqual(belowSecurityFloor.versionAdvisory?.notificationKey, "github-cli:2.98.0"); + + const current = yield* enrich("2.98.0"); + assert.strictEqual(current.versionAdvisory?.notificationKey, null); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.ts b/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.ts index 3763a370..86a16de8 100644 --- a/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.ts +++ b/apps/server/src/sourceControl/SourceControlToolVersionAdvisory.ts @@ -53,6 +53,17 @@ const LatestGitHubReleaseResponse = Schema.Struct({ tag_name: Schema.String, }); +// The client keys toast dismissals on this. Keying on the latest release +// rather than the hard-coded security floor means a closed toast returns when +// the next release ships, the same way provider update prompts behave. +function updateNotificationKey( + target: SourceControlToolVersionTarget, + version: string | null, + fallbackVersion: string, +): string { + return `${target}:${version ?? fallbackVersion}`; +} + function nonEmpty(value: string | null | undefined): string | null { const trimmed = value?.trim() ?? ""; return trimmed.length > 0 ? trimmed : null; @@ -424,7 +435,11 @@ function createGitHubCliAdvisory(input: { recommendedVersion: GH_SECURITY_VERSION, checkedAt: input.checkedAt, message: availabilityNote ? `${baseMessage} ${availabilityNote}` : baseMessage, - notificationKey: `github-cli:security:${GH_SECURITY_VERSION}`, + notificationKey: updateNotificationKey( + "github-cli", + input.latestVersion, + GH_SECURITY_VERSION, + ), actions, }); } @@ -451,7 +466,11 @@ function createGitHubCliAdvisory(input: { recommendedVersion: input.latestVersion, checkedAt: input.checkedAt, message: availabilityNote ?? "A newer GitHub CLI version is available for this environment.", - notificationKey: null, + notificationKey: updateNotificationKey( + "github-cli", + input.latestVersion, + input.latestVersion, + ), actions, }); } @@ -503,7 +522,11 @@ function createGitForWindowsAdvisory(input: { checkedAt: input.checkedAt, message: "This Git for Windows version is below the recommended security-fix release. The official updater may close open Git Bash windows during installation.", - notificationKey: `git-for-windows:security:${GIT_FOR_WINDOWS_SECURITY_VERSION}`, + notificationKey: updateNotificationKey( + "git-for-windows", + input.latestVersion, + GIT_FOR_WINDOWS_SECURITY_VERSION, + ), actions, }); } @@ -522,7 +545,11 @@ function createGitForWindowsAdvisory(input: { checkedAt: input.checkedAt, message: "A newer Git for Windows release is available. The official updater may close open Git Bash windows during installation.", - notificationKey: null, + notificationKey: updateNotificationKey( + "git-for-windows", + input.latestVersion, + input.latestVersion, + ), actions, }); } diff --git a/apps/web/src/components/SourceControlToolUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.logic.test.ts new file mode 100644 index 00000000..b55cc8ca --- /dev/null +++ b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.logic.test.ts @@ -0,0 +1,119 @@ +import type { + SourceControlDiscoveryResult, + SourceControlToolVersionAdvisory, +} from "@threadlines/contracts"; +import * as Option from "effect/Option"; +import { describe, expect, it } from "vitest"; + +import { + collectSourceControlToolUpdateNotices, + sourceControlToolUpdateToastCopy, +} from "./SourceControlToolUpdateLaunchNotification.logic"; + +function advisory( + overrides: Partial, +): SourceControlToolVersionAdvisory { + return { + status: "behind_latest", + severity: "info", + currentVersion: "2.97.0", + latestVersion: "2.98.0", + recommendedVersion: "2.98.0", + checkedAt: null, + message: null, + notificationKey: "github-cli:2.98.0", + actions: [], + ...overrides, + }; +} + +function discovery( + input: { + readonly git?: SourceControlToolVersionAdvisory; + readonly github?: SourceControlToolVersionAdvisory; + } = {}, +): SourceControlDiscoveryResult { + return { + versionControlSystems: [ + { + kind: "git", + label: "Git", + implemented: true, + status: "available", + version: Option.some("git version 2.55.0.windows.3"), + installHint: "Install Git.", + detail: Option.none(), + ...(input.git ? { versionAdvisory: input.git } : {}), + }, + ], + sourceControlProviders: [ + { + kind: "github", + label: "GitHub", + 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(), + }, + ...(input.github ? { versionAdvisory: input.github } : {}), + }, + ], + }; +} + +describe("collectSourceControlToolUpdateNotices", () => { + it("notifies for plain newer releases, not only security floors", () => { + const notices = collectSourceControlToolUpdateNotices({ + discovery: discovery({ + github: advisory({}), + git: advisory({ status: "current", notificationKey: null }), + }), + environmentKey: "environment:env-1", + }); + + expect(notices.map((notice) => notice.dismissalKey)).toEqual([ + "environment:env-1:github-cli:2.98.0", + ]); + }); +}); + +describe("sourceControlToolUpdateToastCopy", () => { + it("reads as available for plain releases and recommended once a security floor is involved", () => { + const github = { + label: "GitHub", + advisory: advisory({}), + dismissalKey: "environment:env-1:github-cli:2.98.0", + }; + const git = { + label: "Git", + advisory: advisory({ + status: "recommended_update", + severity: "warning", + message: "This Git for Windows version is below the recommended security-fix release.", + notificationKey: "git-for-windows:2.56.0.windows.1", + }), + dismissalKey: "environment:env-1:git-for-windows:2.56.0.windows.1", + }; + + expect(sourceControlToolUpdateToastCopy([github])).toEqual({ + type: "info", + title: "GitHub update available", + description: "A newer GitHub release is available.", + }); + expect(sourceControlToolUpdateToastCopy([git])).toEqual({ + type: "warning", + title: "Git update recommended", + description: "This Git for Windows version is below the recommended security-fix release.", + }); + expect(sourceControlToolUpdateToastCopy([git, github])).toEqual({ + type: "warning", + title: "2 source control updates recommended", + description: "Git and GitHub have newer releases, including a recommended security fix.", + }); + }); +}); diff --git a/apps/web/src/components/SourceControlToolUpdateLaunchNotification.logic.ts b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.logic.ts index 7fd4b2ac..8dabc86d 100644 --- a/apps/web/src/components/SourceControlToolUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.logic.ts @@ -5,22 +5,27 @@ import type { import { sourceControlToolAdvisoryDismissalKey } from "../sourceControlToolAdvisoryDismissal"; -export interface SourceControlToolUpdateWarning { +export interface SourceControlToolUpdateNotice { readonly label: string; readonly advisory: SourceControlToolVersionAdvisory; readonly dismissalKey: string; } -export function collectSourceControlToolUpdateWarnings(input: { +/** + * Every git / GitHub CLI advisory the launch toast should mention: security + * floors and plain newer releases alike. The server marks notifiable + * advisories with a key derived from the latest release, so the dismissal + * expires on its own when the next release ships. + */ +export function collectSourceControlToolUpdateNotices(input: { readonly discovery: SourceControlDiscoveryResult; readonly environmentKey: string; -}): ReadonlyArray { +}): ReadonlyArray { return [...input.discovery.versionControlSystems, ...input.discovery.sourceControlProviders] .flatMap((item) => { const advisory = item.versionAdvisory; if ( - advisory?.status !== "recommended_update" || - advisory.severity !== "warning" || + (advisory?.status !== "recommended_update" && advisory?.status !== "behind_latest") || advisory.notificationKey === null ) { return []; @@ -35,8 +40,40 @@ export function collectSourceControlToolUpdateWarnings(input: { .sort((left, right) => left.dismissalKey.localeCompare(right.dismissalKey)); } -export function sourceControlToolUpdateWarningSetKey( - warnings: ReadonlyArray, +export function sourceControlToolUpdateNoticeSetKey( + notices: ReadonlyArray, ): string | null { - return warnings.length > 0 ? warnings.map((warning) => warning.dismissalKey).join("|") : null; + return notices.length > 0 ? notices.map((notice) => notice.dismissalKey).join("|") : null; +} + +export interface SourceControlToolUpdateToastCopy { + readonly type: "warning" | "info"; + readonly title: string; + readonly description: string; +} + +/** Toast wording: security floors read as recommended, plain releases as available. */ +export function sourceControlToolUpdateToastCopy( + notices: ReadonlyArray, +): SourceControlToolUpdateToastCopy { + const hasSecurityNotice = notices.some((notice) => notice.advisory.severity === "warning"); + const verb = hasSecurityNotice ? "recommended" : "available"; + const labels = notices.map((notice) => notice.label).join(" and "); + + if (notices.length === 1) { + const notice = notices[0]!; + return { + type: hasSecurityNotice ? "warning" : "info", + title: `${notice.label} update ${verb}`, + description: notice.advisory.message ?? `A newer ${notice.label} release is available.`, + }; + } + + return { + type: hasSecurityNotice ? "warning" : "info", + title: `${notices.length} source control updates ${verb}`, + description: hasSecurityNotice + ? `${labels} have newer releases, including a recommended security fix.` + : `${labels} have newer releases.`, + }; } diff --git a/apps/web/src/components/SourceControlToolUpdateLaunchNotification.tsx b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.tsx index 0e63c37a..01347513 100644 --- a/apps/web/src/components/SourceControlToolUpdateLaunchNotification.tsx +++ b/apps/web/src/components/SourceControlToolUpdateLaunchNotification.tsx @@ -9,17 +9,18 @@ import { useDismissedSourceControlToolAdvisoryKeys } from "../sourceControlToolA import { useStore } from "../store"; import { useActiveEnvironmentFirstRunSetupPending } from "./chat/firstRunSetupState"; import { - collectSourceControlToolUpdateWarnings, - sourceControlToolUpdateWarningSetKey, + collectSourceControlToolUpdateNotices, + sourceControlToolUpdateNoticeSetKey, + sourceControlToolUpdateToastCopy, } from "./SourceControlToolUpdateLaunchNotification.logic"; import { stackedThreadToast, toastManager } from "./ui/toast"; -const seenSourceControlToolWarningSetKeys = new Set(); -type SourceControlToolWarningToastId = ReturnType; +const seenSourceControlToolNoticeSetKeys = new Set(); +type SourceControlToolNoticeToastId = ReturnType; -interface ActiveSourceControlToolWarningToast { +interface ActiveSourceControlToolNoticeToast { readonly key: string; - readonly toastId: SourceControlToolWarningToastId; + readonly toastId: SourceControlToolNoticeToastId; } export function SourceControlToolUpdateLaunchNotification() { @@ -27,54 +28,46 @@ export function SourceControlToolUpdateLaunchNotification() { const activeEnvironmentId = useStore((state) => state.activeEnvironmentId); const discovery = useSourceControlDiscovery({ environmentId: activeEnvironmentId }); const firstRunSetupPending = useActiveEnvironmentFirstRunSetupPending(); - const activeToastRef = useRef(null); + const activeToastRef = useRef(null); const { dismissedNotificationKeys, dismissNotificationKeys } = useDismissedSourceControlToolAdvisoryKeys(); - const warnings = useMemo(() => { + const notices = useMemo(() => { if (!activeEnvironmentId || !discovery.data) { return []; } - return collectSourceControlToolUpdateWarnings({ + return collectSourceControlToolUpdateNotices({ discovery: discovery.data, environmentKey: `environment:${activeEnvironmentId}`, - }).filter((warning) => !dismissedNotificationKeys.has(warning.dismissalKey)); + }).filter((notice) => !dismissedNotificationKeys.has(notice.dismissalKey)); }, [activeEnvironmentId, discovery.data, dismissedNotificationKeys]); - const warningSetKey = useMemo(() => sourceControlToolUpdateWarningSetKey(warnings), [warnings]); + const noticeSetKey = useMemo(() => sourceControlToolUpdateNoticeSetKey(notices), [notices]); useEffect(() => { const activeToast = activeToastRef.current; - if (activeToast && activeToast.key !== warningSetKey) { + if (activeToast && activeToast.key !== noticeSetKey) { toastManager.close(activeToast.toastId); activeToastRef.current = null; } if ( - warningSetKey === null || + noticeSetKey === null || firstRunSetupPending || activeToastRef.current !== null || - seenSourceControlToolWarningSetKeys.has(warningSetKey) + seenSourceControlToolNoticeSetKeys.has(noticeSetKey) ) { return; } - seenSourceControlToolWarningSetKeys.add(warningSetKey); - const dismissalKeys = warnings.map((warning) => warning.dismissalKey); - const labels = warnings.map((warning) => warning.label).join(" and "); + seenSourceControlToolNoticeSetKeys.add(noticeSetKey); + const dismissalKeys = notices.map((notice) => notice.dismissalKey); const directUpdateAction = - warnings.length === 1 - ? warnings[0]!.advisory.actions.find((action) => action.kind === "runUpdate") + notices.length === 1 + ? notices[0]!.advisory.actions.find((action) => action.kind === "runUpdate") : undefined; - const title = - warnings.length === 1 - ? `${warnings[0]!.label} update recommended` - : `${warnings.length} source control updates recommended`; - const description = - warnings.length === 1 - ? (warnings[0]!.advisory.message ?? "A source control tool update is recommended.") - : `${labels} should be updated for a known security or reliability issue.`; + const copy = sourceControlToolUpdateToastCopy(notices); - let toastId!: SourceControlToolWarningToastId; + let toastId!: SourceControlToolNoticeToastId; const dismiss = () => { dismissNotificationKeys(dismissalKeys); if (activeToastRef.current?.toastId === toastId) { @@ -88,7 +81,7 @@ export function SourceControlToolUpdateLaunchNotification() { }; const runUpdate = () => { if (!directUpdateAction || !activeEnvironmentId) return; - const warning = warnings[0]!; + const notice = notices[0]!; dismiss(); toastManager.close(toastId); @@ -102,10 +95,10 @@ export function SourceControlToolUpdateLaunchNotification() { type: result.status === "succeeded" ? "success" : "info", title: result.status === "succeeded" - ? `${warning.label} updated` + ? `${notice.label} updated` : result.status === "started" - ? `${warning.label} update started` - : `${warning.label} is unchanged`, + ? `${notice.label} update started` + : `${notice.label} is unchanged`, description: result.status === "succeeded" ? `${result.previousVersion ?? "Previous version"} to ${result.currentVersion ?? "updated"}` @@ -118,7 +111,7 @@ export function SourceControlToolUpdateLaunchNotification() { toastManager.add( stackedThreadToast({ type: "error", - title: `Could not update ${warning.label}`, + title: `Could not update ${notice.label}`, description: error instanceof Error ? error.message : "The verified update command failed.", }), @@ -128,9 +121,9 @@ export function SourceControlToolUpdateLaunchNotification() { toastId = toastManager.add( stackedThreadToast({ - type: "warning", - title, - description, + type: copy.type, + title: copy.title, + description: copy.description, timeout: 0, actionProps: { children: directUpdateAction?.label ?? "Settings", @@ -143,8 +136,8 @@ export function SourceControlToolUpdateLaunchNotification() { }, }), ); - activeToastRef.current = { key: warningSetKey, toastId }; - }, [dismissNotificationKeys, firstRunSetupPending, navigate, warningSetKey, warnings]); + activeToastRef.current = { key: noticeSetKey, toastId }; + }, [dismissNotificationKeys, firstRunSetupPending, navigate, noticeSetKey, notices]); return null; } diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index b2277199..3eb59588 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -37,8 +37,8 @@ import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; import { useUiStateStore } from "../../uiStateStore"; import { - collectSourceControlToolUpdateWarnings, - sourceControlToolUpdateWarningSetKey, + collectSourceControlToolUpdateNotices, + sourceControlToolUpdateNoticeSetKey, } from "../SourceControlToolUpdateLaunchNotification.logic"; import { ConnectionsSettings } from "./ConnectionsSettings"; import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings"; @@ -2581,7 +2581,7 @@ describe("SourceControlSettingsPanel discovery states", () => { checkedAt: "2026-08-14T00:00:00.000Z", message: "This GitHub CLI version can briefly open terminal windows during background telemetry on Windows and is below the recommended security-fix release.", - notificationKey: "github-cli:security:2.97.0", + notificationKey: "github-cli:2.98.0", actions: [ { label: "Update now", @@ -2650,13 +2650,13 @@ describe("SourceControlSettingsPanel discovery states", () => { await page.getByRole("button", { name: "Update now" }).click(); expect(updateSourceControlTool).toHaveBeenCalledWith({ target: "github-cli" }); - const warnings = collectSourceControlToolUpdateWarnings({ + const warnings = collectSourceControlToolUpdateNotices({ discovery: discoveryResult, environmentKey: "environment:test-host", }); expect(warnings).toHaveLength(1); - expect(sourceControlToolUpdateWarningSetKey(warnings)).toBe( - "environment:test-host:github-cli:security:2.97.0", + expect(sourceControlToolUpdateNoticeSetKey(warnings)).toBe( + "environment:test-host:github-cli:2.98.0", ); }); @@ -2757,7 +2757,7 @@ describe("SourceControlSettingsPanel discovery states", () => { checkedAt: "2026-08-14T00:00:00.000Z", message: "This Git for Windows version is below the recommended security-fix release. The official updater may close open Git Bash windows during installation.", - notificationKey: "git-for-windows:security:2.55.0.windows.4", + notificationKey: "git-for-windows:2.56.0.windows.1", actions: [ { label: "Update now",