Windows: main window left permanently always-on-top after the office-addin focus_desktop bridge runs

Status Closed — not planned
Maintainer reply None cached
Activity 2 comments · opened Sep 10, 2026 · closed Sep 13, 2026

Windows: main window is left permanently always-on-top after the office-addin focus_desktop bridge runs

Summary

On Windows, the Claude desktop window intermittently becomes permanently
always-on-top and floats over every other application. There is no user-facing
setting or shortcut to turn it off, and nothing in the UI indicates the state — so
from the user's side it looks like an unfindable setting they toggled by accident.

The cause is a raise-to-front trick in the office-addin bridge that pins the window,
focuses it, and unpins it on a 100 ms timer. If that timer does not run, the
WS_EX_TOPMOST flag is left set for the lifetime of the window.

Environment

  • Claude desktop app 1.49585.0
  • Windows 11 Enterprise, build 10.0.26100 (x64)
  • Reproduced 2026-09-09. Heavy Office automation use (the relevant trigger).

Impact

Low severity, high annoyance, and effectively undiagnosable by a normal user:

  • The window covers every other application, including full-screen apps.
  • There is no setting, menu item, or shortcut to clear it (see "What was ruled out").
  • The state is not persisted, so it survives until the app is restarted — but users

have no way to know a restart is the fix, because nothing tells them what changed.

Root cause

resources/app.asar, office-addin bridge, focus_desktop handler. Reformatted from
the minified bundle, with the surrounding branch for context:

// t.bP is the main BrowserWindow
if (process.platform === "win32") {
  t.bP.setAlwaysOnTop(true);
  t.bP.focus();
  t.yP?.webContents.focus();
  setTimeout(() => {
    if (t.bP && !t.bP.isDestroyed()) t.bP.setAlwaysOnTop(false);
  }, 100);
} else {
  t.bP.focus();
  t.yP?.webContents.focus();
}

The pin is the durable state and the unpin is the fragile one — the flag stays set if
the timer never fires. Ways that happens:

  1. Re-entrancy. A second focus_desktop inside the 100 ms window pins again; the

first timer unpins, the second still pins — but any ordering where a pin lands
after the last unpin leaves it on. There is no pin counter or in-flight guard.

  1. Timer starvation. A busy main process can push the callback well past 100 ms,

widening the race.

  1. Any throw between the two calls abandons the pending unpin.

Confirming this is the only reachable path: the main window is constructed with
alwaysOnTop:!1, and this is the only site in the bundle that sets it true on the
main window. Every other alwaysOnTop:!0 in app.asar belongs to a deliberately
floating auxiliary window (quick-entry, the transparent overlays) or to session
popouts, which expose it as real persisted state ({paneTileId, view, title,
alwaysOnTop, bounds}
in session-popouts.json).

Reproduction

The race is timing-dependent, so the practical repro is the state itself. To
reproduce the stuck state directly:

# Any Electron path that leaves the flag set is equivalent to:
#   win.setAlwaysOnTop(true)   with the paired unpin never running

To observe it after it happens in the wild — this reads the real Win32 ex-style, so
it distinguishes a genuine topmost flag from the app merely stealing focus:

Add-Type -TypeDefinition 'using System;using System.Runtime.InteropServices;
public class W{[DllImport("user32.dll")]public static extern int GetWindowLong(IntPtr h,int i);}'
Get-Process claude | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object {
  $ex = [W]::GetWindowLong($_.MainWindowHandle, -20)   # GWL_EXSTYLE
  '{0}  exstyle=0x{1:X8}  topmost={2}' -f $_.MainWindowTitle, $ex, (($ex -band 0x8) -ne 0)
}

Observed while stuck: Claude exstyle=0x00280108 topmost=True
Expected / after restart: Claude exstyle=0x00280100 topmost=False

Bit 0x8 is WS_EX_TOPMOST.

What was ruled out

Worth stating, because these are the answers a user will be given first and all three
are wrong:

  • Not a user setting. No always-on-top key is persisted in %APPDATA%\Claude\

— not config.json, window-state.json, Preferences, or Local Storage.

  • Not a mis-keyed shortcut. The app's complete accelerator list (31 entries,

extracted from app.asar) contains no on-top toggle, and there is no menu item or
UI affordance for it. en-US.json has no matching label.

  • Not PowerToys. Its Always on Top module (Win+Ctrl+T) is the usual suspect

for this symptom on Windows; it was not installed on the affected machine. No other
window on the desktop carried WS_EX_TOPMOST.

Suggested fix

Make the unpin unconditional rather than timer-dependent. Any of:

  1. Reference-count the pin, and unpin only when the count returns to zero — fixes

the re-entrancy case directly.

  1. Unpin on the window's own focus event instead of a fixed delay, with the

timer only as a fallback. The pin exists to win the foreground race; once focus is
actually held it has served its purpose.

  1. try/finally around the pin so a throw cannot strand it.
  2. Assert on the way in: if win.isAlwaysOnTop() is already true at the start of

focus_desktop and no popout has legitimately requested it, clear it first. This
is a cheap self-healing backstop regardless of which fix above is chosen.

Consider also whether the pin is needed at all — Electron's
win.moveTop() / app.focus({steal:true}) may be sufficient on modern Windows, and
focus({steal:true}) is already being called on the line above.

Workaround

Restart the app, or clear the flag in place without losing the session:

Add-Type -TypeDefinition 'using System;using System.Runtime.InteropServices;
public class T{[DllImport("user32.dll")]public static extern bool SetWindowPos(
IntPtr h,IntPtr a,int x,int y,int cx,int cy,uint f);}'
Get-Process claude | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object {
  # HWND_NOTOPMOST(-2), SWP_NOSIZE|SWP_NOMOVE|SWP_NOACTIVATE(0x13)
  [T]::SetWindowPos($_.MainWindowHandle, [IntPtr](-2), 0,0,0,0, 0x13)
}

Position, size, focus and the running session are all preserved.

---

*If this belongs on the desktop-app tracker rather than this repo, happy to move it —
please redirect.*

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗