Skip to content

[codex] Preserve stable snapshots and stabilize Electron e2e#734

Merged
ashione merged 7 commits intoValueCell-ai:mainfrom
ashione:codex/issue-709-732-chat-recovery
Apr 1, 2026
Merged

[codex] Preserve stable snapshots and stabilize Electron e2e#734
ashione merged 7 commits intoValueCell-ai:mainfrom
ashione:codex/issue-709-732-chat-recovery

Conversation

@ashione
Copy link
Copy Markdown
Contributor

@ashione ashione commented Apr 1, 2026

What changed

  • preserve the last stable snapshot for Models token usage during background refreshes and transient empty responses
  • introduce a shared useStableSnapshot hook and apply it to Agents and Channels so those pages keep rendering prior data during refresh/error states
  • harden chat history reload behavior so transient gateway/history failures do not clear existing messages and stale session loads cannot overwrite the active session
  • add Electron Playwright coverage for setup flow, main navigation, provider lifecycle, proxy settings, and token usage views
  • stabilize Electron E2E shutdown on macOS by allowing window close to quit in E2E mode and hardening fixture teardown to quit first, then fall back to app.close() / SIGKILL

Why

Several pages were clearing visible content or falling back to blocking loading states during transient refreshes. That caused the token usage view to flash to empty, the chat view to lose visible history on failed reloads, and list pages to momentarily blank during background fetches.

The accompanying Electron coverage was needed because these regressions are easiest to miss when only unit tests are exercised. During validation, E2E shutdown on macOS also proved flaky and left residual Electron processes behind, so that path is fixed in the same PR.

User impact

  • token usage history no longer flashes to "no data" while refreshing
  • agents and channels pages keep their last successful content visible during refreshes
  • chat history is more resilient to temporary gateway/RPC failures and session-switch races
  • Electron E2E coverage now exercises the main desktop flows without depending on setup completion for the new navigation case

Validation

  • pnpm exec vitest run tests/unit/agents-page.test.tsx tests/unit/channels-page.test.tsx tests/unit/models-usage-history.test.ts tests/unit/chat-history-actions.test.ts
  • pnpm run typecheck
  • pnpm exec playwright test tests/e2e/main-navigation.spec.ts --workers=1
  • pnpm exec playwright test tests/e2e/app-smoke.spec.ts --workers=1
  • pnpm run test:e2e

@ashione ashione changed the title [codex] Preserve stable snapshots during refresh [codex] Preserve stable snapshots and stabilize Electron e2e Apr 1, 2026
@ashione ashione marked this pull request as ready for review April 1, 2026 09:04
Copilot AI review requested due to automatic review settings April 1, 2026 09:04
@ashione ashione self-assigned this Apr 1, 2026
@ashione ashione requested review from hazeone, su8su and vcfgv April 1, 2026 09:07
Copy link
Copy Markdown
Contributor

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2358562877

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +83 to +85
]).then(() => {
closed = true;
}),
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 Badge Only mark Electron app closed after close event succeeds

closeElectronApp sets closed = true after Promise.allSettled(...) regardless of whether app.waitForEvent('close') actually fired, so a timeout/rejection is treated as success and the function returns early without running the app.close()/SIGKILL fallback. In teardown paths where electronApp.quit() does not close the process promptly, this leaves orphaned Electron processes and reintroduces the CI flakiness this helper is meant to prevent.

Useful? React with 👍 / 👎.

? fetchState.data.filter((entry) => !shouldHideUsageEntry(entry))
: [];
const stableUsageHistory = fetchState.stableData.filter((entry) => !shouldHideUsageEntry(entry));
const visibleUsageHistory = resolveVisibleUsageHistory(usageHistory, stableUsageHistory);
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 Badge Hide cached usage history when gateway is not running

This now falls back to stableUsageHistory even when isGatewayRunning is false, so after a disconnect/restart the Models page can continue showing stale token history from the previous session instead of reflecting the reset state. Because reset only clears data, this path keeps old records visible while the gateway is down, which is misleading and differs from the previous behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Improves UI resilience during background refreshes/errors by preserving “stable” snapshots (token usage, agents/channels listings, chat history) and expands/stabilizes Electron Playwright E2E coverage, including a setup-bypass path for navigation smoke tests.

Changes:

  • Add stable snapshot helpers/hook and apply them to Models token usage and Agents/Channels pages to avoid blanking during refresh/error states.
  • Harden chat history reload to avoid clearing messages on transient failures and to drop stale results after session switches.
  • Add/expand Electron Playwright smoke coverage and improve macOS E2E shutdown reliability (allow close-to-quit in E2E + stronger teardown).

Reviewed changes

Copilot reviewed 18 out of 20 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tests/unit/models-usage-history.test.ts Adds unit coverage for stable/visible usage-history resolution helpers.
tests/unit/chat-history-actions.test.ts Adds unit coverage for preserving messages on reload failure + stale-session result dropping.
tests/unit/channels-page.test.tsx Adds unit coverage ensuring Channels keeps last snapshot visible during pending refresh.
tests/unit/agents-page.test.tsx Adds unit coverage ensuring Agents keeps last snapshot visible during refresh and still shows initial blocking spinner when no snapshot exists.
tests/e2e/main-navigation.spec.ts New E2E spec for core navigation when setup is bypassed.
tests/e2e/fixtures/electron.ts Adds helpers for stable window selection and more robust app shutdown; extends launcher options (skipSetup).
tests/e2e/app-smoke.spec.ts Uses new close helper to reduce flaky teardown / orphaned processes.
src/stores/chat/history-actions.ts Prevents clearing current messages on transient history failures; guards against stale session overwrites.
src/stores/chat.ts Mirrors chat history hardening in the main store loadHistory implementation.
src/pages/Models/usage-history.ts Introduces stable/visible usage history resolution helpers.
src/pages/Models/index.tsx Preserves stable usage snapshot across refreshes and adjusts loading/refreshing UI behavior.
src/pages/Channels/index.tsx Uses shared stable snapshot hook to keep channels/agents visible during refresh/error states; adds testid/spinner behavior.
src/pages/Agents/index.tsx Uses shared stable snapshot hook to keep agent/channel-account data visible during refresh; adds testid/spinner behavior.
src/hooks/use-stable-snapshot.ts New generic hook to persist and optionally display the last stable snapshot.
src/App.tsx Allows setup redirect bypass via query param for E2E navigation tests.
electron/main/index.ts Propagates E2E setup-bypass flag into renderer URL/query; adjusts quit/close behavior for E2E.
README.md Documents Electron E2E tests and headless Linux xvfb guidance.
README.zh-CN.md Same documentation additions (Chinese).
README.ja-JP.md Same documentation additions (Japanese).
pnpm-lock.yaml Bumps Playwright packages to support updated E2E behavior.
package.json Formatting-only change (final newline/brace alignment).
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)

src/pages/Channels/index.tsx:263

  • isUsingStableValue can become true when shouldUseStable is true due to an error state (since shouldUseStable: loading || Boolean(error)), which makes the refresh icon spin even when no refresh is in flight. Consider basing the spinner animation on loading (or a dedicated isRefreshing) rather than on isUsingStableValue for error-driven stable rendering.
          {gatewayStatus.state !== 'running' && (
            <div className="mb-8 p-4 rounded-xl border border-yellow-500/50 bg-yellow-500/10 flex items-center gap-3">
              <AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
              <span className="text-yellow-700 dark:text-yellow-400 text-sm font-medium">
                {t('gatewayWarning')}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

};
case 'reset':
return { status: 'idle', data: [] };
return { ...state, status: 'idle', data: [] };
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

In the reducer reset case, stableData is not cleared. Because stableUsageHistory is derived from fetchState.stableData without an isGatewayRunning guard, the UI can continue to render the last stable token-usage history even after the gateway stops (when dispatchFetch({ type: 'reset' }) runs). Consider clearing stableData on reset, or gating stableUsageHistory/visibleUsageHistory behind isGatewayRunning so stale usage data is not shown while the gateway is offline.

Suggested change
return { ...state, status: 'idle', data: [] };
return { ...state, status: 'idle', data: [], stableData: [] };

Copilot uses AI. Check for mistakes.
Comment on lines +78 to +85
Promise.allSettled([
app.waitForEvent('close', { timeout: timeoutMs }),
app.evaluate(({ app: electronApp }) => {
electronApp.quit();
}),
]).then(() => {
closed = true;
}),
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

closeElectronApp marks the app as closed unconditionally once Promise.allSettled(...) resolves. Because allSettled also resolves when waitForEvent('close') rejects due to timeout, this can cause teardown to return early and skip the app.close() / SIGKILL fallback even when the app never actually closed. Track whether the close event fired (or inspect the settled results) before deciding to return.

Suggested change
Promise.allSettled([
app.waitForEvent('close', { timeout: timeoutMs }),
app.evaluate(({ app: electronApp }) => {
electronApp.quit();
}),
]).then(() => {
closed = true;
}),
(async () => {
const [closeResult] = await Promise.allSettled([
app.waitForEvent('close', { timeout: timeoutMs }),
app.evaluate(({ app: electronApp }) => {
electronApp.quit();
}),
]);
if (closeResult && closeResult.status === 'fulfilled') {
closed = true;
}
})(),

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +49
const hasMessages = get().messages.length > 0;
set((state) => ({
loading: false,
error: !quiet && errorMessage ? errorMessage : state.error,
...(hasMessages ? {} : { messages: [] as RawMessage[] }),
}));
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

applyLoadFailure reads get().messages.length outside the set((state) => ...) callback. If messages change between that read and the update, the logic can clear messages incorrectly. Consider deriving hasMessages from state.messages.length inside the setter callback.

Suggested change
const hasMessages = get().messages.length > 0;
set((state) => ({
loading: false,
error: !quiet && errorMessage ? errorMessage : state.error,
...(hasMessages ? {} : { messages: [] as RawMessage[] }),
}));
set((state) => {
const hasMessages = state.messages.length > 0;
return {
loading: false,
error: !quiet && errorMessage ? errorMessage : state.error,
...(hasMessages ? {} : { messages: [] as RawMessage[] }),
};
});

Copilot uses AI. Check for mistakes.
Comment on lines 109 to 113
// Async: load missing image previews from disk (updates in background)
loadMissingPreviews(finalMessages).then((updated) => {
if (!isCurrentSession()) return;
if (updated) {
// Create new object references so React.memo detects changes.
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

loadMissingPreviews(...).then(...) sets messages from the captured finalMessages snapshot. Even with the new session guard, this can overwrite newer messages added later in the same session. Prefer updating based on the current state.messages (e.g., patching only affected messages/files) instead of replacing from the stale snapshot.

Copilot uses AI. Check for mistakes.
Comment on lines +1323 to +1328
const hasMessages = get().messages.length > 0;
set((state) => ({
loading: false,
error: !quiet && errorMessage ? errorMessage : state.error,
...(hasMessages ? {} : { messages: [] as RawMessage[] }),
}));
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

applyLoadFailure computes hasMessages via get() outside the set((state) => ...) callback, which can race with other updates and clear messages incorrectly. Use state.messages.length inside the setter callback to decide whether to preserve/clear messages.

Suggested change
const hasMessages = get().messages.length > 0;
set((state) => ({
loading: false,
error: !quiet && errorMessage ? errorMessage : state.error,
...(hasMessages ? {} : { messages: [] as RawMessage[] }),
}));
set((state) => {
const hasMessages = state.messages.length > 0;
return {
loading: false,
error: !quiet && errorMessage ? errorMessage : state.error,
...(hasMessages ? {} : { messages: [] as RawMessage[] }),
};
});

Copilot uses AI. Check for mistakes.
Comment on lines 1392 to 1396
// Async: load missing image previews from disk (updates in background)
loadMissingPreviews(finalMessages).then((updated) => {
if (!isCurrentSession()) return;
if (updated) {
// Create new object references so React.memo detects changes.
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

This preview-hydration path sets messages from the captured finalMessages snapshot, which can overwrite newer same-session messages appended after the history load. The new isCurrentSession() guard prevents cross-session overwrites but not same-session races. Prefer patching state.messages in-place (or merging by id/timestamp) rather than writing the snapshot back.

Copilot uses AI. Check for mistakes.
Comment on lines +125 to +129
let mounted = true;
// eslint-disable-next-line react-hooks/set-state-in-effect
void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]);
void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]).finally(() => {
if (mounted) {
setHasCompletedInitialLoad(true);
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

hasCompletedInitialLoad is only set after Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]) settles, but stable snapshot persistence is gated on hasCompletedInitialLoad && !loading. This can delay persisting a stable snapshot even after agents are already loaded/rendered, so a user refresh during that window can still fall back to the blocking spinner because hasStableValue is still false. Consider persisting as soon as the primary page data (agents/channelGroups) is available, rather than waiting on the provider snapshot.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: af3296ed31

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

previousStableEntries: UsageHistoryEntry[],
nextEntries: UsageHistoryEntry[],
): UsageHistoryEntry[] {
return nextEntries.length > 0 ? nextEntries : previousStableEntries;
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 Badge Allow empty usage fetches to clear stale history

resolveStableUsageHistory always falls back to previousStableEntries when nextEntries is empty, and the Models reducer uses this on every completed fetch. That means once the page has shown any token history, a later successful fetch returning [] can never clear it, so users keep seeing stale usage rows even when the backend has no records (for example after transcript/history reset).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b07493e60c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
};
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]);
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts, usageRefreshNonce]);
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 Badge Keep the current usage page during auto refresh

Adding usageRefreshNonce to this effect causes token-history fetches to rerun every 15s/focus, but the fetch success path still calls setUsagePage(1). As a result, once there are enough records to paginate, users on page 2+ are repeatedly forced back to page 1 during background refreshes, which makes browsing older entries impractical and is a regression introduced by the new auto-refresh loop.

Useful? React with 👍 / 👎.

@ashione
Copy link
Copy Markdown
Contributor Author

ashione commented Apr 1, 2026

@su8su @vcfgv You might try this PR, I guess double-flash-memory help users building great experience

@ashione ashione merged commit 5a3da41 into ValueCell-ai:main Apr 1, 2026
6 checks passed
@ashione ashione deleted the codex/issue-709-732-chat-recovery branch April 1, 2026 12:35
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.

2 participants