Skip to content

feat: floating window#1355

Merged
zerob13 merged 1 commit intodevfrom
feat-floating-window
Mar 17, 2026
Merged

feat: floating window#1355
zerob13 merged 1 commit intodevfrom
feat-floating-window

Conversation

@zhangmo8
Copy link
Copy Markdown
Collaborator

@zhangmo8 zhangmo8 commented Mar 17, 2026

20260317_171326.mp4

Summary by CodeRabbit

Release Notes

  • New Features
    • Introduced a floating widget for DeepChat sessions with an expandable panel displaying session list and real-time status updates.
    • Added session status indicators (in progress, done, error) with active session count display.
    • Enabled drag-and-snap functionality to align the widget to screen edges.
    • Added dark/light theme and multi-language support for the floating widget.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 17, 2026

📝 Walkthrough

Walkthrough

This PR implements a comprehensive floating DeepChat agent widget system. It adds specification documents, defines data models for sessions and snapshots, introduces layout utilities and IPC communication handlers in the main process, refactors the preload API with new methods, completely rewrites the Vue renderer component with drag-and-expand capabilities, and adds internationalization support across all supported languages.

Changes

Cohort / File(s) Summary
Documentation
docs/specs/floating-agent-widget/plan.md, docs/specs/floating-agent-widget/spec.md, docs/specs/floating-agent-widget/tasks.md
Comprehensive specification, architecture plan, and task checklist for the floating widget feature including design decisions, data models, event flows, testing strategy, and risk mitigations.
Shared Types & Layout
src/shared/types/floating-widget.ts, src/main/presenter/floatingButtonPresenter/layout.ts
New TypeScript types (FloatingWidgetSnapshot, FloatingWidgetSessionItem) and layout utility functions for widget sizing, positioning, dock detection, and session snapshot building.
Main Process Events
src/main/events.ts, src/renderer/src/events.ts
Added new event constants for floating widget snapshot, language, theme, expansion, and session operations to both main and renderer event buses.
Main Process Presenter Integration
src/main/presenter/configPresenter/index.ts, src/main/presenter/deepchatAgentPresenter/index.ts, src/main/presenter/newAgentPresenter/index.ts
Integrated floating widget state refresh into existing presenters; added callbacks to refresh widget on language/theme/status changes and wrapped session list updates.
FloatingButtonWindow Enhancements
src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts, src/main/presenter/floatingButtonPresenter/types.ts
Extended window class with layout-aware bounds management, dock side tracking, opacity changes, and new public methods for bounds/dock side manipulation; increased default opacity to 1.0.
FloatingButtonPresenter Core Logic
src/main/presenter/floatingButtonPresenter/index.ts
Major refactor introducing snapshot-based state management, IPC handler registration, layout animation system, session loading/navigation, language/theme resolution, and window bounds manipulation with easing.
Preload API
src/preload/floating-preload.ts
Enhanced preload interface with new methods (getSnapshot, getLanguage, getTheme, toggleExpanded, setExpanded, openSession) and event listeners for snapshot/language/theme updates; changed click behavior to toggle expansion.
Renderer Component & Setup
src/renderer/floating/FloatingButton.vue, src/renderer/floating/components/FloatingSessionItem.vue, src/renderer/floating/main.ts, src/renderer/floating/env.d.ts
Complete rewrite of floating button component with drag-and-drop, expand/collapse, theming, and session rendering; new session item component; enhanced main.ts with i18n and theme initialization; updated type definitions.
Internationalization
src/renderer/src/i18n/{da-DK,en-US,fa-IR,fr-FR,he-IL,ja-JP,ko-KR,pt-BR,ru-RU,zh-CN,zh-HK,zh-TW}/chat.json
Added floatingWidget translation section across all 12 locales with UI strings for widget title, states, session counts, and status indicators.
Tests
test/main/presenter/floatingButtonPresenter/layout.test.ts, test/renderer/stores/sessionStore.test.ts
New test suite for layout helpers (snapshot building, repositioning, edge snapping) and renderer session store navigation on external activation.
Session Routing & Feature Flag
src/renderer/src/stores/ui/session.ts, src/shared/featureFlags.ts
Added automatic navigation to chat view when external session activation occurs; enabled FLOATING_BUTTON_AVAILABLE feature flag to activate widget.

Sequence Diagram(s)

sequenceDiagram
    participant Renderer as Floating Renderer
    participant Preload as Preload API
    participant Main as Main Process
    participant Config as Config/Agents
    
    Renderer->>Preload: getSnapshot()
    Preload->>Main: ipcRenderer.invoke(SNAPSHOT_REQUEST)
    Main->>Main: loadDeepChatSessions()
    Main->>Main: buildFloatingWidgetSnapshot()
    Main-->>Preload: FloatingWidgetSnapshot
    Preload-->>Renderer: snapshot
    
    Config->>Main: onLanguageChanged()
    Main->>Main: refreshLanguage()
    Main->>Preload: send(LANGUAGE_CHANGED)
    Preload-->>Renderer: onLanguageChanged(callback)
    
    Renderer->>Preload: toggleExpanded()
    Preload->>Main: send(TOGGLE_EXPANDED)
    Main->>Main: applyWindowLayout()
    Main->>Main: animateWindowBounds()
    Main->>Preload: send(SNAPSHOT_UPDATED)
    Preload-->>Renderer: onSnapshotUpdate(callback)
Loading
sequenceDiagram
    participant Renderer as Floating Widget UI
    participant Preload as Preload API
    participant Main as Main Process
    participant Agent as Agent Presenter
    participant Chat as Chat Window
    
    Renderer->>Preload: openSession(sessionId)
    Preload->>Main: send(OPEN_SESSION, sessionId)
    Main->>Main: resolveChatWindow()
    Main->>Agent: activateSession(sessionId)
    Agent->>Chat: navigate to session
    Chat-->>Main: SESSION_ACTIVATED event
    Main->>Preload: send(SNAPSHOT_UPDATED)
    Preload-->>Renderer: onSnapshotUpdate()
    Renderer->>Renderer: update active session UI
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

codex

Poem

🐰 A floating widget hops to life,
Sessions sorted, states so bright,
Drag it left or right with grace,
DeepChat agent finds its place!
Snippets expand on every whim,
Widget charm's a feature gem! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: floating window' is clear and directly related to the main changeset, which implements a comprehensive floating agent widget feature with layout, state management, UI components, and internationalization support.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-floating-window
📝 Coding Plan
  • Generate coding plan for human review comments

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.

Tip

You can validate your CodeRabbit configuration file in your editor.

If your editor has YAML language server, you can enable auto-completion and validation by adding # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json at the top of your CodeRabbit configuration file.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🧹 Nitpick comments (1)
src/preload/floating-preload.ts (1)

91-120: Return per-listener cleanup instead of a global purge.

These helpers only add listeners, and the only cleanup path wipes every handler on the channel. That means FloatingButton.vue cannot dispose its snapshot subscription without also removing the language/theme subscriptions registered by src/renderer/floating/main.ts.

Suggested pattern
-  onSnapshotUpdate: (callback: (snapshot: FloatingWidgetSnapshot) => void) => {
-    ipcRenderer.on(FLOATING_BUTTON_EVENTS.SNAPSHOT_UPDATED, (_event, snapshot) => {
-      callback(snapshot)
-    })
-  },
+  onSnapshotUpdate: (callback: (snapshot: FloatingWidgetSnapshot) => void) => {
+    const listener = (_event: unknown, snapshot: FloatingWidgetSnapshot) => {
+      callback(snapshot)
+    }
+    ipcRenderer.on(FLOATING_BUTTON_EVENTS.SNAPSHOT_UPDATED, listener)
+    return () => ipcRenderer.off(FLOATING_BUTTON_EVENTS.SNAPSHOT_UPDATED, listener)
+  },

Apply the same pattern to the language/theme subscriptions, then mirror the return types in src/renderer/floating/env.d.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/preload/floating-preload.ts` around lines 91 - 120, Change each listener
helper (onSnapshotUpdate, onLanguageChanged, onThemeChanged, onConfigUpdate) to
return a per-listener cleanup/unsubscribe function instead of relying on
removeAllListeners; attach the ipcRenderer handler as you do now but also return
() => ipcRenderer.removeListener(CHANNEL, handler) so callers can dispose
individual subscriptions. Keep removeAllListeners for global purge if needed but
stop using it as the only cleanup path. Update the corresponding type signatures
in src/renderer/floating/env.d.ts to reflect the new return type (a () => void
unsubscribe) for each onXxx method so the renderer code can call the returned
function to remove only that handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/presenter/deepchatAgentPresenter/index.ts`:
- Around line 2551-2554: The try/catch around the fire-and-forget call to
presenter.floatingButtonPresenter.refreshWidgetState() won't catch asynchronous
rejections; change to explicitly handle the returned promise by either awaiting
the call inside an async function or appending .catch(...) to the promise so
rejections are handled (e.g.,
presenter.floatingButtonPresenter.refreshWidgetState().catch(err => process
warning)). Update the same pattern in newAgentPresenter where
refreshWidgetState() is invoked to ensure all async rejections are caught and
logged rather than leaking unhandled promise rejections; leave
setSessionStatus() unchanged as it is synchronous.

In `@src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts`:
- Around line 73-79: The webPreferences in FloatingButtonWindow.ts currently
unconditionally set webSecurity and sandbox to false; update the webSecurity and
sandbox entries to be gated by the existing isDev flag (same pattern used for
devTools) so that in development they are false but in production they remain
enabled; locate the webPreferences object (in FloatingButtonWindow) and change
webSecurity and sandbox to use isDev ? false : true (or equivalent) so
production builds do not disable same-origin policy or the renderer sandbox.

In `@src/main/presenter/floatingButtonPresenter/index.ts`:
- Around line 107-115: The presenter currently only checks for the wrapper
object (this.floatingWindow) so enable()/createFloatingWindow() short-circuit
when the wrapper exists but its internal BrowserWindow was cleared; update the
guards in enable() and the other early-return sites to verify the wrapper
actually hosts a live BrowserWindow before returning (e.g., check a getter like
this.floatingWindow.getBrowserWindow() and/or call isDestroyed() on that
BrowserWindow or add/use a wrapper method like
this.floatingWindow.hasWindow()/isAlive()); if the internal window is missing or
destroyed, call this.createFloatingWindow() to recreate it instead of returning
early.
- Around line 193-300: The presenter now duplicates app-level IPC registration
inside registerIpcHandlers; move all FLOATING_BUTTON_EVENTS registrations (all
ipcMain.handle and ipcMain.on calls for SNAPSHOT_REQUEST, LANGUAGE_REQUEST,
THEME_REQUEST, CLICKED, RIGHT_CLICKED, TOGGLE_EXPANDED, SET_EXPANDED,
OPEN_SESSION, DRAG_START, DRAG_MOVE, DRAG_END) out of registerIpcHandlers and
into the centralized eventbus.ts so app-event lifecycle is single-sourced; in
eventbus.ts register handlers that call the presenter's methods (e.g.,
refreshWidgetState, resolveTheme, toggleExpanded, showContextMenu, setExpanded,
openSession, and the drag handlers that use floatingWindow and DragRuntimeState)
and ensure destroy() only removes listeners that were registered by this
presenter (or rely on eventbus.ts to remove them), avoiding duplicate teardown
logic.
- Around line 166-177: refreshTheme currently awaits resolveTheme() then sends
to buttonWindow; if resolveTheme rejects or the window is destroyed after the
await this can create an unhandled rejection — wrap the async send path in a
try/catch, call await this.resolveTheme() inside the try, recheck
this.floatingWindow?.exists() and buttonWindow && !buttonWindow.isDestroyed()
before calling
buttonWindow.webContents.send(FLOATING_BUTTON_EVENTS.THEME_CHANGED, ...), and
handle/log errors (or swallow) inside the catch so no rejection escapes
refreshTheme; reference methods: refreshTheme, floatingWindow, getWindow,
resolveTheme, and FLOATING_BUTTON_EVENTS.THEME_CHANGED.
- Around line 208-213: The handler for FLOATING_BUTTON_EVENTS.SNAPSHOT_REQUEST
treats sessions.length === 0 as “not loaded” and re-triggers refreshWidgetState
even when there are legitimately zero sessions; add an explicit bootstrap flag
(e.g., this.snapshot.initialized or this._hasBootstrapped) that is set to true
when refreshWidgetState (and/or loadDeepChatSessions) finishes first-time
loading, change the IPC handler to check that flag (and this.snapshot.expanded)
instead of sessions.length to decide whether to call refreshWidgetState, and
optionally guard refreshWidgetState with an in-flight boolean to avoid stacking
concurrent refreshes; update references in the handler and in
refreshWidgetState/loadDeepChatSessions to set and check the new flag.

In `@src/main/presenter/floatingButtonPresenter/layout.ts`:
- Around line 150-164: snapWidgetBoundsToEdge can produce an X that positions
the widget partially off-screen when bounds.width > workArea.width; after
computing x (using inferDockSide), clamp it so it never goes left of workArea.x
and never beyond the maximum allowed X (workArea.x + max(0, workArea.width -
bounds.width)), i.e. replace the raw right-docked expression with a clamped
value, then Math.round the clamped x before returning; reference
snapWidgetBoundsToEdge, inferDockSide, and clampWidgetY.

In `@src/renderer/floating/FloatingButton.vue`:
- Around line 198-225: The floating launcher is currently an unfocusable div
using `@mousedown/`@contextmenu so keyboard users can't open it; update the
template to separate the drag surface from the activation control by turning the
interactive toggle into a real focusable element (e.g., a <button> or div with
tabindex="0") and wire its keyboard handlers to the same activation logic as the
mouse (handleMouseDown/handleRightClick or the underlying toggle method that
flips snapshot.expanded), add keydown handling for Enter and Space to call the
toggle/activation, ensure ARIA role/label (aria-pressed or aria-expanded)
reflects snapshot.expanded, and keep the drag-specific handlers (isDragging
logic) on the separate drag container so keyboard users can focus and activate
the launcher without interfering with dragging.
- Around line 809-820: The reduced-motion media query in FloatingButton.vue
omits the animated classes, so elements like busy-orbit-ring and live-chip-dot
keep animating; update the `@media` (prefers-reduced-motion: reduce) rule to
include .busy-orbit-ring and .live-chip-dot (and any other animated classes such
as .logo-orb-image if needed) and override their animations by setting
animation: none !important and transition: none !important (or keep
transition-duration: 0/1ms and transition-delay: 0ms) to fully disable
spinning/pulsing for functions/components that render those classes.

In `@src/renderer/floating/main.ts`:
- Around line 10-17: The widget currently initializes i18n with a hardcoded
'zh-CN' and floatingTheme to 'dark', causing a flash of wrong locale/theme;
change the bootstrap so you read the persisted user locale and theme before
creating i18n and before creating the floatingTheme ref (e.g., call your
IPC/getSettings helpers synchronously or await an initial settings fetch), then
pass the retrieved locale into createI18n and use it to seed floatingTheme
(ref<'dark'|'light'>(userTheme)), and apply the same pre-mount initialization
pattern for the other initialization block around the code referenced at lines
47-68 to avoid the transient wrong state.

---

Nitpick comments:
In `@src/preload/floating-preload.ts`:
- Around line 91-120: Change each listener helper (onSnapshotUpdate,
onLanguageChanged, onThemeChanged, onConfigUpdate) to return a per-listener
cleanup/unsubscribe function instead of relying on removeAllListeners; attach
the ipcRenderer handler as you do now but also return () =>
ipcRenderer.removeListener(CHANNEL, handler) so callers can dispose individual
subscriptions. Keep removeAllListeners for global purge if needed but stop using
it as the only cleanup path. Update the corresponding type signatures in
src/renderer/floating/env.d.ts to reflect the new return type (a () => void
unsubscribe) for each onXxx method so the renderer code can call the returned
function to remove only that handler.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9370c8e6-9157-4f08-9cef-72deef620f29

📥 Commits

Reviewing files that changed from the base of the PR and between ac62fe6 and 289d922.

📒 Files selected for processing (34)
  • docs/specs/floating-agent-widget/plan.md
  • docs/specs/floating-agent-widget/spec.md
  • docs/specs/floating-agent-widget/tasks.md
  • src/main/events.ts
  • src/main/presenter/configPresenter/index.ts
  • src/main/presenter/deepchatAgentPresenter/index.ts
  • src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts
  • src/main/presenter/floatingButtonPresenter/index.ts
  • src/main/presenter/floatingButtonPresenter/layout.ts
  • src/main/presenter/floatingButtonPresenter/types.ts
  • src/main/presenter/newAgentPresenter/index.ts
  • src/preload/floating-preload.ts
  • src/renderer/floating/FloatingButton.vue
  • src/renderer/floating/components/FloatingSessionItem.vue
  • src/renderer/floating/env.d.ts
  • src/renderer/floating/main.ts
  • src/renderer/src/events.ts
  • src/renderer/src/i18n/da-DK/chat.json
  • src/renderer/src/i18n/en-US/chat.json
  • src/renderer/src/i18n/fa-IR/chat.json
  • src/renderer/src/i18n/fr-FR/chat.json
  • src/renderer/src/i18n/he-IL/chat.json
  • src/renderer/src/i18n/ja-JP/chat.json
  • src/renderer/src/i18n/ko-KR/chat.json
  • src/renderer/src/i18n/pt-BR/chat.json
  • src/renderer/src/i18n/ru-RU/chat.json
  • src/renderer/src/i18n/zh-CN/chat.json
  • src/renderer/src/i18n/zh-HK/chat.json
  • src/renderer/src/i18n/zh-TW/chat.json
  • src/renderer/src/stores/ui/session.ts
  • src/shared/featureFlags.ts
  • src/shared/types/floating-widget.ts
  • test/main/presenter/floatingButtonPresenter/layout.test.ts
  • test/renderer/stores/sessionStore.test.ts

Comment on lines +2551 to +2554
try {
void presenter.floatingButtonPresenter.refreshWidgetState()
} catch (error) {
console.warn('[DeepChatAgent] Failed to refresh floating widget state:', error)
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n --type=ts -C3 'async\s+refreshWidgetState\s*\(|refreshWidgetState\s*\([^)]*\)\s*:\s*Promise<'
rg -n --type=ts -C3 'void\s+presenter\.floatingButtonPresenter\.refreshWidgetState\(\)'
rg -n --type=ts -C2 '\brefreshWidgetState\s*\('

Repository: ThinkInAIXYZ/deepchat

Length of output: 4612


🏁 Script executed:

sed -n '2520,2560p' src/main/presenter/deepchatAgentPresenter/index.ts

Repository: ThinkInAIXYZ/deepchat

Length of output: 1379


Catch async refresh failures explicitly.

Line 2552 dispatches refreshWidgetState() as fire-and-forget inside try/catch; if it rejects asynchronously, this catch won't run and can leak an unhandled rejection. Since setSessionStatus() is not async, you must wrap the promise handling explicitly.

🔧 Proposed fix
-    try {
-      void presenter.floatingButtonPresenter.refreshWidgetState()
-    } catch (error) {
-      console.warn('[DeepChatAgent] Failed to refresh floating widget state:', error)
-    }
+    presenter.floatingButtonPresenter.refreshWidgetState().catch(error => {
+      console.warn('[DeepChatAgent] Failed to refresh floating widget state:', error)
+    })

This same pattern exists at src/main/presenter/newAgentPresenter/index.ts:910 and should be fixed identically.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
void presenter.floatingButtonPresenter.refreshWidgetState()
} catch (error) {
console.warn('[DeepChatAgent] Failed to refresh floating widget state:', error)
presenter.floatingButtonPresenter.refreshWidgetState().catch(error => {
console.warn('[DeepChatAgent] Failed to refresh floating widget state:', error)
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/presenter/deepchatAgentPresenter/index.ts` around lines 2551 - 2554,
The try/catch around the fire-and-forget call to
presenter.floatingButtonPresenter.refreshWidgetState() won't catch asynchronous
rejections; change to explicitly handle the returned promise by either awaiting
the call inside an async function or appending .catch(...) to the promise so
rejections are handled (e.g.,
presenter.floatingButtonPresenter.refreshWidgetState().catch(err => process
warning)). Update the same pattern in newAgentPresenter where
refreshWidgetState() is invoked to ensure all async rejections are caught and
logged rather than leaking unhandled promise rejections; leave
setSessionStatus() unchanged as it is synchronous.

Comment on lines 107 to 115
if (this.floatingWindow) {
console.log('FloatingButton window already exists, showing it')
this.floatingWindow.show()
return // 已经存在窗口,只需显示
await this.refreshWidgetState()
this.refreshLanguage()
await this.refreshTheme()
return
}

console.log('Creating new floating button window')
await this.createFloatingWindow()
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.

⚠️ Potential issue | 🟠 Major

Recreate the window when only the wrapper instance survives.

FloatingButtonWindow clears its internal BrowserWindow on 'closed', but the presenter keeps the wrapper object alive. After that, Line 107 and Line 182 both short-circuit on object presence, so enable()/createFloatingWindow() never build a new window and the widget can't recover from an unexpected close or renderer crash.

🔧 Suggested guard for stale wrapper instances
 public async enable(): Promise<void> {
   if (!FLOATING_BUTTON_AVAILABLE) {
     this.destroy()
     console.log('FloatingButton is temporarily unavailable, skipping enable')
     return
   }

   this.config.enabled = true

-  if (this.floatingWindow) {
+  if (this.floatingWindow?.exists()) {
     this.floatingWindow.show()
     await this.refreshWidgetState()
     this.refreshLanguage()
     await this.refreshTheme()
     return
   }

+  this.floatingWindow = null
   await this.createFloatingWindow()
 }

 private async createFloatingWindow(): Promise<void> {
   this.registerIpcHandlers()

-  if (!this.floatingWindow) {
+  if (!this.floatingWindow?.exists()) {
+    this.floatingWindow?.destroy()
     this.floatingWindow = new FloatingButtonWindow(this.config)
     await this.floatingWindow.create()
   }

   this.floatingWindow.show()

Also applies to: 179-191

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/presenter/floatingButtonPresenter/index.ts` around lines 107 - 115,
The presenter currently only checks for the wrapper object (this.floatingWindow)
so enable()/createFloatingWindow() short-circuit when the wrapper exists but its
internal BrowserWindow was cleared; update the guards in enable() and the other
early-return sites to verify the wrapper actually hosts a live BrowserWindow
before returning (e.g., check a getter like
this.floatingWindow.getBrowserWindow() and/or call isDestroyed() on that
BrowserWindow or add/use a wrapper method like
this.floatingWindow.hasWindow()/isAlive()); if the internal window is missing or
destroyed, call this.createFloatingWindow() to recreate it instead of returning
early.

Comment on lines +166 to +177
public async refreshTheme(): Promise<void> {
if (!this.floatingWindow?.exists()) {
return
}

const buttonWindow = this.floatingWindow.getWindow()
if (!buttonWindow || buttonWindow.isDestroyed()) {
return
}

buttonWindow.webContents.send(FLOATING_BUTTON_EVENTS.THEME_CHANGED, await this.resolveTheme())
}
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.

⚠️ Potential issue | 🟠 Major

Catch async failures inside refreshTheme().

src/main/presenter/configPresenter/index.ts calls this via void presenter.floatingButtonPresenter.refreshTheme(), so anything that rejects after the await on Line 176 bypasses the surrounding try/catch. A theme lookup failure or a window being destroyed mid-await turns into an unhandled rejection unless this method catches internally and rechecks the window before sending.

🔧 Suggested guard around the async send path
 public async refreshTheme(): Promise<void> {
-  if (!this.floatingWindow?.exists()) {
-    return
-  }
-
-  const buttonWindow = this.floatingWindow.getWindow()
-  if (!buttonWindow || buttonWindow.isDestroyed()) {
-    return
-  }
-
-  buttonWindow.webContents.send(FLOATING_BUTTON_EVENTS.THEME_CHANGED, await this.resolveTheme())
+  try {
+    const theme = await this.resolveTheme()
+    const buttonWindow = this.floatingWindow?.getWindow()
+    if (!buttonWindow || buttonWindow.isDestroyed()) {
+      return
+    }
+
+    buttonWindow.webContents.send(FLOATING_BUTTON_EVENTS.THEME_CHANGED, theme)
+  } catch (error) {
+    console.error('Failed to refresh floating widget theme:', error)
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/presenter/floatingButtonPresenter/index.ts` around lines 166 - 177,
refreshTheme currently awaits resolveTheme() then sends to buttonWindow; if
resolveTheme rejects or the window is destroyed after the await this can create
an unhandled rejection — wrap the async send path in a try/catch, call await
this.resolveTheme() inside the try, recheck this.floatingWindow?.exists() and
buttonWindow && !buttonWindow.isDestroyed() before calling
buttonWindow.webContents.send(FLOATING_BUTTON_EVENTS.THEME_CHANGED, ...), and
handle/log errors (or swallow) inside the catch so no rejection escapes
refreshTheme; reference methods: refreshTheme, floatingWindow, getWindow,
resolveTheme, and FLOATING_BUTTON_EVENTS.THEME_CHANGED.

Comment on lines +193 to +300
private registerIpcHandlers(): void {
ipcMain.removeHandler(FLOATING_BUTTON_EVENTS.SNAPSHOT_REQUEST)
ipcMain.removeHandler(FLOATING_BUTTON_EVENTS.LANGUAGE_REQUEST)
ipcMain.removeHandler(FLOATING_BUTTON_EVENTS.THEME_REQUEST)
ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.CLICKED)
ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.RIGHT_CLICKED)
ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.TOGGLE_EXPANDED)
ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.SET_EXPANDED)
ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.OPEN_SESSION)
ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.DRAG_START)
ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.DRAG_MOVE)
ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.DRAG_END)

let isDuringDragSession = false
let dragState: DragRuntimeState | null = null

// 处理点击事件
ipcMain.on(FLOATING_BUTTON_EVENTS.CLICKED, async () => {
if (isDuringDragSession) {
return
}
try {
let floatingButtonPosition: { x: number; y: number; width: number; height: number } | null =
null
if (this.floatingWindow && this.floatingWindow.exists()) {
const buttonWindow = this.floatingWindow.getWindow()
if (buttonWindow && !buttonWindow.isDestroyed()) {
const bounds = buttonWindow.getBounds()
floatingButtonPosition = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height
}
}
}
if (floatingButtonPosition) {
await presenter.windowPresenter.toggleFloatingChatWindow(floatingButtonPosition)
} else {
await presenter.windowPresenter.toggleFloatingChatWindow()
}
} catch (error) {
console.error('Failed to handle floating button click:', error)
ipcMain.handle(FLOATING_BUTTON_EVENTS.SNAPSHOT_REQUEST, async () => {
if (this.snapshot.sessions.length === 0 && !this.snapshot.expanded) {
await this.refreshWidgetState()
}
return this.snapshot
})

ipcMain.handle(FLOATING_BUTTON_EVENTS.LANGUAGE_REQUEST, async () => {
return this.configPresenter.getLanguage()
})

ipcMain.handle(FLOATING_BUTTON_EVENTS.THEME_REQUEST, async () => {
return await this.resolveTheme()
})

ipcMain.on(FLOATING_BUTTON_EVENTS.CLICKED, () => {
this.toggleExpanded()
})

ipcMain.on(FLOATING_BUTTON_EVENTS.RIGHT_CLICKED, () => {
try {
this.showContextMenu()
} catch (error) {
console.error('Failed to handle floating button right click:', error)
}
this.showContextMenu()
})

// 处理拖拽事件
let wasFloatingChatVisibleBeforeDrag = false // 记录拖拽前浮窗是否可见
let dragState: {
isDragging: boolean
startX: number
startY: number
windowX: number
windowY: number
} | null = null
ipcMain.on(FLOATING_BUTTON_EVENTS.TOGGLE_EXPANDED, () => {
this.toggleExpanded()
})

ipcMain.on(FLOATING_BUTTON_EVENTS.SET_EXPANDED, (_event, expanded: boolean) => {
this.setExpanded(Boolean(expanded))
})

ipcMain.on(FLOATING_BUTTON_EVENTS.OPEN_SESSION, (_event, sessionId: string) => {
void this.openSession(sessionId)
})

ipcMain.on(FLOATING_BUTTON_EVENTS.DRAG_START, (_event, { x, y }: { x: number; y: number }) => {
isDuringDragSession = true
try {
if (this.floatingWindow && this.floatingWindow.exists()) {
const buttonWindow = this.floatingWindow.getWindow()
if (buttonWindow && !buttonWindow.isDestroyed()) {
const bounds = buttonWindow.getBounds()

// 检查浮窗是否可见,如果可见则隐藏
const floatingChatWindow = presenter.windowPresenter.getFloatingChatWindow()
wasFloatingChatVisibleBeforeDrag = floatingChatWindow?.isShowing() || false

if (wasFloatingChatVisibleBeforeDrag) {
floatingChatWindow?.hide()
console.log('FloatingChatWindow hidden during drag start')
}

dragState = {
isDragging: true,
startX: x,
startY: y,
windowX: bounds.x,
windowY: bounds.y
}
}
}
} catch (error) {
console.error('Failed to handle drag start:', error)
if (!this.floatingWindow?.exists()) {
return
}

const bounds = this.floatingWindow.getBounds()
if (!bounds) {
return
}

dragState = {
startX: x,
startY: y,
windowX: bounds.x,
windowY: bounds.y
}
})

ipcMain.on(FLOATING_BUTTON_EVENTS.DRAG_MOVE, (_event, { x, y }: { x: number; y: number }) => {
try {
if (
dragState &&
dragState.isDragging &&
this.floatingWindow &&
this.floatingWindow.exists()
) {
const buttonWindow = this.floatingWindow.getWindow()
if (buttonWindow && !buttonWindow.isDestroyed()) {
const deltaX = x - dragState.startX
const deltaY = y - dragState.startY
const newX = dragState.windowX + deltaX
const newY = dragState.windowY + deltaY

buttonWindow.setBounds({
x: newX,
y: newY,
width: this.config.size.width,
height: this.config.size.height
})
}
}
} catch (error) {
console.error('Failed to handle drag move:', error)
if (!dragState || !this.floatingWindow?.exists()) {
return
}
})

ipcMain.on(FLOATING_BUTTON_EVENTS.DRAG_END, (_event, _data: { x: number; y: number }) => {
try {
if (
dragState &&
dragState.isDragging &&
this.floatingWindow &&
this.floatingWindow.exists()
) {
const buttonWindow = this.floatingWindow.getWindow()
if (buttonWindow && !buttonWindow.isDestroyed()) {
// 多显示器边界检查
const bounds = buttonWindow.getBounds()
const currentDisplay = screen.getDisplayMatching(bounds)
const { workArea } = currentDisplay

// 确保悬浮球完全在当前显示器的工作区内
const targetX = Math.max(
workArea.x,
Math.min(bounds.x, workArea.x + workArea.width - bounds.width)
)
const targetY = Math.max(
workArea.y,
Math.min(bounds.y, workArea.y + workArea.height - bounds.height)
)

// 只有在越界时才调整位置
if (targetX !== bounds.x || targetY !== bounds.y) {
buttonWindow.setPosition(targetX, targetY)
}
}
}
const bounds = this.floatingWindow.getBounds()
if (!bounds) {
return
}

// 如果拖拽前浮窗是可见的,拖拽结束后重新显示
if (wasFloatingChatVisibleBeforeDrag) {
const floatingChatWindow = presenter.windowPresenter.getFloatingChatWindow()
if (floatingChatWindow) {
// 获取悬浮球当前位置,用于计算浮窗显示位置
let floatingButtonPosition:
| { x: number; y: number; width: number; height: number }
| undefined = undefined
if (this.floatingWindow && this.floatingWindow.exists()) {
const buttonWindow = this.floatingWindow.getWindow()
if (buttonWindow && !buttonWindow.isDestroyed()) {
const bounds = buttonWindow.getBounds()
floatingButtonPosition = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height
}
}
}

floatingChatWindow.show(floatingButtonPosition)
console.log('FloatingChatWindow shown after drag end')
}
}
const deltaX = x - dragState.startX
const deltaY = y - dragState.startY

this.floatingWindow.setBounds({
x: dragState.windowX + deltaX,
y: dragState.windowY + deltaY,
width: bounds.width,
height: bounds.height
})
})

// 重置拖拽状态
ipcMain.on(FLOATING_BUTTON_EVENTS.DRAG_END, () => {
if (!dragState || !this.floatingWindow?.exists()) {
dragState = null
wasFloatingChatVisibleBeforeDrag = false
} catch (error) {
console.error('Failed to handle drag end:', error)
} finally {
isDuringDragSession = false
return
}

const bounds = this.floatingWindow.getBounds()
if (!bounds) {
dragState = null
return
}

const currentDisplay = screen.getDisplayMatching(bounds)
const snapped = snapWidgetBoundsToEdge(bounds, currentDisplay.workArea)
this.floatingWindow.setDockSide(snapped.dockSide)
this.floatingWindow.setBounds(snapped)
dragState = null
})
}
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.

🛠️ Refactor suggestion | 🟠 Major

Move these app IPC registrations back to eventbus.ts.

This PR adds a second app-event registry inside the presenter, so listener registration and teardown are now split between registerIpcHandlers() and destroy() instead of the repo's centralized eventbus path.

As per coding guidelines, "maintain centralized event handling in eventbus.ts for app events".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/presenter/floatingButtonPresenter/index.ts` around lines 193 - 300,
The presenter now duplicates app-level IPC registration inside
registerIpcHandlers; move all FLOATING_BUTTON_EVENTS registrations (all
ipcMain.handle and ipcMain.on calls for SNAPSHOT_REQUEST, LANGUAGE_REQUEST,
THEME_REQUEST, CLICKED, RIGHT_CLICKED, TOGGLE_EXPANDED, SET_EXPANDED,
OPEN_SESSION, DRAG_START, DRAG_MOVE, DRAG_END) out of registerIpcHandlers and
into the centralized eventbus.ts so app-event lifecycle is single-sourced; in
eventbus.ts register handlers that call the presenter's methods (e.g.,
refreshWidgetState, resolveTheme, toggleExpanded, showContextMenu, setExpanded,
openSession, and the drag handlers that use floatingWindow and DragRuntimeState)
and ensure destroy() only removes listeners that were registered by this
presenter (or rely on eventbus.ts to remove them), avoiding duplicate teardown
logic.

Comment on lines +208 to +213
ipcMain.handle(FLOATING_BUTTON_EVENTS.SNAPSHOT_REQUEST, async () => {
if (this.snapshot.sessions.length === 0 && !this.snapshot.expanded) {
await this.refreshWidgetState()
}
return this.snapshot
})
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.

⚠️ Potential issue | 🟡 Minor

Don't treat a real empty snapshot as stale.

Line 209 uses sessions.length === 0 as the "not loaded yet" signal. If DeepChat genuinely has no sessions, every SNAPSHOT_REQUEST re-runs loadDeepChatSessions() and can stack concurrent refreshes on repeated requests.

🔧 Suggested explicit bootstrap flag
 private snapshot: FloatingWidgetSnapshot = { ...EMPTY_SNAPSHOT }
+private hasLoadedSnapshot = false

 public destroy(): void {
   this.config.enabled = false
   this.snapshot = { ...EMPTY_SNAPSHOT }
+  this.hasLoadedSnapshot = false
   this.stopLayoutAnimation()
   ...
 }

 public async refreshWidgetState(): Promise<void> {
   try {
     const sessions = await this.loadDeepChatSessions()
     this.snapshot = buildFloatingWidgetSnapshot(sessions, this.snapshot.expanded)
+    this.hasLoadedSnapshot = true
     this.applyWindowLayout()
     this.pushSnapshotToRenderer()
   } catch (error) {
     console.error('Failed to refresh floating widget state:', error)
   }
 }

 ipcMain.handle(FLOATING_BUTTON_EVENTS.SNAPSHOT_REQUEST, async () => {
-  if (this.snapshot.sessions.length === 0 && !this.snapshot.expanded) {
+  if (!this.hasLoadedSnapshot) {
     await this.refreshWidgetState()
   }
   return this.snapshot
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/presenter/floatingButtonPresenter/index.ts` around lines 208 - 213,
The handler for FLOATING_BUTTON_EVENTS.SNAPSHOT_REQUEST treats sessions.length
=== 0 as “not loaded” and re-triggers refreshWidgetState even when there are
legitimately zero sessions; add an explicit bootstrap flag (e.g.,
this.snapshot.initialized or this._hasBootstrapped) that is set to true when
refreshWidgetState (and/or loadDeepChatSessions) finishes first-time loading,
change the IPC handler to check that flag (and this.snapshot.expanded) instead
of sessions.length to decide whether to call refreshWidgetState, and optionally
guard refreshWidgetState with an in-flight boolean to avoid stacking concurrent
refreshes; update references in the handler and in
refreshWidgetState/loadDeepChatSessions to set and check the new flag.

Comment on lines +150 to +164
export function snapWidgetBoundsToEdge(
bounds: WidgetRect,
workArea: WidgetRect
): WidgetRect & { dockSide: FloatingWidgetDockSide } {
const dockSide = inferDockSide(bounds, workArea)
const x = dockSide === 'left' ? workArea.x : workArea.x + workArea.width - bounds.width
const y = clampWidgetY(bounds.y, bounds.height, workArea)

return {
x: Math.round(x),
y: Math.round(y),
width: bounds.width,
height: bounds.height,
dockSide
}
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.

⚠️ Potential issue | 🟠 Major

Clamp snapped X before returning off-screen bounds.

The right-docked branch returns workArea.x + workArea.width - bounds.width directly. When the widget is wider than the work area, that becomes < workArea.x, so the window snaps partially off-screen.

Proposed clamp
 export function snapWidgetBoundsToEdge(
   bounds: WidgetRect,
   workArea: WidgetRect
 ): WidgetRect & { dockSide: FloatingWidgetDockSide } {
   const dockSide = inferDockSide(bounds, workArea)
-  const x = dockSide === 'left' ? workArea.x : workArea.x + workArea.width - bounds.width
+  const snappedX =
+    dockSide === 'left' ? workArea.x : workArea.x + workArea.width - bounds.width
+  const x = Math.max(
+    workArea.x,
+    Math.min(snappedX, workArea.x + workArea.width - bounds.width)
+  )
   const y = clampWidgetY(bounds.y, bounds.height, workArea)
 
   return {
     x: Math.round(x),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function snapWidgetBoundsToEdge(
bounds: WidgetRect,
workArea: WidgetRect
): WidgetRect & { dockSide: FloatingWidgetDockSide } {
const dockSide = inferDockSide(bounds, workArea)
const x = dockSide === 'left' ? workArea.x : workArea.x + workArea.width - bounds.width
const y = clampWidgetY(bounds.y, bounds.height, workArea)
return {
x: Math.round(x),
y: Math.round(y),
width: bounds.width,
height: bounds.height,
dockSide
}
export function snapWidgetBoundsToEdge(
bounds: WidgetRect,
workArea: WidgetRect
): WidgetRect & { dockSide: FloatingWidgetDockSide } {
const dockSide = inferDockSide(bounds, workArea)
const snappedX =
dockSide === 'left' ? workArea.x : workArea.x + workArea.width - bounds.width
const x = Math.max(
workArea.x,
Math.min(snappedX, workArea.x + workArea.width - bounds.width)
)
const y = clampWidgetY(bounds.y, bounds.height, workArea)
return {
x: Math.round(x),
y: Math.round(y),
width: bounds.width,
height: bounds.height,
dockSide
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/presenter/floatingButtonPresenter/layout.ts` around lines 150 - 164,
snapWidgetBoundsToEdge can produce an X that positions the widget partially
off-screen when bounds.width > workArea.width; after computing x (using
inferDockSide), clamp it so it never goes left of workArea.x and never beyond
the maximum allowed X (workArea.x + max(0, workArea.width - bounds.width)), i.e.
replace the raw right-docked expression with a clamped value, then Math.round
the clamped x before returning; reference snapWidgetBoundsToEdge, inferDockSide,
and clampWidgetY.

Comment on lines +198 to +225
<div
class="widget-stage h-screen w-screen overflow-hidden bg-transparent"
:data-theme="props.theme"
:class="{ dark: props.theme === 'dark' }"
>
<div
class="relative h-full w-full select-none"
:class="[
snapshot.expanded ? 'cursor-grab' : 'cursor-pointer',
isDragging ? 'cursor-grabbing' : ''
]"
@mousedown="handleMouseDown"
@contextmenu="handleRightClick"
>
<div class="relative h-full w-full overflow-hidden rounded-[var(--widget-radius)]">
<div
class="collapsed-layer absolute inset-0 flex h-full w-full items-center justify-center overflow-hidden"
:class="[
snapshot.expanded
? 'collapsed-layer-hidden pointer-events-none'
: 'pointer-events-auto',
{ 'floating-shell-dragging': isDragging }
]"
>
<div
class="logo-orb logo-orb-hero relative isolate flex h-full w-full items-center justify-center overflow-hidden rounded-full"
:class="hasActiveTasks ? 'status-orb-busy' : 'status-orb-idle'"
>
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.

⚠️ Potential issue | 🟠 Major

Expose a keyboard-operable launcher.

The primary toggle surface is a mouse-only <div> with mousedown/contextmenu, so keyboard users cannot focus or open the floating widget at all. Please split the drag surface from a real button, or add proper focusability and Enter/Space handling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/floating/FloatingButton.vue` around lines 198 - 225, The
floating launcher is currently an unfocusable div using `@mousedown/`@contextmenu
so keyboard users can't open it; update the template to separate the drag
surface from the activation control by turning the interactive toggle into a
real focusable element (e.g., a <button> or div with tabindex="0") and wire its
keyboard handlers to the same activation logic as the mouse
(handleMouseDown/handleRightClick or the underlying toggle method that flips
snapshot.expanded), add keydown handling for Enter and Space to call the
toggle/activation, ensure ARIA role/label (aria-pressed or aria-expanded)
reflects snapshot.expanded, and keep the drag-specific handlers (isDragging
logic) on the separate drag container so keyboard users can focus and activate
the launcher without interfering with dragging.

Comment on lines +809 to +820
@media (prefers-reduced-motion: reduce) {
.collapsed-layer,
.floating-shell,
.panel-header,
.panel-list,
.session-row,
.logo-orb-hero,
.logo-orb-image {
transition-duration: 1ms !important;
transition-delay: 0ms !important;
animation-duration: 1ms !important;
}
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.

⚠️ Potential issue | 🟡 Minor

The reduced-motion block doesn't stop the infinite animations.

busy-orbit-ring and live-chip-dot keep spinning/pulsing even when prefers-reduced-motion: reduce is set, because the media query never targets them.

Proposed reduced-motion fix
 `@media` (prefers-reduced-motion: reduce) {
   .collapsed-layer,
   .floating-shell,
   .panel-header,
   .panel-list,
   .session-row,
   .logo-orb-hero,
-  .logo-orb-image {
+  .logo-orb-image,
+  .busy-orbit-ring,
+  .live-chip-dot {
     transition-duration: 1ms !important;
     transition-delay: 0ms !important;
-    animation-duration: 1ms !important;
+    animation: none !important;
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@media (prefers-reduced-motion: reduce) {
.collapsed-layer,
.floating-shell,
.panel-header,
.panel-list,
.session-row,
.logo-orb-hero,
.logo-orb-image {
transition-duration: 1ms !important;
transition-delay: 0ms !important;
animation-duration: 1ms !important;
}
`@media` (prefers-reduced-motion: reduce) {
.collapsed-layer,
.floating-shell,
.panel-header,
.panel-list,
.session-row,
.logo-orb-hero,
.logo-orb-image,
.busy-orbit-ring,
.live-chip-dot {
transition-duration: 1ms !important;
transition-delay: 0ms !important;
animation: none !important;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/floating/FloatingButton.vue` around lines 809 - 820, The
reduced-motion media query in FloatingButton.vue omits the animated classes, so
elements like busy-orbit-ring and live-chip-dot keep animating; update the
`@media` (prefers-reduced-motion: reduce) rule to include .busy-orbit-ring and
.live-chip-dot (and any other animated classes such as .logo-orb-image if
needed) and override their animations by setting animation: none !important and
transition: none !important (or keep transition-duration: 0/1ms and
transition-delay: 0ms) to fully disable spinning/pulsing for
functions/components that render those classes.

Comment on lines +10 to +17
const i18n = createI18n({
locale: 'zh-CN',
fallbackLocale: 'en-US',
legacy: false,
messages: locales
})

const floatingTheme = ref<'dark' | 'light'>('dark')
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.

⚠️ Potential issue | 🟡 Minor

Load locale and theme before the first mount.

The widget mounts with hardcoded zh-CN and dark, so users on any other settings will briefly see the wrong language/theme before the IPC calls resolve.

Proposed bootstrap ordering
-const app = createApp(Root)
-
-app.use(i18n)
-app.mount('#app')
-
-void window.floatingButtonAPI
-  .getLanguage()
-  .then(applyLanguage)
-  .catch((error) => {
-    console.warn('Failed to initialize floating widget language:', error)
-  })
-
-window.floatingButtonAPI.onLanguageChanged(applyLanguage)
-
-void window.floatingButtonAPI
-  .getTheme()
-  .then(applyTheme)
-  .catch((error) => {
-    console.warn('Failed to initialize floating widget theme:', error)
-  })
-
-window.floatingButtonAPI.onThemeChanged(applyTheme)
+const bootstrap = async () => {
+  const [languageResult, themeResult] = await Promise.allSettled([
+    window.floatingButtonAPI.getLanguage(),
+    window.floatingButtonAPI.getTheme()
+  ])
+
+  if (languageResult.status === 'fulfilled') {
+    applyLanguage(languageResult.value)
+  } else {
+    console.warn('Failed to initialize floating widget language:', languageResult.reason)
+  }
+
+  if (themeResult.status === 'fulfilled') {
+    applyTheme(themeResult.value)
+  } else {
+    console.warn('Failed to initialize floating widget theme:', themeResult.reason)
+  }
+
+  const app = createApp(Root)
+  app.use(i18n)
+  app.mount('#app')
+
+  window.floatingButtonAPI.onLanguageChanged(applyLanguage)
+  window.floatingButtonAPI.onThemeChanged(applyTheme)
+}
+
+void bootstrap()

Also applies to: 47-68

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/floating/main.ts` around lines 10 - 17, The widget currently
initializes i18n with a hardcoded 'zh-CN' and floatingTheme to 'dark', causing a
flash of wrong locale/theme; change the bootstrap so you read the persisted user
locale and theme before creating i18n and before creating the floatingTheme ref
(e.g., call your IPC/getSettings helpers synchronously or await an initial
settings fetch), then pass the retrieved locale into createI18n and use it to
seed floatingTheme (ref<'dark'|'light'>(userTheme)), and apply the same
pre-mount initialization pattern for the other initialization block around the
code referenced at lines 47-68 to avoid the transient wrong state.

@zerob13 zerob13 merged commit b2b591f into dev Mar 17, 2026
2 checks passed
@zhangmo8 zhangmo8 deleted the feat-floating-window branch March 17, 2026 12:42
zerob13 pushed a commit that referenced this pull request Mar 17, 2026
zerob13 added a commit that referenced this pull request Mar 17, 2026
* feat(agent): streamline RTK tooling

* fix(rtk): add precise rewrite fallback

* fix(runtime): normalize rtk command lookup

* feat: floating window (#1355)

* fix(rtk): use read for health smoke test

---------

Co-authored-by: xiaomo <wegi866@gmail.com>
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