Skip to content

fix(theme): surface legacy syntax translation#574

Merged
benvinegar merged 2 commits into
mainfrom
fix/legacy-syntax-deprecation-notice
Jul 19, 2026
Merged

fix(theme): surface legacy syntax translation#574
benvinegar merged 2 commits into
mainfrom
fix/legacy-syntax-deprecation-notice

Conversation

@benvinegar

@benvinegar benvinegar commented Jul 19, 2026

Copy link
Copy Markdown
Member

Summary

  • show a brief transient footer notice when deprecated [custom_theme.syntax] colors are translated into approximate Shiki scopes
  • generalize the existing update-only startup notice hook so local deprecations and asynchronous update notices share one queue
  • preserve notice behavior across config layering and session reloads

Notice

Deprecated [custom_theme.syntax] translated approximately • migrate to [custom_theme.syntax_scopes]

Testing

  • bun run typecheck
  • bun run lint
  • bun run format:check
  • focused config, startup, hook, update-notice, and AppHost interaction tests
  • bun run test:tty-smoke (9 passed)
  • real TTY smoke run in tmux
  • bun test: 1,226 passed, 14 skipped; only the existing direct-file atomic-save watch test failed, matching the known failure documented on feat(theme): support raw Shiki syntax scopes #570
  • bun run test:integration: 54 passed; the same direct-file atomic-save watch test failed

Dependency

#570 introduced the legacy syntax translation this notice reports and is now merged.

This PR description was generated by Pi using OpenAI GPT-5.3

@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR surfaces a transient footer warning when deprecated [custom_theme.syntax] colors are translated into approximate Shiki scopes, and unifies the existing update-check hook (useStartupUpdateNotice) into a more general useStartupNotices hook that sequences both synchronous local notices and asynchronously resolved ones through a shared queue.

  • startupNotice.ts introduces the StartupNotice interface (replacing the now-removed UpdateNotice) and the LEGACY_CUSTOM_SYNTAX_NOTICE constant; config.ts detects legacy syntax across config layers and populates HunkConfigResolution.startupNotices accordingly.
  • useStartupNotices replaces useStartupUpdateNotice with a priority queue that shows local notices first, then async-resolver notices, deduplicating by key via a persistent shownKeysRef; AppHost wires the bootstrap's startupNotices into the hook and correctly switches the pager-mode guard to activeBootstrap.
  • The startupNotices field returned from resolveConfiguredCliInput always allocates a new array (including [] for the no-notice case), which changes the hook's notices reference on every session reload and causes the effect — and its async update-check timers — to restart unnecessarily.

Confidence Score: 3/5

Safe to merge for the legacy-notice feature itself, but the empty-array allocation in config.ts will silently erase any in-progress async update notice whenever a session reloads.

The core notice queue, dedup, and sequencing logic in useStartupNotices is well-tested. The issue is in config.ts: startupNotices is always a freshly allocated array (even the empty case returns []), so every reloadSession call in AppHost gives the hook a new notices reference, tearing down the current effect, calling setNoticeText(null), and marking the in-flight update notice key in shownKeysRef as already seen — permanently suppressing it for the rest of the session. On a busy workflow where diffs refresh frequently, users could consistently lose the update-available banner mid-display and never see it again until they restart the process.

src/core/config.ts line 570 — the startupNotices return value; src/ui/hooks/useStartupNotices.ts — the persistent shownKeysRef interaction with effect restarts.

Important Files Changed

Filename Overview
src/core/config.ts Propagates legacy-syntax detection through a new CustomThemeLayer return type; always returns a new array for startupNotices, causing reference instability in the hook dependency.
src/ui/hooks/useStartupNotices.ts New generalized hook that sequences local and async notices; queue and dedup logic are sound but shownKeysRef is never reset across effect restarts, making all notices one-shot per process lifetime.
src/ui/AppHost.tsx Migrates from useStartupUpdateNotice to useStartupNotices, correctly switches to activeBootstrap for the enabled check, and propagates startupNotices on reload; passes startupNotices reference directly without stabilization.
src/core/startupNotice.ts New module introducing StartupNotice interface and LEGACY_CUSTOM_SYNTAX_NOTICE constant; clean and minimal.
src/core/updateNotice.ts Removes the duplicate UpdateNotice interface and replaces it with StartupNotice from startupNotice.ts; purely a type unification with no behavioral changes.
src/core/startup.ts Passes configured.startupNotices onto the bootstrap; one-line addition in the correct position.
src/core/types.ts Adds optional startupNotices field to AppBootstrap; correctly typed as readonly and optional.
src/ui/hooks/useStartupNotices.test.tsx Good coverage of queue ordering, dedup, and resolver swap; the requeues test verifies both messages appear in seen but does not assert the legacy notice is shown for its full durationMs before the effect restarts.
src/ui/AppHost.interactions.test.tsx New interaction test confirms the legacy deprecation notice appears in the footer when startupNotices is set on the bootstrap.
src/core/startup.test.ts New startup test verifies startupNotices from resolveConfiguredCliInputImpl are forwarded into the bootstrap; straightforward and correct.
src/core/config.test.ts Adds two assertions to verify startupNotices is empty for the non-legacy case and contains LEGACY_CUSTOM_SYNTAX_NOTICE for the legacy case.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Config as config.ts
    participant Startup as startup.ts
    participant AppHost as AppHost.tsx
    participant Hook as useStartupNotices
    participant Footer as App Footer

    Config->>Config: resolveConfiguredCliInput()
    Config->>Config: readCustomTheme() → usesLegacySyntax flag
    Config-->>Startup: "HunkConfigResolution { startupNotices: [] or [LEGACY_NOTICE] }"

    Startup->>Startup: prepareStartupPlan()
    Startup-->>AppHost: "AppBootstrap { startupNotices }"

    AppHost->>Hook: "useStartupNotices({ notices: activeBootstrap.startupNotices, resolver })"
    Hook->>Hook: enqueue local notices immediately
    Hook->>Hook: "setTimeout(resolver, delayMs=1200ms)"
    Hook-->>Footer: noticeText (legacy notice or null)

    Note over Hook,Footer: After 1200ms
    Hook->>Hook: resolver() → UpdateNotice?
    Hook->>Hook: enqueue if key not in shownKeysRef
    Hook-->>Footer: noticeText (update notice)

    Note over AppHost,Hook: On session reload
    AppHost->>AppHost: reloadSession() → setActiveBootstrap(nextBootstrap)
    AppHost->>Hook: notices changes reference (new [] each time)
    Hook->>Hook: effect cleanup + restart
    Hook->>Hook: setNoticeText(null) — clears any visible notice
    Hook->>Hook: shownKeysRef keeps all previously seen keys
    Hook->>Hook: enqueue local notices (skipped if key in shownKeysRef)
    Hook->>Hook: setTimeout(resolver, delayMs) — restarts delay
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Config as config.ts
    participant Startup as startup.ts
    participant AppHost as AppHost.tsx
    participant Hook as useStartupNotices
    participant Footer as App Footer

    Config->>Config: resolveConfiguredCliInput()
    Config->>Config: readCustomTheme() → usesLegacySyntax flag
    Config-->>Startup: "HunkConfigResolution { startupNotices: [] or [LEGACY_NOTICE] }"

    Startup->>Startup: prepareStartupPlan()
    Startup-->>AppHost: "AppBootstrap { startupNotices }"

    AppHost->>Hook: "useStartupNotices({ notices: activeBootstrap.startupNotices, resolver })"
    Hook->>Hook: enqueue local notices immediately
    Hook->>Hook: "setTimeout(resolver, delayMs=1200ms)"
    Hook-->>Footer: noticeText (legacy notice or null)

    Note over Hook,Footer: After 1200ms
    Hook->>Hook: resolver() → UpdateNotice?
    Hook->>Hook: enqueue if key not in shownKeysRef
    Hook-->>Footer: noticeText (update notice)

    Note over AppHost,Hook: On session reload
    AppHost->>AppHost: reloadSession() → setActiveBootstrap(nextBootstrap)
    AppHost->>Hook: notices changes reference (new [] each time)
    Hook->>Hook: effect cleanup + restart
    Hook->>Hook: setNoticeText(null) — clears any visible notice
    Hook->>Hook: shownKeysRef keeps all previously seen keys
    Hook->>Hook: enqueue local notices (skipped if key in shownKeysRef)
    Hook->>Hook: setTimeout(resolver, delayMs) — restarts delay
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/core/config.ts:570
**New array reference on every config resolution breaks notice dedup after reload**

`resolveConfiguredCliInput` always returns a freshly-allocated array for `startupNotices` — either `[LEGACY_CUSTOM_SYNTAX_NOTICE]` or `[]`. `AppHost` passes this directly as the `notices` prop to `useStartupNotices`. Because the reference changes on every `reloadSession` call (even when the array is logically empty), the hook's `[notices]` dependency fires on each reload: the current effect is torn down, `setNoticeText(null)` immediately erases any visible async update notice, and `shownKeysRef` prevents it from being re-queued for the rest of the session.

Use `undefined` for the no-notice case so the reference is stable across reloads. `AppBootstrap.startupNotices` is already typed as optional, and `useStartupNotices` defaults to `EMPTY_STARTUP_NOTICES` when the prop is `undefined`, so no other changes are needed.

### Issue 2 of 2
src/ui/hooks/useStartupNotices.ts:28
**`shownKeysRef` is never cleared on effect restart — one-shot per process lifetime**

`shownKeysRef` is a `useRef` that accumulates shown keys for the lifetime of the `AppHost` component. When `notices` or `resolver` changes and the effect restarts (e.g., on a session reload that keeps the legacy syntax), any key already in `shownKeysRef.current` is permanently suppressed. For the update-check path this matches the old `lastShownKeyRef` behavior. For the new local `notices` path it means the deprecated-syntax warning is shown at most once per process lifetime — worth confirming that is the intended UX.

Reviews (1): Last reviewed commit: "fix(theme): surface legacy syntax transl..." | Re-trigger Greptile

Comment thread src/core/config.ts Outdated
options: resolvedOptions,
},
customTheme: resolvedCustomTheme,
startupNotices: usesLegacyCustomSyntax ? [LEGACY_CUSTOM_SYNTAX_NOTICE] : [],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 New array reference on every config resolution breaks notice dedup after reload

resolveConfiguredCliInput always returns a freshly-allocated array for startupNotices — either [LEGACY_CUSTOM_SYNTAX_NOTICE] or []. AppHost passes this directly as the notices prop to useStartupNotices. Because the reference changes on every reloadSession call (even when the array is logically empty), the hook's [notices] dependency fires on each reload: the current effect is torn down, setNoticeText(null) immediately erases any visible async update notice, and shownKeysRef prevents it from being re-queued for the rest of the session.

Use undefined for the no-notice case so the reference is stable across reloads. AppBootstrap.startupNotices is already typed as optional, and useStartupNotices defaults to EMPTY_STARTUP_NOTICES when the prop is undefined, so no other changes are needed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/config.ts
Line: 570

Comment:
**New array reference on every config resolution breaks notice dedup after reload**

`resolveConfiguredCliInput` always returns a freshly-allocated array for `startupNotices` — either `[LEGACY_CUSTOM_SYNTAX_NOTICE]` or `[]`. `AppHost` passes this directly as the `notices` prop to `useStartupNotices`. Because the reference changes on every `reloadSession` call (even when the array is logically empty), the hook's `[notices]` dependency fires on each reload: the current effect is torn down, `setNoticeText(null)` immediately erases any visible async update notice, and `shownKeysRef` prevents it from being re-queued for the rest of the session.

Use `undefined` for the no-notice case so the reference is stable across reloads. `AppBootstrap.startupNotices` is already typed as optional, and `useStartupNotices` defaults to `EMPTY_STARTUP_NOTICES` when the prop is `undefined`, so no other changes are needed.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 37b30ab. Config resolution now returns undefined when there is no local notice and reuses a singleton array when legacy syntax is present, so unchanged reloads preserve the notice queue in both cases. The config tests now assert those identity semantics.

Responded by Pi using OpenAI GPT-5.3.

This comment was generated by Pi using OpenAI GPT-5.3

resolver,
}: StartupNoticeOptions) {
const [noticeText, setNoticeText] = useState<string | null>(null);
const shownKeysRef = useRef(new Set<string>());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 shownKeysRef is never cleared on effect restart — one-shot per process lifetime

shownKeysRef is a useRef that accumulates shown keys for the lifetime of the AppHost component. When notices or resolver changes and the effect restarts (e.g., on a session reload that keeps the legacy syntax), any key already in shownKeysRef.current is permanently suppressed. For the update-check path this matches the old lastShownKeyRef behavior. For the new local notices path it means the deprecated-syntax warning is shown at most once per process lifetime — worth confirming that is the intended UX.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/hooks/useStartupNotices.ts
Line: 28

Comment:
**`shownKeysRef` is never cleared on effect restart — one-shot per process lifetime**

`shownKeysRef` is a `useRef` that accumulates shown keys for the lifetime of the `AppHost` component. When `notices` or `resolver` changes and the effect restarts (e.g., on a session reload that keeps the legacy syntax), any key already in `shownKeysRef.current` is permanently suppressed. For the update-check path this matches the old `lastShownKeyRef` behavior. For the new local `notices` path it means the deprecated-syntax warning is shown at most once per process lifetime — worth confirming that is the intended UX.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed as intentional: startup notices are one-shot for the lifetime of AppHost, matching the existing update-notice policy, so config/session reloads do not replay a warning the user already saw. I documented that policy beside shownKeysRef and tightened the restart test to assert the local warning appears exactly once.

Responded by Pi using OpenAI GPT-5.3.

This comment was generated by Pi using OpenAI GPT-5.3

@benvinegar
benvinegar changed the base branch from feat/raw-shiki-syntax-scopes to main July 19, 2026 01:06
@benvinegar
benvinegar force-pushed the fix/legacy-syntax-deprecation-notice branch from 7830670 to e37a77e Compare July 19, 2026 01:06
@benvinegar
benvinegar merged commit 53fcb2c into main Jul 19, 2026
8 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.

1 participant