Skip to content

feat: floating agent panel — auto-detach on window blur#69

Merged
debuglebowski merged 1 commit intodebuglebowski:mainfrom
robertsinke:feat/floating-agent-panel
Apr 13, 2026
Merged

feat: floating agent panel — auto-detach on window blur#69
debuglebowski merged 1 commit intodebuglebowski:mainfrom
robertsinke:feat/floating-agent-panel

Conversation

@robertsinke
Copy link
Copy Markdown
Contributor

@robertsinke robertsinke commented Apr 11, 2026

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.win on the PTY session to route events to whichever BrowserWindow is active, with a dynamic getWin() lookup so the onData/onExit closures read the current window instead of the one captured at creation time.

Test plan

  • Open agent panel, switch to another app — collapsed mini-terminal appears bottom-right, always on top
  • Click the widget body — expands to full terminal with animation, terminal fades in
  • Click minimize button (em-dash) in expanded header — collapses back
  • Press Cmd+. while floating window visible — toggles collapse/expand
  • Status dot: green when agent is waiting for input, amber pulsing when working
  • Type in expanded floating terminal — input reaches the PTY, agent responds
  • Click back on SlayZone — floating window disappears, agent panel in sidebar shows up-to-date conversation
  • Move cursor to different display — widget repositions within ~500ms
  • Drag the collapsed widget header — window moves freely
  • Drag the expanded panel header — window moves freely

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 is redirectSessionWindow in pty-manager.ts — it swaps session.win at runtime so onData/onExit closures route IPC events to whichever window is active. Buffer replay on reattach uses existing seq-based deduplication in PtyContext to 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

Filename Overview
packages/apps/app/src/main/floating-agent.ts New module: implements floating always-on-top BrowserWindow, IPC detach/reattach handlers, display-follow interval, and global shortcut toggle. Minor issue: show() called outside the isDestroyed() guard.
packages/domains/terminal/src/main/pty-manager.ts Adds redirectSessionWindow export and getWin() dynamic lookup for onData/onExit. The setInterval inside the first onData invocation still uses the stale win closure for pty:session-detected notifications after a redirect.
packages/apps/app/src/renderer/src/App.tsx Adds blur/focus effect for auto-detach/reattach. floatingDetachedRef is not reset when agentSessionId changes, which can orphan the floating session on project switch.
packages/apps/app/src/renderer/src/components/agent-panel/FloatingAgentPanel.tsx Floating renderer root: fetches session/config on mount, subscribes to collapse-changed and state events, renders collapsed widget or expanded terminal. Clean and straightforward.
packages/apps/app/src/renderer/src/main.tsx Minimal renderer bootstrap for the floating window (PtyProvider + ThemeProvider only), correctly skipping telemetry, Convex, and tab-store hydration.
packages/domains/terminal/src/client/Terminal.tsx Adds onResizeNeeded handler to re-fit and send updated dimensions after reattach. Clean addition with no issues.
packages/apps/app/src/renderer/src/components/agent-panel/FloatingAgentCollapsed.tsx New collapsed widget component with status dot, drag handle header, and click-to-expand body. Well-structured, no issues.
packages/apps/app/src/renderer/src/components/agent-panel/FloatingAgentCollapsedIcon.tsx Icon-style collapsed variant. Simple and correct.
packages/shared/types/src/api.ts Adds floatingAgent typed API surface to ElectronAPI. Types are complete and consistent with the IPC handlers.
packages/apps/app/src/preload/index.ts Exposes floatingAgent IPC bridge. All channels are properly sandboxed via contextBridge, listeners return unsub functions. Correct.
packages/apps/app/src/main/index.ts Wires up attachFloatingAgentBlurHandlers on the main window and calls setupFloatingAgent with the shortcuts override getter. Clean integration.
packages/domains/terminal/src/main/index.ts Exports redirectSessionWindow and getBufferSince for 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 = MainWindow
Loading

Comments Outside Diff (1)

  1. packages/domains/terminal/src/main/pty-manager.ts, line 879-912 (link)

    P2 Stale win closure in session-detection timer

    The win captured from getWin() when firstOutputTs === null (first data chunk) is closed over inside the setInterval callback. After redirectSessionWindow redirects 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 to SESSION_ID_AUTO_DETECT_DELAY_MS × 10 ms), pty:session-detected is sent to the main window instead of the floating window, breaking conversation-ID tracking for the floating terminal.

    Or more simply, replace win with getWin() at the call site inside the interval callback.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/domains/terminal/src/main/pty-manager.ts
    Line: 879-912
    
    Comment:
    **Stale `win` closure in session-detection timer**
    
    The `win` captured from `getWin()` when `firstOutputTs === null` (first data chunk) is closed over inside the `setInterval` callback. After `redirectSessionWindow` redirects 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 to `SESSION_ID_AUTO_DETECT_DELAY_MS × 10` ms), `pty:session-detected` is sent to the main window instead of the floating window, breaking conversation-ID tracking for the floating terminal.
    
    Or more simply, replace `win` with `getWin()` at the call site inside the interval callback.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/domains/terminal/src/main/pty-manager.ts
Line: 879-912

Comment:
**Stale `win` closure in session-detection timer**

The `win` captured from `getWin()` when `firstOutputTs === null` (first data chunk) is closed over inside the `setInterval` callback. After `redirectSessionWindow` redirects 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 to `SESSION_ID_AUTO_DETECT_DELAY_MS × 10` ms), `pty:session-detected` is sent to the main window instead of the floating window, breaking conversation-ID tracking for the floating terminal.

Or more simply, replace `win` with `getWin()` at the call site inside the interval callback.

How can I resolve this? If you propose a fix, please make it concise.

---

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.

---

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.

Reviews (1): Last reviewed commit: "feat: floating agent panel — auto-detach..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

@robertsinke robertsinke force-pushed the feat/floating-agent-panel branch 2 times, most recently from c2b2ce2 to a71d4a8 Compare April 11, 2026 12:06
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>
@robertsinke robertsinke force-pushed the feat/floating-agent-panel branch from a71d4a8 to 31a1847 Compare April 11, 2026 12:13
Comment on lines +224 to +229
if (!floatingAgentWindow.isDestroyed()) {
floatingAgentWindow.webContents.send('floating-agent:session-changed')
floatingAgentWindow.webContents.send('floating-agent:collapse-changed', true)
}
floatingAgentWindow.show()
startDisplayFollow()
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.

P2 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.

Suggested change
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.

Comment on lines +403 to +419
// 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])
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.

P2 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.

Suggested change
// 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.

@debuglebowski
Copy link
Copy Markdown
Owner

Love it 🤩 Should be pretty straight forward.

@debuglebowski debuglebowski merged commit f56b013 into debuglebowski:main Apr 13, 2026
1 check passed
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