Skip to content

fix(floating-button): persist resting position across restarts - #1715

Merged
zhangmo8 merged 1 commit into
devfrom
fix/floating-button-position-persistence
Jun 1, 2026
Merged

fix(floating-button): persist resting position across restarts#1715
zhangmo8 merged 1 commit into
devfrom
fix/floating-button-position-persistence

Conversation

@zhangmo8

@zhangmo8 zhangmo8 commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

背景

Closes #1714

悬浮按钮被拖动后,重启应用不会记住位置,每次都跳回主屏幕左上角。

根因

悬浮按钮通过 electron-window-state 持久化位置,它跟踪实时窗口边界。但按钮空闲时停靠在 peeked(半隐藏)状态,一半在屏幕外:

  • electron-window-state 记录了这个屏幕外矩形;
  • 下次启动时 ensureWindowVisibleOnSomeDisplay() 判定坐标不可见,重置为 {0,0},按钮跳到左上角;
  • 次要:悬浮窗用 destroy()(非 close())销毁,只触发 closed,退出前位置捕获不可靠。

方案

不再用 electron-window-state 跟踪悬浮按钮的实时边界,改为持久化逻辑停靠位置(吸附后、完全可见的坐标 + dock 方向),通过本项目惯用的 configPresenter(electron-store)存储:

  • 保存:拖拽结束(DRAG_END)吸附到边缘后,保存 snapped{ x, y, dockSide }(始终在屏幕内)。
  • 恢复:创建窗口时读取持久化位置,按当前显示器工作区重新吸附到保存的边缘并夹取纵向偏移,分辨率/显示器变化也能稳健恢复。
  • 首次运行(无记录)保持原有默认位置(右下角 + 偏移)。

纯主进程改动,不涉及 renderer / IPC 路由,无新增依赖,无数据迁移(旧的 floating-button-window-state.json 不再读取即可)。

行为对比(BEFORE / AFTER)

BEFORE — 拖到右侧中部后重启
┌────────────────────────────┐      ┌────────────────────────────┐
│ ▮ ← 重启后跳回左上角        │      │                            │
│                            │      │                          ▮ │ ← 拖到这里
│                            │  →   │                            │
│                            │      │                            │
└────────────────────────────┘      └────────────────────────────┘
        重启后                              退出前

AFTER — 位置被记住
┌────────────────────────────┐      ┌────────────────────────────┐
│                            │      │                            │
│                          ▮ │      │                          ▮ │ ← 拖到这里
│                            │  →   │                            │
│                            │      │                            │
└────────────────────────────┘      └────────────────────────────┘
        重启后(同一边缘/高度)            退出前

改动文件

  • src/shared/types/floating-widget.ts:新增 FloatingWidgetDockSideFloatingButtonBounds 类型。
  • src/main/presenter/floatingButtonPresenter/layout.ts:复用共享 FloatingWidgetDockSide(单一来源)。
  • src/main/presenter/configPresenter/index.ts + IConfigPresenter:新增 getFloatingButtonBounds / setFloatingButtonBounds
  • src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts:移除 electron-window-state,接收并恢复持久化位置。
  • src/main/presenter/floatingButtonPresenter/index.ts:创建时读取、拖拽结束时保存。
  • docs/issues/floating-button-position-persistence/:SDD spec/plan/tasks。

测试

  • 新增:DRAG_END 后持久化吸附位置、初始化时读取持久化位置。
  • pnpm vitest run test/main/presenter/floatingButtonPresenter → 11/11 通过。
  • pnpm run typecheckpnpm run lintpnpm run formatpnpm run i18n 通过。

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Floating button now remembers and restores its position after app restart
    • Restored position automatically adjusts to ensure full visibility on screen
    • Button repositions intelligently when display configuration changes

The floating button relied on electron-window-state, which tracks live
window bounds. Because the widget idles in a peeked (half-off-screen)
position, electron-window-state recorded an off-screen rectangle and then
reset it to {0,0} on the next launch (ensureWindowVisibleOnSomeDisplay),
so the button always jumped to the top-left corner after a restart.

Persist the snapped, fully on-screen docked position (y + dock side) via
configPresenter on drag end, and restore it on window creation, re-docking
to the saved edge and clamping into the current display work area.
@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR replaces electron-window-state with explicit persisted bounds to fix the floating button resetting to top-left after restart. It adds shared types (FloatingWidgetDockSide, FloatingButtonBounds), extends ConfigPresenter with get/set methods, refactors FloatingButtonWindow to accept and restore persisted bounds with work-area clamping, and persists snapped bounds on drag end.

Changes

Floating Button Position Persistence

Layer / File(s) Summary
Planning and specification
docs/issues/floating-button-position-persistence/spec.md, docs/issues/floating-button-position-persistence/plan.md, docs/issues/floating-button-position-persistence/tasks.md
Specification documents the root cause (off-screen bounds recorded by electron-window-state being reset on restore) and acceptance criteria (restore docked edge + vertical offset with work-area clamping). Plan outlines the refactor: replace window-state persistence with explicit bounds + dock side storage via configPresenter, remove electron-window-state integration, and validate persistence in tests. Tasks enumerate completed work items.
Shared type contracts
src/shared/types/floating-widget.ts, src/main/presenter/floatingButtonPresenter/layout.ts, src/shared/types/presenters/legacy.presenters.d.ts
Define exported FloatingWidgetDockSide ('left' | 'right') and FloatingButtonBounds(x, y, dockSide) in shared types. Updatelayout.tsto import and re-exportFloatingWidgetDockSidefrom shared types. ExtendIConfigPresenter` interface with getter/setter methods.
Persistence API in ConfigPresenter
src/main/presenter/configPresenter/index.ts
Implement getFloatingButtonBounds() reading and validating stored bounds (numeric x/y, dockSide of left/right), returning null if invalid/missing; and setFloatingButtonBounds() persisting bounds via setSetting.
Window initialization with restoration
src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts
Accept persistedBounds in constructor; initialize dockSide from persistedBounds.dockSide or fall back to config-inferred side. Remove electron-window-state management. Rewrite resolveInitialBounds() to compute horizontal edge alignment from saved dockSide, clamp vertical offset to current display work area, and round coordinates; when no persisted bounds exist, fall back to config.position with same clamping/rounding.
Drag-end persistence and loading
src/main/presenter/floatingButtonPresenter/index.ts
Load persisted bounds from configPresenter.getFloatingButtonBounds() and pass into FloatingButtonWindow constructor during window creation. On DRAG_END, after snapping bounds to edge and dock side, call new persistFloatingBounds() helper to save {x, y, dockSide} via configPresenter.setFloatingButtonBounds() with error logging.
Test validation of persistence
test/main/presenter/floatingButtonPresenter/index.test.ts
Update createConfigPresenter() mock with getFloatingButtonBounds and setFloatingButtonBounds stubs. Add test cases verifying initialize() calls getFloatingButtonBounds() to restore persisted position, and that DRAG_END persists snapped docked bounds with expected dockSide: 'right' (not interim peek).

Sequence Diagrams

sequenceDiagram
  participant FloatingButtonPresenter
  participant ConfigPresenter
  participant FloatingButtonWindow
  participant Display
  FloatingButtonPresenter->>ConfigPresenter: getFloatingButtonBounds()
  ConfigPresenter-->>FloatingButtonPresenter: FloatingButtonBounds | null
  FloatingButtonPresenter->>FloatingButtonWindow: new FloatingButtonWindow(config, persistedBounds)
  FloatingButtonWindow->>FloatingButtonWindow: resolveInitialBounds()
  alt persistedBounds exist
    FloatingButtonWindow->>Display: getDisplayNearestPoint(y)
    Display-->>FloatingButtonWindow: display work area
    FloatingButtonWindow->>FloatingButtonWindow: clamp y to work area
    FloatingButtonWindow->>FloatingButtonWindow: align x to dockSide edge
  else no persisted bounds
    FloatingButtonWindow->>FloatingButtonWindow: compute from config.position
    FloatingButtonWindow->>Display: clamp to work area
  end
  FloatingButtonWindow-->>FloatingButtonPresenter: initialized with bounds
Loading
sequenceDiagram
  participant User
  participant FloatingButtonPresenter
  participant FloatingButtonWindow
  participant ConfigPresenter
  User->>FloatingButtonWindow: drag start/move/end
  FloatingButtonPresenter->>FloatingButtonPresenter: onDragEnd()
  FloatingButtonPresenter->>FloatingButtonPresenter: snap bounds to edge and dockSide
  FloatingButtonPresenter->>FloatingButtonPresenter: persistFloatingBounds(snapped)
  FloatingButtonPresenter->>ConfigPresenter: setFloatingButtonBounds({x, y, dockSide})
  ConfigPresenter->>ConfigPresenter: setSetting('floatingButtonBounds', value)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • ThinkInAIXYZ/deepchat#810: Introduces the DRAG_START/DRAG_MOVE/DRAG_END lifecycle that this PR hooks persistence into at drag-end time.
  • ThinkInAIXYZ/deepchat#814: Both PRs modify the DRAG_END flow in FloatingButtonPresenter to clamp/snap bounds to the active display work area.
  • ThinkInAIXYZ/deepchat#729: Adds initial electron-window-state integration that this PR replaces with persisted bounds via configPresenter.

Poem

🐰 A button once lost when the restart bell chimed,
Now remembers its place, perfectly timed!
No more the left-top reset blues,
Docked edge and height—it never will lose. ✨

🚥 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 'fix(floating-button): persist resting position across restarts' accurately and concisely describes the main change: persisting and restoring the floating button's resting position after application restarts.
Linked Issues check ✅ Passed The PR fully implements the requirements from issue #1714: it persists the logical docked position instead of raw window bounds, re-docks to saved edge, and clamps vertical offset to current display work area, preventing off-screen coordinates from being reset.
Out of Scope Changes check ✅ Passed All changes directly support the floating button position persistence objective. Added types, presenter methods, window initialization logic, and drag-end persistence are all within scope; documentation and tests validate the implementation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 fix/floating-button-position-persistence

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 and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/presenter/floatingButtonPresenter/index.ts (1)

344-355: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Persist the collapsed resting bounds, not the dragged rectangle.

Line 354 currently saves whatever size was being dragged. If the user drops while the widget is expanded or otherwise revealed, the saved y belongs to that larger rect, so the next launch restores the collapsed button at the wrong resting height.

💡 Suggested fix
       const currentDisplay = screen.getDisplayMatching(stableBounds)
       const snapped = snapWidgetBoundsToEdge(stableBounds, currentDisplay.workArea)
+      const restingBounds = repositionWidgetForResize(
+        snapped,
+        getWidgetSizeForSnapshot({ ...this.snapshot, expanded: false }),
+        currentDisplay.workArea,
+        snapped.dockSide
+      )
       this.floatingWindow.setDockSide(snapped.dockSide)
       this.floatingWindow.setBounds(snapped)
-      this.persistFloatingBounds(snapped)
+      this.persistFloatingBounds({ ...restingBounds, dockSide: snapped.dockSide })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/presenter/floatingButtonPresenter/index.ts` around lines 344 - 355,
The code is persisting the dragged rectangle (snapped) instead of the widget's
collapsed resting bounds; update the logic in the drop/finish-drag path so that
after computing snapped via snapWidgetBoundsToEdge(stableBounds,
currentDisplay.workArea) and applying it with
this.floatingWindow.setDockSide/snapped and
this.floatingWindow.setBounds(snapped), you compute the collapsed resting rect
(e.g., via this.floatingWindow.getCollapsedBounds() or by using the widget's
collapsed height/y) and call this.persistFloatingBounds(collapsedBounds) instead
of this.persistFloatingBounds(snapped); keep this.isDragging = false as-is.
🧹 Nitpick comments (1)
test/main/presenter/floatingButtonPresenter/index.test.ts (1)

280-286: ⚡ Quick win

Test doesn't actually verify restoration.

Since getFloatingButtonBounds is mocked to return null (Line 197), this test only proves the method is called during initialize() — it never exercises the restore-to-persisted-position path that the title promises. Consider overriding the stub to return a concrete persisted record and asserting the window is re-docked/clamped to it (e.g. setBounds/setDockSide called with the expected on-screen coordinates).

💚 Example: assert actual restoration
   it('restores the persisted resting position on initialization', async () => {
     const configPresenter = createConfigPresenter()
+    configPresenter.getFloatingButtonBounds.mockReturnValue({ x: 0, y: 300, dockSide: 'left' })
     floatingPresenter = new FloatingButtonPresenter(configPresenter)
     await floatingPresenter.initialize()

     expect(configPresenter.getFloatingButtonBounds).toHaveBeenCalled()
+    expect(floatingWindowState.dockSide).toBe('left')
+    expect(floatingWindowState.bounds.y).toBe(300)
   })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/main/presenter/floatingButtonPresenter/index.test.ts` around lines 280 -
286, The test currently only verifies that
configPresenter.getFloatingButtonBounds is called; update the test to stub
getFloatingButtonBounds to return a concrete persisted bounds/dock record and
then assert FloatingButtonPresenter.initialize actually restores it by verifying
calls to window methods like setBounds and setDockSide (or presenter methods
that apply positioning) on the floatingPresenter; locate the stubbed
getFloatingButtonBounds (used when creating configPresenter), set it to return
the expected persisted object, call floatingPresenter.initialize(), and assert
the expected setBounds/setDockSide calls were made with the on-screen
coordinates.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/main/presenter/floatingButtonPresenter/index.ts`:
- Around line 344-355: The code is persisting the dragged rectangle (snapped)
instead of the widget's collapsed resting bounds; update the logic in the
drop/finish-drag path so that after computing snapped via
snapWidgetBoundsToEdge(stableBounds, currentDisplay.workArea) and applying it
with this.floatingWindow.setDockSide/snapped and
this.floatingWindow.setBounds(snapped), you compute the collapsed resting rect
(e.g., via this.floatingWindow.getCollapsedBounds() or by using the widget's
collapsed height/y) and call this.persistFloatingBounds(collapsedBounds) instead
of this.persistFloatingBounds(snapped); keep this.isDragging = false as-is.

---

Nitpick comments:
In `@test/main/presenter/floatingButtonPresenter/index.test.ts`:
- Around line 280-286: The test currently only verifies that
configPresenter.getFloatingButtonBounds is called; update the test to stub
getFloatingButtonBounds to return a concrete persisted bounds/dock record and
then assert FloatingButtonPresenter.initialize actually restores it by verifying
calls to window methods like setBounds and setDockSide (or presenter methods
that apply positioning) on the floatingPresenter; locate the stubbed
getFloatingButtonBounds (used when creating configPresenter), set it to return
the expected persisted object, call floatingPresenter.initialize(), and assert
the expected setBounds/setDockSide calls were made with the on-screen
coordinates.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7df14693-cc49-4fd2-88f9-0be9b3db3d70

📥 Commits

Reviewing files that changed from the base of the PR and between ed3cf44 and b922129.

📒 Files selected for processing (10)
  • docs/issues/floating-button-position-persistence/plan.md
  • docs/issues/floating-button-position-persistence/spec.md
  • docs/issues/floating-button-position-persistence/tasks.md
  • src/main/presenter/configPresenter/index.ts
  • src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts
  • src/main/presenter/floatingButtonPresenter/index.ts
  • src/main/presenter/floatingButtonPresenter/layout.ts
  • src/shared/types/floating-widget.ts
  • src/shared/types/presenters/legacy.presenters.d.ts
  • test/main/presenter/floatingButtonPresenter/index.test.ts

@zhangmo8
zhangmo8 merged commit c80d28a into dev Jun 1, 2026
3 checks passed
@zhangmo8
zhangmo8 deleted the fix/floating-button-position-persistence branch June 1, 2026 04:41
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.

[BUG] 悬浮按钮/窗口位置在重启后丢失(重置到左上角)

1 participant