Skip to content

fix(gui): give restore focus somewhere to land when its trigger is gone - #3113

Merged
lidge-jun merged 2 commits into
devfrom
codex/3059-restore-focus-return
Sep 1, 2026
Merged

lidge-jun merged 2 commits into
devfrom
codex/3059-restore-focus-return

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Aug 31, 2026 •

Copy link
Copy Markdown
Owner

Summary

Closes #3059. The reporter's diagnosis is wrong and their experience is real, which is why this is a fix rather than a close.

What the report describes cannot happen. onRestored() → refresh() clears status, if (!status) unmounts the page. But refresh() keeps cached data: runFetch shows loading only when data === undefined or forceLoading is set (gui/src/client-resource.ts:339-341), and FileIntegrationPage's restore passes neither, so useDataSurface classifies it loading-with-stale-data and the if (!status) branch at :175 is cold-load only. That branch was already cold-load only at the commit the report names.

The focus failure underneath it is real, and RestoreDialog documented it against itself:

// The row's button is gone from the DOM in the collapsed case, so this is
// a best effort: focus returns only if the trigger survived the close.

A restore that consumes its snapshot re-renders the row as an expired badge with no button (RollbackHistory.tsx:44-46), so the remembered element is detached by cleanup time. Calling .focus() on a detached node succeeds silently and focus stays on <body> — a keyboard user is dropped at the top of the document with nothing announced.

The dialog now also remembers the enclosing region and falls back to it when the trigger did not survive. isConnected is the load-bearing check; a detached node is exactly what looks fine and does nothing.

Screenshot

There is no visual change to screenshot. This alters where keyboard focus lands after a dialog closes; the rendered pixels are identical before and after. The equivalent evidence is the rendered-DOM assertion, which is what the tests capture:

before: document.activeElement === document.body      (focus lost)
after:  document.activeElement === <section> region   (focus returned)

Both are asserted against a real happy-dom render of the production RestoreDialog, not a mock.

Verification

cd gui && bun test tests/integrations-surfaces.test.tsx  -> 34 pass / 0 fail / 135 expect()
bun x tsc --noEmit                                       -> exit 0
cd gui && bun run lint                                   -> clean

Mutation: collapsing the cleanup back to trigger?.focus?.() gives 33 pass / 1 fail, exactly focus returns to a stable region when the restore removed its trigger. Restored to 34/0.

focus returns to the trigger itself when it survived is the control — the fallback must not preempt a trigger that is still in the document.

Checklist

  • Focused tests for the changed subsystem pass
  • bun x tsc --noEmit clean
  • bun run lint:gui clean
  • Regression test present and mutation-verified
  • Rendered-DOM evidence provided in place of a screenshot, with the reason stated

Triaged in the 2026-08-31 non-priority-70 bug round.

Summary by CodeRabbit

  • Bug Fixes

    • Improved keyboard focus behavior after restoring expired items.
    • Focus now moves to the containing region when the original restore control is removed.
    • Preserved focus on the restore control when it remains available.
  • Tests

    • Added regression coverage for both focus-management scenarios.

The reporter's diagnosis is wrong and their experience is real, which is why
this is a fix rather than a close.

What #3059 describes -- onRestored() -> refresh() clears status, if (!status)
unmounts the page -- cannot happen. refresh() keeps cached data:
runFetch only shows loading when data === undefined or forceLoading is set
(gui/src/client-resource.ts:339-341), and FileIntegrationPage's restore passes
neither, so useDataSurface classifies it loading-with-stale-data and the
if (!status) branch at :175 is cold-load only.

The focus failure underneath it is real, and RestoreDialog said so itself:

  // The row's button is gone from the DOM in the collapsed case, so this is
  // a best effort: focus returns only if the trigger survived the close.

A restore that consumes its snapshot re-renders the row as an expired badge
with no button (RollbackHistory.tsx:44-46), so the remembered element is
detached by cleanup time. Calling .focus() on a detached node succeeds
silently and focus stays on <body> -- a keyboard user is dropped at the top of
the document with nothing announced.

The dialog now also remembers the enclosing region and falls back to it when
the trigger did not survive, made programmatically focusable with tabindex=-1
so it never joins the Tab order. isConnected is the load-bearing check.

Mutation-checked: collapsing the cleanup back to trigger?.focus?.() fails
exactly the new region test (33 pass / 1 fail), restored to 34/0.

Closes #3059.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner August 31, 2026 19:18
@github-actions

Copy link
Copy Markdown
Contributor

✅ Deterministic PR hygiene checks passed.

@coderabbitai

coderabbitai Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Team

Run ID: 93e7c2c9-7a20-41f3-8b43-c889174bbd0c

📥 Commits

Reviewing files that changed from the base of the PR and between 58c616d and 9daba00.

📒 Files selected for processing (2)
  • gui/src/pages/integrations/RestoreDialog.tsx
  • gui/tests/integrations-surfaces.test.tsx

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

The restore dialog now preserves focus after a successful restore removes its trigger. Cleanup focuses the trigger when it remains mounted and otherwise focuses a connected containing region. Regression tests cover both outcomes.

Changes

Restore focus management

Layer / File(s) Summary
Trigger and fallback focus cleanup
gui/src/pages/integrations/RestoreDialog.tsx:35-36, gui/src/pages/integrations/RestoreDialog.tsx:48-81, gui/src/pages/integrations/RestoreDialog.tsx:96
The dialog stores a containing fallback region and tracks successful restores. Cleanup focuses the trigger when it remains available. After a successful restore removes the trigger, cleanup makes the connected fallback region focusable and focuses it. Detached targets are ignored.
Focus restoration regression coverage
gui/tests/integrations-surfaces.test.tsx:676-784
Tests verify fallback focus after asynchronous refresh removes the trigger and verify that focus remains on the original trigger when it survives dialog closure.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 9daba

This localized GUI change improves where keyboard focus lands after a restore dialog closes without changing restore behavior or access boundaries; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: ingwannu

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: providing a focus destination when the restore trigger is removed.
Linked Issues check ✅ Passed The PR addresses issue #3059 by preserving focus on the connected restore trigger and focusing the enclosing region when the trigger is removed. This prevents focus from falling to and preserve…
Out of Scope Changes check ✅ Passed The changes are limited to RestoreDialog focus cleanup and regression tests in the GUI integration surfaces. The changes directly support issue #3059 and do not introduce unrelated scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 2 files.
Full details: Linked Issues check

Explanation

The PR addresses issue #3059 by preserving focus on the connected restore trigger and focusing the enclosing region when the trigger is removed. This prevents focus from falling to <body> and preserves keyboard navigation context.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/3059-restore-focus-return

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-08-31T19:21:31.290741Z 58c616d PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions github-actions Bot added the bug Something isn't working label Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

✅ READY

  • all PR quality gates passed.

UI screenshot waived by the gui-screenshot-waived label.

Hygiene

✅ Deterministic PR hygiene checks passed.

@github-actions
github-actions Bot marked this pull request as draft August 31, 2026 19:18

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

Copy link
Copy Markdown

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: 58c616ddd3

ℹ️ 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 thread gui/src/pages/integrations/RestoreDialog.tsx
@lidge-jun lidge-jun added the gui-screenshot-waived Maintainer waiver for false-positive GUI screenshot requirements label Aug 31, 2026
@lidge-jun

Copy link
Copy Markdown
Owner Author

Applying gui-screenshot-waived rather than attaching an image, with the reason stated so a reviewer can disagree.

This PR changes where keyboard focus lands after a dialog closes. The rendered pixels are identical before and after — a screenshot of the fixed build and a screenshot of the broken build would be the same image, which is worse than no screenshot because it implies verification that did not happen.

The equivalent evidence is the rendered-DOM assertion, and it is in the tests against a real happy-dom render of the production RestoreDialog:

before: document.activeElement === document.body      (focus lost)
after:  document.activeElement === <section> region   (focus returned)

Mutation-checked: collapsing the cleanup back to trigger?.focus?.() gives 33 pass / 1 fail on exactly that assertion.

Also noting the unrelated CI failure on this head: test 4/4 failed on npm launcher restarts the stopped runtime after a staged update failure in tests/update-stop-first.test.ts, a 91-second process-integration test. This PR touches two files, both under gui/, and that suite imports neither. Rerun requested rather than patched around.

@github-actions
github-actions Bot marked this pull request as ready for review August 31, 2026 19:33
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 58 / 80

설명

이 PR은 이슈 #3059를 지금 dev HEAD 6123be31f 의 GUI에서 고칩니다. 제보자는 복원 후 페이지가 언마운트된다고 봤지만, 그 경로는 지금 트리에 없습니다. onRestored() → refresh() 는 캐시를 유지하고, gui/src/client-resource.ts 339–341행은 data === undefined 이거나 forceLoading 일 때만 로딩으로 봅니다. FileIntegrationPage 복원은 둘 다 안 넘기므로 loading-with-stale-data 이고, if (!status) 는 첫 로드 전용입니다. 제보가 가리킨 커밋에서도 그 분기는 이미 그랬습니다.

다만 포커스가 사라지는 경험은 진짜입니다. 지금 체크아웃의 gui/src/pages/integrations/RestoreDialog.tsx 44–52행은 열릴 때 활성 버튼만 기억하고, 닫힐 때 restoreFocusRef.current?.focus?.() 를 호출합니다. 주석도 “버튼이 DOM에서 없어지면 최선을 다할 뿐”이라고 인정합니다. 스냅샷을 소모한 행은 RollbackHistory.tsx 44–46행에서 expired 배지로 바뀌고 버튼이 없습니다. 기억한 노드는 cleanup 시점에 이미 떨어져 있습니다. 떨어진 노드에 .focus() 는 조용히 성공하고 포커스는 <body> 에 남습니다. 키보드 사용자는 문서 맨 위로 떨어집니다.

이 PR은 트리거가 속한 section / [role=region] / main 을 같이 기억합니다. cleanup에서 트리거가 isConnected 이면 그리로, 아니면 영역으로 돌아갑니다. 영역은 기본이 포커스 가능이 아니라서 tabindex="-1" 을 붙입니다. 탭 순서에는 안 넣고 프로그램만 포커스합니다. 테스트 두 개가 트리거가 사라진 경우와 살아 남은 경우를 고정합니다. 페이지 언마운트 가설을 코드로 반박하면서 실제 증상을 고치는 방향이 맞습니다.

round-2 prio≥70 과는 무관한 GUI 접근성 수정입니다. types.ts/config.ts 분할과도 무관합니다. 파일 2개·+114/−3이라 충돌면이 작습니다.

라인 gui/src/pages/integrations/RestoreDialog.tsx tabindex - 영역에 tabindex="-1" 을 한 번 붙이면 복원이 끝난 뒤에도 남습니다. 포커스를 되돌린 다음 빼 줄지는 취향입니다. 남아 있어도 탭 순서는 안 바뀌지만, 나중에 같은 영역이 프로그램 포커스 대상으로 계속 보입니다.
라인 closest("section, [role='region'], main") - 가장 가까운 조상이 페이지 전체 main 이면 키보드 사용자는 페이지 상단으로 갑니다. 행의 section 이 있으면 그쪽으로 가서 괜찮습니다. RollbackHistory 마크업에 행을 감싼 section이 있는지가 이 한 줄의 품질을 가릅니다.
경로 테스트 unmount - 다이얼로그 unmount로 cleanup을 재현합니다. 실제 흐름은 onRestored 후 행 리렌더입니다. 메커니즘은 같고, 통합 경로(배지로 바뀐 뒤 포커스)까지는 안 갑니다. 지금 범위엔 충분합니다.
경로 제보 #3059 - 페이지 언마운트 진단은 틀리고 경험은 맞습니다. 이 PR이 머지되면 이슈를 이 설명과 함께 닫으면 됩니다.

메인테이너의 판단이 필요한 지점

  • tabindex를 포커스 후 제거할지, 남겨 둘지
  • fallback을 행 section으로 더 좁힐지 (마크업에 그 래퍼가 있는지)
  • round-2 열차와 병렬로 바로 넣을지 (충돌면은 거의 없음)

너의 추천
머지 쪽으로 진행하는 것을 추천합니다. 제보의 원인 설명은 틀리지만 포커스 유실은 HEAD에서 재현되고, isConnected 가드와 영역 fallback이 그 구멍을 막습니다. 테스트가 두 갈래를 고정합니다. Protect dev 리뷰 후 넣고 #3059를 닫으면 됩니다.

이 댓글은 grok-bot이 작성했습니다

@lidge-jun

Copy link
Copy Markdown
Owner Author

Administrator bypass record\n\nOwner authorization was explicit in Codex session 01a05a34-1e3a-73f2-8607-15e517cbec11. Exact head: 9daba00. Current rollup: 0 failing, 0 pending. The focus-race review blocker was repaired with the real close-then-refresh-removal ordering; focused GUI tests passed and the thread is resolved. Using the MAINTAINERS.md maintain/admin bypass because the author cannot self-approve.

@lidge-jun
lidge-jun merged commit b6e53d8 into dev Sep 1, 2026
27 checks passed
@lidge-jun
lidge-jun deleted the codex/3059-restore-focus-return branch September 1, 2026 00:37
tarunravi pushed a commit to tarunravi/opencodex that referenced this pull request Sep 14, 2026
…ne (lidge-jun#3113)

* fix(gui): give restore focus somewhere to land when its trigger is gone

The reporter's diagnosis is wrong and their experience is real, which is why
this is a fix rather than a close.

What lidge-jun#3059 describes -- onRestored() -> refresh() clears status, if (!status)
unmounts the page -- cannot happen. refresh() keeps cached data:
runFetch only shows loading when data === undefined or forceLoading is set
(gui/src/client-resource.ts:339-341), and FileIntegrationPage's restore passes
neither, so useDataSurface classifies it loading-with-stale-data and the
if (!status) branch at :175 is cold-load only.

The focus failure underneath it is real, and RestoreDialog said so itself:

  // The row's button is gone from the DOM in the collapsed case, so this is
  // a best effort: focus returns only if the trigger survived the close.

A restore that consumes its snapshot re-renders the row as an expired badge
with no button (RollbackHistory.tsx:44-46), so the remembered element is
detached by cleanup time. Calling .focus() on a detached node succeeds
silently and focus stays on <body> -- a keyboard user is dropped at the top of
the document with nothing announced.

The dialog now also remembers the enclosing region and falls back to it when
the trigger did not survive, made programmatically focusable with tabindex=-1
so it never joins the Tab order. isConnected is the load-bearing check.

Mutation-checked: collapsing the cleanup back to trigger?.focus?.() fails
exactly the new region test (33 pass / 1 fail), restored to 34/0.

Closes lidge-jun#3059.

* fix(gui): retain restore focus through history refresh
agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
…ne (lidge-jun#3113)

* fix(gui): give restore focus somewhere to land when its trigger is gone

The reporter's diagnosis is wrong and their experience is real, which is why
this is a fix rather than a close.

What lidge-jun#3059 describes -- onRestored() -> refresh() clears status, if (!status)
unmounts the page -- cannot happen. refresh() keeps cached data:
runFetch only shows loading when data === undefined or forceLoading is set
(gui/src/client-resource.ts:339-341), and FileIntegrationPage's restore passes
neither, so useDataSurface classifies it loading-with-stale-data and the
if (!status) branch at :175 is cold-load only.

The focus failure underneath it is real, and RestoreDialog said so itself:

  // The row's button is gone from the DOM in the collapsed case, so this is
  // a best effort: focus returns only if the trigger survived the close.

A restore that consumes its snapshot re-renders the row as an expired badge
with no button (RollbackHistory.tsx:44-46), so the remembered element is
detached by cleanup time. Calling .focus() on a detached node succeeds
silently and focus stays on <body> -- a keyboard user is dropped at the top of
the document with nothing announced.

The dialog now also remembers the enclosing region and falls back to it when
the trigger did not survive, made programmatically focusable with tabindex=-1
so it never joins the Tab order. isConnected is the load-bearing check.

Mutation-checked: collapsing the cleanup back to trigger?.focus?.() fails
exactly the new region test (33 pass / 1 fail), restored to 34/0.

Closes lidge-jun#3059.

* fix(gui): retain restore focus through history refresh
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working gui-screenshot-waived Maintainer waiver for false-positive GUI screenshot requirements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant