feat: floating agent panel — auto-detach on window blur#69
Conversation
c2b2ce2 to
a71d4a8
Compare
When SlayZone loses focus, the agent panel auto-detaches into a compact always-on-top floating widget. When SlayZone regains focus, it snaps back into the sidebar. Same PTY session throughout, no data loss. - Collapsed mini-terminal (default): status dot + one-line state - Expanded: full terminal at 50% display height - Cmd+. toggles collapse/expand (globalShortcut while detached) - Follows cursor across displays (500ms polling) - Natively draggable in both states - Provider-agnostic (Claude, Codex, Gemini, etc.) Core mechanism: session.win swap on the PTY session routes events to the active BrowserWindow, with a dynamic getWin() lookup so onData/ onExit closures read the current window instead of the one captured at creation time. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
a71d4a8 to
31a1847
Compare
| if (!floatingAgentWindow.isDestroyed()) { | ||
| floatingAgentWindow.webContents.send('floating-agent:session-changed') | ||
| floatingAgentWindow.webContents.send('floating-agent:collapse-changed', true) | ||
| } | ||
| floatingAgentWindow.show() | ||
| startDisplayFollow() |
There was a problem hiding this comment.
show() called outside the isDestroyed() guard
The floatingAgentWindow.show() call falls outside the if (!floatingAgentWindow.isDestroyed()) guard above it. If the window is destroyed between the guard check and show() (e.g., the user closes it via the OS), this throws Error: Object has been destroyed.
| if (!floatingAgentWindow.isDestroyed()) { | |
| floatingAgentWindow.webContents.send('floating-agent:session-changed') | |
| floatingAgentWindow.webContents.send('floating-agent:collapse-changed', true) | |
| } | |
| floatingAgentWindow.show() | |
| startDisplayFollow() | |
| if (!floatingAgentWindow.isDestroyed()) { | |
| floatingAgentWindow.webContents.send('floating-agent:session-changed') | |
| floatingAgentWindow.webContents.send('floating-agent:collapse-changed', true) | |
| floatingAgentWindow.show() | |
| } | |
| startDisplayFollow() | |
| registerFloatingShortcut() |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/apps/app/src/main/floating-agent.ts
Line: 224-229
Comment:
**`show()` called outside the `isDestroyed()` guard**
The `floatingAgentWindow.show()` call falls outside the `if (!floatingAgentWindow.isDestroyed())` guard above it. If the window is destroyed between the guard check and `show()` (e.g., the user closes it via the OS), this throws `Error: Object has been destroyed`.
```suggestion
if (!floatingAgentWindow.isDestroyed()) {
floatingAgentWindow.webContents.send('floating-agent:session-changed')
floatingAgentWindow.webContents.send('floating-agent:collapse-changed', true)
floatingAgentWindow.show()
}
startDisplayFollow()
registerFloatingShortcut()
```
How can I resolve this? If you propose a fix, please make it concise.| // Floating agent panel: detach on blur, reattach on focus | ||
| const floatingDetachedRef = useRef(false) | ||
| useEffect(() => { | ||
| const unsubBlur = window.api.floatingAgent.onWindowBlur(() => { | ||
| if (agentPanelState.isOpen && agentSessionId) { | ||
| window.api.floatingAgent.detach(agentSessionId, agentPanelState.panelWidth) | ||
| floatingDetachedRef.current = true | ||
| } | ||
| }) | ||
| const unsubFocus = window.api.floatingAgent.onWindowFocus(() => { | ||
| if (floatingDetachedRef.current && agentSessionId) { | ||
| window.api.floatingAgent.reattach(agentSessionId) | ||
| floatingDetachedRef.current = false | ||
| } | ||
| }) | ||
| return () => { unsubBlur(); unsubFocus() } | ||
| }, [agentPanelState.isOpen, agentPanelState.panelWidth, agentSessionId]) |
There was a problem hiding this comment.
floatingDetachedRef not reset when agentSessionId changes
If the user switches projects while the floating window is active, agentSessionId changes, the effect re-registers, but floatingDetachedRef.current remains true. The new focus listener then calls reattach(newSessionId). Because the main process has floatingDetached = true (set by the old detach), reattach proceeds: it redirects the new session ID to the main window (which may not exist), clears floatingDetached, and hides the floating window. The old session's session.win is left pointing to the now-hidden floating window, orphaning it — future output from that session is routed to a hidden webContents and never shown.
| // Floating agent panel: detach on blur, reattach on focus | |
| const floatingDetachedRef = useRef(false) | |
| useEffect(() => { | |
| const unsubBlur = window.api.floatingAgent.onWindowBlur(() => { | |
| if (agentPanelState.isOpen && agentSessionId) { | |
| window.api.floatingAgent.detach(agentSessionId, agentPanelState.panelWidth) | |
| floatingDetachedRef.current = true | |
| } | |
| }) | |
| const unsubFocus = window.api.floatingAgent.onWindowFocus(() => { | |
| if (floatingDetachedRef.current && agentSessionId) { | |
| window.api.floatingAgent.reattach(agentSessionId) | |
| floatingDetachedRef.current = false | |
| } | |
| }) | |
| return () => { unsubBlur(); unsubFocus() } | |
| }, [agentPanelState.isOpen, agentPanelState.panelWidth, agentSessionId]) | |
| const floatingDetachedRef = useRef(false) | |
| useEffect(() => { | |
| floatingDetachedRef.current = false | |
| const unsubBlur = window.api.floatingAgent.onWindowBlur(() => { |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/apps/app/src/renderer/src/App.tsx
Line: 403-419
Comment:
**`floatingDetachedRef` not reset when `agentSessionId` changes**
If the user switches projects while the floating window is active, `agentSessionId` changes, the effect re-registers, but `floatingDetachedRef.current` remains `true`. The new focus listener then calls `reattach(newSessionId)`. Because the main process has `floatingDetached = true` (set by the old detach), reattach proceeds: it redirects the new session ID to the main window (which may not exist), clears `floatingDetached`, and hides the floating window. The old session's `session.win` is left pointing to the now-hidden floating window, orphaning it — future output from that session is routed to a hidden webContents and never shown.
```suggestion
const floatingDetachedRef = useRef(false)
useEffect(() => {
floatingDetachedRef.current = false
const unsubBlur = window.api.floatingAgent.onWindowBlur(() => {
```
How can I resolve this? If you propose a fix, please make it concise.|
Love it 🤩 Should be pretty straight forward. |
Cool project you've been working on, I really like it — especially the recent addition of the persistent agent sidepanel makes it much easier to manage my other terminals/tasks.
I use the agent panel as a kind of ambient orchestrator — it dispatches tasks, monitors other terminals, has context on what I'm working on. The problem is I do a lot of work outside of SlayZone (browser, other terminals, different screens), and switching back to the SlayZone window just to talk to the agent breaks flow.
This PR makes the agent panel auto-detach into a small always-on-top floating widget when SlayZone loses focus. When you click back into SlayZone, it snaps back into the sidebar. Same PTY session throughout, no data loss.
It defaults to a compact collapsed state (~138x52px) showing a status dot and one-line state like "waiting for input." Click it to expand into a full terminal at half display height. Cmd+. toggles between collapsed and expanded. It follows your cursor across displays, is draggable in both states, and works with whatever provider the agent panel is running.
The core trick is swapping
session.winon the PTY session to route events to whichever BrowserWindow is active, with a dynamicgetWin()lookup so theonData/onExitclosures read the current window instead of the one captured at creation time.Test plan
Greptile Summary
This PR adds auto-detach behavior to the agent side panel: when the main window loses focus, the panel's PTY session is redirected to a small always-on-top floating
BrowserWindow(collapsed widget or icon), and snapped back on re-focus. The core mechanism isredirectSessionWindowinpty-manager.ts— it swapssession.winat runtime soonData/onExitclosures route IPC events to whichever window is active. Buffer replay on reattach uses existingseq-based deduplication inPtyContextto apply only the output produced while floating, which is correct. All three P2 findings are edge-case timing issues in the routing layer; the happy path is solid.Confidence Score: 4/5
Safe to merge for the main use case; three P2 issues in edge-case timing and project-switch paths should be addressed before this ships broadly.
All three findings are P2: the stale win in the session-detection timer, the missing isDestroyed() guard on show(), and the un-reset floatingDetachedRef on project switch. None affect the primary detach/reattach flow, but they can cause orphaned sessions or missed conversation-ID tracking in narrow timing windows.
pty-manager.ts (stale win in setInterval), floating-agent.ts (show() guard), App.tsx (ref not reset on session ID change)
Important Files Changed
show()called outside theisDestroyed()guard.redirectSessionWindowexport andgetWin()dynamic lookup for onData/onExit. The setInterval inside the first onData invocation still uses the stalewinclosure forpty:session-detectednotifications after a redirect.floatingDetachedRefis not reset whenagentSessionIdchanges, which can orphan the floating session on project switch.onResizeNeededhandler to re-fit and send updated dimensions after reattach. Clean addition with no issues.floatingAgenttyped API surface to ElectronAPI. Types are complete and consistent with the IPC handlers.floatingAgentIPC bridge. All channels are properly sandboxed via contextBridge, listeners return unsub functions. Correct.attachFloatingAgentBlurHandlerson the main window and callssetupFloatingAgentwith the shortcuts override getter. Clean integration.redirectSessionWindowandgetBufferSincefor use by the floating agent module. No issues.Sequence Diagram
sequenceDiagram participant Main as MainWindow participant App as App.tsx (renderer) participant FMain as floating-agent.ts participant PTY as pty-manager.ts participant Float as FloatingWindow Main->>App: window blur (80ms debounce) App->>FMain: IPC floating-agent:detach(sessionId) FMain->>PTY: redirectSessionWindow(sessionId, FloatingWindow) FMain->>Float: show() + session-changed IPC Note over PTY: session.win = FloatingWindow PTY-->>Float: pty:data / pty:state-change (live) Float-->>App: (hidden, no events) Main->>App: window focus App->>FMain: IPC floating-agent:reattach(sessionId) FMain->>PTY: redirectSessionWindow(sessionId, MainWindow) FMain->>Main: replay getBufferSince(sessionId, -1) Note over Main: PtyContext deduplicates via lastSeq FMain->>Main: pty:resize-needed FMain->>Float: hide() Note over PTY: session.win = MainWindowComments Outside Diff (1)
packages/domains/terminal/src/main/pty-manager.ts, line 879-912 (link)winclosure in session-detection timerThe
wincaptured fromgetWin()whenfirstOutputTs === null(first data chunk) is closed over inside thesetIntervalcallback. AfterredirectSessionWindowredirects the session to the floating window,getWin()returns the floating window for new data events — but this timer still holds the original window reference. If the user detaches during the agent startup phase (before detection completes, which can take up toSESSION_ID_AUTO_DETECT_DELAY_MS × 10ms),pty:session-detectedis sent to the main window instead of the floating window, breaking conversation-ID tracking for the floating terminal.Or more simply, replace
winwithgetWin()at the call site inside the interval callback.Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "feat: floating agent panel — auto-detach..." | Re-trigger Greptile
(2/5) Greptile learns from your feedback when you react with thumbs up/down!