Skip to content

Show a transient startup update notice#127

Merged
benvinegar merged 4 commits intomodem-dev:mainfrom
tanvesh01:tanvesh01/issue-125
Mar 29, 2026
Merged

Show a transient startup update notice#127
benvinegar merged 4 commits intomodem-dev:mainfrom
tanvesh01:tanvesh01/issue-125

Conversation

@tanvesh01
Copy link
Copy Markdown
Contributor

@tanvesh01 tanvesh01 commented Mar 28, 2026

Summary

image

Adds a subtle startup update notice to the existing bottom status bar when the installed hunk version is behind npm dist-tags for hunkdiff.

What changed

  • export resolveCliVersion() from src/core/cli.ts so update checks can reuse the shipped version lookup
  • add src/core/updateNotice.ts to:
    • fetch npm dist-tags for hunkdiff
    • compare the installed version against latest and beta
    • choose the best update to recommend
    • return a session-local notice payload with the final message text
  • add src/ui/hooks/useStartupUpdateNotice.ts to:
    • run one delayed background check after startup
    • repeat checks every 6 hours while the same TUI session stays open
    • suppress re-showing the same notice in one session
    • auto-dismiss the notice after a short delay
  • wire the resolver through src/main.tsx and src/ui/App.tsx
  • extend StatusBar so it can render the notice when the filter UI is not active
  • add basic coverage for updateNotice.ts
  • add status bar precedence tests to keep filter UI ahead of the notice

Behavior

  • only runs for normal app sessions, not pager mode
  • does not block startup or take focus
  • shows one line in the status bar:
    • Update available: <version> (<channel>) • <command>
  • stable installs prefer latest when both latest and beta are newer
  • prerelease installs choose the higher newer version between latest and beta

Validation

  • bun run typecheck
  • bun test test/update-notice.test.ts

Closes #125

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 28, 2026

Greptile Summary

This PR adds a non-blocking, transient startup update notice to the existing bottom status bar. When hunk is started in normal (non-pager) mode, a background check fires after 1.2 s, fetches dist-tags from the npm registry, compares the installed semver against latest/beta, and if an update is available, shows a single line in the StatusBar for 7 seconds. The check repeats every 6 hours while the session stays open, and the same notice key is never shown twice in one session.

Key changes:

  • resolveCliVersion() exported from src/core/cli.ts so the new module can reuse it.
  • src/core/updateNotice.ts encapsulates all version-comparison and dist-tags logic; fully injectable for tests.
  • src/ui/hooks/useStartupUpdateNotice.ts owns the timer lifecycle, deduplication, and auto-dismiss.
  • StatusBar extended with a noticeText prop behind a three-tier ternary (filter input > filter summary > notice).
  • Unit tests cover the main version-selection branches and StatusBar precedence.

Issues found:

  • \"0.0.0-unknown\" \u2014 the fallback returned by resolveCliVersion() when no package.json is found \u2014 passes isPrereleaseVersion, so dev/source builds always trigger the notice against every published release.
  • If the npm registry fetch hangs indefinitely (no AbortController timeout), inFlight is never reset and all subsequent 6-hour interval checks are silently skipped for the session.
  • The startedRef.current guard has a latent footgun: if the resolver prop ever changes identity (causing effect cleanup + re-run), the cleanup cancels the existing timers but the re-run returns early, permanently disabling the hook for that session.
  • Test coverage gaps: no test for the already-up-to-date case, non-OK HTTP status, prerelease install where latest is the higher winner, or the \"0.0.0-unknown\" path.

Confidence Score: 5/5

Safe to merge — all findings are P2; the update notice is non-blocking and non-critical, so the identified gaps cannot cause data loss or a broken primary path.

All four findings are quality/reliability improvements to a cosmetic, opt-in feature. The hanging-fetch and startedRef issues only affect the notice's ability to appear (a best-effort feature), not core app functionality. The dev-build false-positive is cosmetic. No P0 or P1 issues found.

src/core/updateNotice.ts and src/ui/hooks/useStartupUpdateNotice.ts warrant a second look for the dev-version guard and fetch-timeout issues before this pattern is widely reused.

Important Files Changed

Filename Overview
src/core/updateNotice.ts New module: fetches npm dist-tags, selects the best update notice, and returns a session-local payload; well-structured but dev builds with "0.0.0-unknown" version always trigger the notice
src/ui/hooks/useStartupUpdateNotice.ts New hook: manages delayed background update check with interval repeats and auto-dismiss; two reliability issues — startedRef guard is permanently defeated if resolver identity changes, and inFlight is never reset on a hanging fetch
src/ui/components/chrome/StatusBar.tsx Adds noticeText prop; clean three-way ternary gives filter input > filter summary > notice precedence
src/ui/App.tsx Wires startupNoticeResolver through App → AppShell; correctly gates on pager mode and passes stable module-level function reference
src/core/cli.ts Single-line change: exports resolveCliVersion for reuse by the new update-notice module
src/main.tsx Passes resolveStartupUpdateNotice as startupNoticeResolver prop into App; only affects normal (non-pager) sessions
test/update-notice.test.ts New test file with four cases; missing coverage for already-up-to-date, non-OK HTTP response, prerelease install where latest wins, and dev version strings
test/ui-components.test.tsx Adds three StatusBar precedence tests covering notice-only, filter-input-wins, and filter-summary-wins scenarios; complete and correct

Sequence Diagram

sequenceDiagram
    participant Main as main.tsx
    participant App as App.tsx
    participant Hook as useStartupUpdateNotice
    participant Resolver as updateNotice.ts
    participant NPM as registry.npmjs.org
    participant Bar as StatusBar

    Main->>App: render(<App startupNoticeResolver={resolveStartupUpdateNotice} />)
    App->>Hook: useStartupUpdateNotice({ enabled, resolver })
    Note over Hook: startedRef.current = true
    Hook->>Hook: setTimeout(runUpdateCheck, 1200ms)
    Hook->>Resolver: resolver()
    Resolver->>NPM: GET /hunkdiff/dist-tags
    NPM-->>Resolver: { latest, beta }
    Resolver->>Resolver: parseDistTags()
    Resolver->>Resolver: selectUpdateNotice(installedVersion, distTags)
    Resolver-->>Hook: UpdateNotice | null
    Hook->>Hook: setNoticeText(notice.message)
    Hook->>App: noticeText (state)
    App->>Bar: noticeText prop
    Bar-->>Bar: render notice (if no active filter)
    Note over Hook: setTimeout(setNoticeText(null), 7000ms)
    Hook->>App: noticeText = null
    Note over Hook: setInterval(runUpdateCheck, 6h)
Loading

Reviews (1): Last reviewed commit: "Show a transient startup update notice" | Re-trigger Greptile

Comment on lines +83 to +93
const repeatTimer = setInterval(runUpdateCheck, DEFAULT_STARTUP_NOTICE_REPEAT_MS);
repeatTimer.unref?.();

return () => {
cancelled = true;
inFlight = false;
clearTimeout(delayTimer);
clearInterval(repeatTimer);

clearDismissTimer();
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 resolver identity change permanently disables the hook

When the resolver prop changes reference (e.g., during a session reload that re-creates the App subtree), React will run this effect's cleanup followed by a fresh effect invocation. The cleanup sets cancelled = true and clears all timers — but the fresh invocation finds startedRef.current === true and returns immediately without re-establishing any timers. The update check is silently dead for the rest of the session.

In the current production code path resolver is always the module-level resolveStartupUpdateNotice (a stable reference), so this never fires. But the hook's API contract does not document or enforce this constraint, and a future caller who wraps the resolver in an anonymous function would trigger a hard-to-debug failure.

The fix is to either (a) reset startedRef.current = false in the cleanup so the effect can restart if its dependencies change, or (b) hold the resolver in a useRef so it is always current without being a reactive dependency:

const resolverRef = useRef(resolver);
resolverRef.current = resolver;

useEffect(() => {
  if (startedRef.current || !enabled || !resolverRef.current) {
    return;
  }
  // ... use resolverRef.current() inside runUpdateCheck
}, [enabled]); // resolver intentionally not a dep

Comment on lines +41 to +76
const runUpdateCheck = () => {
if (cancelled || inFlight) {
return;
}

inFlight = true;
void resolver()
.then((notice) => {
if (cancelled || !notice) {
return;
}

if (notice.key === lastShownKey) {
return;
}

lastShownKey = notice.key;
setNoticeText(notice.message);
clearDismissTimer();
dismissTimer = setTimeout(() => {
if (cancelled) {
return;
}

setNoticeText(null);
dismissTimer = null;
}, DEFAULT_STARTUP_NOTICE_DURATION_MS);
dismissTimer.unref?.();
})
.catch(() => {
// Ignore non-blocking update-check failures.
})
.finally(() => {
inFlight = false;
});
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 inFlight flag never resets on a hanging fetch

The inFlight guard prevents overlapping checks, which is correct. However, fetch has no built-in timeout. If the npm registry is reachable but extremely slow and the TCP connection never closes, the promise returned by resolver() may hang indefinitely. inFlight stays true for the entire session and every interval tick (at the 6-hour boundary) returns early, so the notice can never appear.

Consider wrapping the fetch call in AbortController + setTimeout to enforce a reasonable upper bound (e.g. 5 s), or propagate the signal via UpdateNoticeDeps:

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5_000);
try {
  const response = await fetchImpl(DIST_TAGS_URL, { signal: controller.signal });
  ...
} finally {
  clearTimeout(timeoutId);
}

Alternatively, wrapping the entire resolver() call in Promise.race([resolver(), timeoutPromise]) inside the hook achieves the same effect without touching updateNotice.ts.

Comment on lines +72 to +85
/** Return whether the installed version can participate in update comparisons. */
function isComparableInstalledVersion(version: string) {
return isStableVersion(version) || isPrereleaseVersion(version);
}

/** Choose the single best update notice from the fetched dist-tags and installed version. */
function selectUpdateNotice(installedVersion: string, distTags: ParsedDistTags): UpdateNotice | null {
if (!isComparableInstalledVersion(installedVersion)) {
return null;
}

const validLatest = distTags.latest && isStableVersion(distTags.latest) ? distTags.latest : undefined;
const validBeta = distTags.beta && isPrereleaseVersion(distTags.beta) ? distTags.beta : undefined;
const installedIsStable = isStableVersion(installedVersion);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dev/unknown builds always trigger the notice

resolveCliVersion() returns "0.0.0-unknown" when no package.json is found (e.g., running from source, CI, or an unpackaged dev build). isPrereleaseVersion("0.0.0-unknown") matches the pattern /^\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/, so isComparableInstalledVersion returns true. Since "0.0.0-unknown" is semver-less-than any real release, isNewerVersion returns true for every published version, and the notice fires on every dev-mode startup.

A simple guard at the top of selectUpdateNotice would suppress this:

function selectUpdateNotice(installedVersion: string, distTags: ParsedDistTags): UpdateNotice | null {
  if (installedVersion === "0.0.0-unknown") {
    return null;
  }
  if (!isComparableInstalledVersion(installedVersion)) {
    return null;
  }
  // ...
}

Or you could integrate this into isComparableInstalledVersion directly.

Comment on lines +12 to +60
describe("startup update notice", () => {
test("prefers latest for stable installs when latest is newer", async () => {
await expect(
resolveStartupUpdateNotice({
fetchImpl: async () => createDistTagsResponse({ latest: "0.7.1", beta: "0.8.0-beta.1" }),
resolveInstalledVersion: () => "0.7.0",
}),
).resolves.toEqual({
key: "latest:0.7.1",
message: "Update available: 0.7.1 (latest) • npm i -g hunkdiff",
});
});

test("falls back to beta for stable installs when latest is not newer", async () => {
await expect(
resolveStartupUpdateNotice({
fetchImpl: async () => createDistTagsResponse({ latest: "0.7.0", beta: "0.8.0-beta.1" }),
resolveInstalledVersion: () => "0.7.0",
}),
).resolves.toEqual({
key: "beta:0.8.0-beta.1",
message: "Update available: 0.8.0-beta.1 (beta) • npm i -g hunkdiff@beta",
});
});

test("beta installs choose the higher newer version between latest and beta", async () => {
await expect(
resolveStartupUpdateNotice({
fetchImpl: async () =>
createDistTagsResponse({ latest: "0.8.0", beta: "0.8.1-beta.1" }),
resolveInstalledVersion: () => "0.8.0-beta.1",
}),
).resolves.toEqual({
key: "beta:0.8.1-beta.1",
message: "Update available: 0.8.1-beta.1 (beta) • npm i -g hunkdiff@beta",
});
});

test("returns null on fetch failure", async () => {
await expect(
resolveStartupUpdateNotice({
fetchImpl: async () => {
throw new Error("network down");
},
resolveInstalledVersion: () => "0.7.0",
}),
).resolves.toBeNull();
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing test coverage for several important paths

The existing tests cover the happy-path variants well, but a few important branches are untested:

  1. Already up-to-date: no test verifies that resolveStartupUpdateNotice returns null when the installed version equals (or is newer than) the latest dist-tag. This is the most common runtime outcome and the most important invariant.

  2. Non-OK HTTP response: there is a test for a thrown error but none for a response.ok === false case (e.g. a 404 or 503 from the registry). The if (!response.ok) return null branch is completely uncovered.

  3. Prerelease install where latest is the higher update: the test "beta installs choose the higher newer version" only covers the scenario where beta wins the comparison. There is no test for when latest is the higher of the two newer versions for a prerelease user.

  4. Unknown/dev version string: no test asserts the behaviour when resolveInstalledVersion returns "0.0.0-unknown" or another non-standard string (see related comment on selectUpdateNotice).

@benvinegar
Copy link
Copy Markdown
Member

@tanvesh01 Thanks! Excited to try this

@benvinegar
Copy link
Copy Markdown
Member

Pushed a follow-up fix commit to this PR: 0be8311 (Fix startup update notice edge cases).

What changed:

  • suppresses false-positive notices for unresolved/local versions like 0.0.0-unknown
  • adds a bounded timeout/abort path around the npm dist-tags fetch so a hung request does not wedge the session checker
  • removes the brittle one-shot hook startup guard and keeps session-local dedupe while allowing clean restarts if dependencies change
  • expands test coverage for up-to-date installs, unresolved versions, non-OK responses, abort/timeout behavior, repeated-check dedupe, and resolver lifecycle changes

Validation:

  • bun run typecheck
  • bun test test/update-notice.test.ts test/startup-update-notice-hook.test.tsx test/ui-components.test.tsx

@benvinegar
Copy link
Copy Markdown
Member

Pretty handy! Thanks.

@benvinegar benvinegar merged commit 106529b into modem-dev:main Mar 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Show a brief 'new version available' notice on startup

2 participants