Conversation
📝 WalkthroughWalkthroughThis 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
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)
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
📝 Coding Plan
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. Comment 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 |
There was a problem hiding this comment.
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.vuecannot dispose its snapshot subscription without also removing the language/theme subscriptions registered bysrc/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
📒 Files selected for processing (34)
docs/specs/floating-agent-widget/plan.mddocs/specs/floating-agent-widget/spec.mddocs/specs/floating-agent-widget/tasks.mdsrc/main/events.tssrc/main/presenter/configPresenter/index.tssrc/main/presenter/deepchatAgentPresenter/index.tssrc/main/presenter/floatingButtonPresenter/FloatingButtonWindow.tssrc/main/presenter/floatingButtonPresenter/index.tssrc/main/presenter/floatingButtonPresenter/layout.tssrc/main/presenter/floatingButtonPresenter/types.tssrc/main/presenter/newAgentPresenter/index.tssrc/preload/floating-preload.tssrc/renderer/floating/FloatingButton.vuesrc/renderer/floating/components/FloatingSessionItem.vuesrc/renderer/floating/env.d.tssrc/renderer/floating/main.tssrc/renderer/src/events.tssrc/renderer/src/i18n/da-DK/chat.jsonsrc/renderer/src/i18n/en-US/chat.jsonsrc/renderer/src/i18n/fa-IR/chat.jsonsrc/renderer/src/i18n/fr-FR/chat.jsonsrc/renderer/src/i18n/he-IL/chat.jsonsrc/renderer/src/i18n/ja-JP/chat.jsonsrc/renderer/src/i18n/ko-KR/chat.jsonsrc/renderer/src/i18n/pt-BR/chat.jsonsrc/renderer/src/i18n/ru-RU/chat.jsonsrc/renderer/src/i18n/zh-CN/chat.jsonsrc/renderer/src/i18n/zh-HK/chat.jsonsrc/renderer/src/i18n/zh-TW/chat.jsonsrc/renderer/src/stores/ui/session.tssrc/shared/featureFlags.tssrc/shared/types/floating-widget.tstest/main/presenter/floatingButtonPresenter/layout.test.tstest/renderer/stores/sessionStore.test.ts
| try { | ||
| void presenter.floatingButtonPresenter.refreshWidgetState() | ||
| } catch (error) { | ||
| console.warn('[DeepChatAgent] Failed to refresh floating widget state:', error) |
There was a problem hiding this comment.
🧩 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.tsRepository: 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.
| 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.
| 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() |
There was a problem hiding this comment.
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.
| 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()) | ||
| } |
There was a problem hiding this comment.
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.
| 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 | ||
| }) | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| ipcMain.handle(FLOATING_BUTTON_EVENTS.SNAPSHOT_REQUEST, async () => { | ||
| if (this.snapshot.sessions.length === 0 && !this.snapshot.expanded) { | ||
| await this.refreshWidgetState() | ||
| } | ||
| return this.snapshot | ||
| }) |
There was a problem hiding this comment.
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.
| 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 | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| <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'" | ||
| > |
There was a problem hiding this comment.
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.
| @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; | ||
| } |
There was a problem hiding this comment.
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.
| @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.
| const i18n = createI18n({ | ||
| locale: 'zh-CN', | ||
| fallbackLocale: 'en-US', | ||
| legacy: false, | ||
| messages: locales | ||
| }) | ||
|
|
||
| const floatingTheme = ref<'dark' | 'light'>('dark') |
There was a problem hiding this comment.
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.
* 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>
20260317_171326.mp4
Summary by CodeRabbit
Release Notes