From 19783cb0a9f9bdf8b247fb55e840fd71b15b7e22 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:20:01 -0400 Subject: [PATCH] fix(web): source control update toasts return on each new release Git and GitHub CLI toasts only fired for two hard-coded security versions, and closing one silenced it until Threadlines shipped a higher number. Plain newer releases never toasted at all. The server now keys every update advisory on the latest release instead of the security floor, and the client shows a toast for any newer release. Closing it hides it until the next release ships, the same way the Claude and Codex update prompts already behave. Security floors still change the wording and tone, but no longer gate the toast. --- .../SourceControlDiscovery.test.ts | 2 +- .../SourceControlToolVersionAdvisory.test.ts | 39 ++++++ .../SourceControlToolVersionAdvisory.ts | 35 +++++- ...ToolUpdateLaunchNotification.logic.test.ts | 119 ++++++++++++++++++ ...ntrolToolUpdateLaunchNotification.logic.ts | 53 ++++++-- ...rceControlToolUpdateLaunchNotification.tsx | 69 +++++----- .../settings/SettingsPanels.browser.tsx | 14 +-- 7 files changed, 273 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/components/SourceControlToolUpdateLaunchNotification.logic.test.ts diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index b954be18d..4c7d2add0 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 a2d487d71..c3e62c993 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 3763a370e..86a16de8e 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 000000000..b55cc8cac --- /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 7fd4b2ac0..8dabc86d4 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 0e63c37a4..013475132 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 b22771992..3eb59588e 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",