Proof Portal

Project overview

Omarchy

ProbeLabs65 findings · 95 requirements

A proof layer — requirements, tests and verified fixes — for two of Omarchy's subsystems: the application menu (launcher scripts, QML model, JSONC config, search and selection) and the lock screen (lock scripts, QML, PAM authentication). Scope is deliberately limited to those components of omacom/omarchy; the rest of the distribution is not covered.

Inbox
ReportR-691GitHub issuePublicDecided· Out of scope

[omarchy.workspaces] Bar keeps stale workspace numbers after a workspace renumber (changeworkspaceid is ignored)

Reported by aicoder2048 · filed Sep 11, 2026 · open at source

Decided: Out of scope — decided by agents · verified 3 days ago.

At a glance

1 · What is claimed?
Only the report text below — no validator has restated it as checkable claims yet.
2 · What does it affect?
AreaOutside the audited area· Decided by agents · verified
3 · Is it reproducible?
No validation has been run yet.
4 · What remains unclear?
  • Not validated yet: no claims, no reproducer.
5 · What happens next?
Decided by agents and verified by a second, independent agent — see the decision.

Original report

[omarchy.workspaces] Bar keeps stale workspace numbers after a workspace renumber (changeworkspaceid is ignored)revision 1 · captured 2026-09-28 21:23 UTC via poll

System details

  • Omarchy 4.0.2-1, Hyprland 0.56.2 (efb50993780079460b0cbed1363e2166a2de1d9f), Quickshell 0.3.1 (quickshell 0.3.1-1), single output eDP-1, laptop.
  • shell/plugins/bar/widgets/Workspaces.qml on quattro is byte-identical to the shipped 4.0.2 file, so this is not a stale-checkout problem.
  • Happy to attach omarchy-debug --no-sudo --print if the versions above are not enough (it contains machine identifiers, so I left it out of a public thread by default).

What's wrong?

Hyprland 0.56 can renumber an existing workspace with hl.dsp.workspace.change_id (changeworkspaceid on socket2). The workspace keeps its windows, layout and monitor; only its id/name change. This is the only way to genuinely swap two workspaces — without it you have to move every window by hand, which is exactly what #7727's omarchy-hyprland-workspace-compact does.

After any change_id, Hyprland's own state is correct but the bar is not:

  • the workspace keeps its old number in the bar,
  • the "current workspace" marker stays on the old square,
  • and it only corrects itself when some other workspace event arrives (switching workspace, a trackpad swipe, opening/closing a workspace).

Steps to reproduce

# workspace 1 is focused and exists; id 9 must be free
hyprctl eval 'hl.dispatch(hl.dsp.workspace.change_id({ workspace = "1", id = 9 }))'

hyprctl -j activeworkspace | jq .id   # -> 9     (Hyprland is right)
hyprctl -j workspaces | jq '[.[].id]' # -> [9, …] (1 is gone)
# the bar still highlights 1, and 9 never appears

The real-world shape is the three-step swap, which is what a "swap workspace N with M" binding has to do:

-- focused workspace <-> workspace N, keeping windows/layout in place
hl.dispatch(hl.dsp.workspace.change_id({ workspace = cur_id, id = tmp }))  -- tmp is a free id
hl.dispatch(hl.dsp.workspace.change_id({ workspace = target, id = cur_id }))
hl.dispatch(hl.dsp.workspace.change_id({ workspace = tmp,    id = target }))

Independently reported (with video) on the Hyprland forum — same symptom in a custom Quickshell config and in ashell:
https://forum.hypr.land/t/hl-dsp-workspace-change-id-not-updating-status-bar/2076

Root cause — it is Quickshell, not the widget

Hyprland.workspaces and Hyprland.focusedWorkspace come from Quickshell's Hyprland IPC client, which has no branch for `changeworkspaceid` in HyprlandIpc::onEvent (src/wayland/hyprland/ipc/connection.cpp). It only forwards the event to QML as Hyprland.rawEvent.

Hyprland.refreshWorkspaces() cannot repair it either:

  • HyprlandWorkspace::updateFromObject() ignores id/name once a workspace exists — literally commented // ID cannot be updated after creation, and // No events we currently handle give a workspace id but not a name. changeworkspaceid is exactly that event.
  • HyprlandIpc::refreshWorkspaces() matches the model by the stale id (find_if(m->bindableId() == id)), so after A(4→5) / B(5→4) it re-applies the new ids to the wrong objects and leaves the mapping inverted.

And it cannot be fixed from QML: HyprlandWorkspace.id / .name are READ … BINDABLE without a QML setter.

Already tracked upstream — open since 2026-07-28, with a proposed fix and an independent confirmation:
[quickshell-mirror/quickshell#929](https://github.com/quickshell-mirror/quickshell/issues/929)
(That issue also documents a second consequence: per-workspace toplevels go stale too, and a bare refreshToplevels() would list moved windows under both workspaces. I left a comment there with the minimal implementation path — that the ID can already be mutated, via the writable bindableId().)

Proposed change in Omarchy (mitigation)

Track the renumber in the widget from Hyprland.rawEvent and use the tracked value for the current-square test. Applied against shell/plugins/bar/widgets/Workspaces.qml on quattro:

   function focusWorkspace(id) {
     if (!root.bar) return
     root.bar.run("hyprctl dispatch " + Util.shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })"))
   }
 
+  // Hyprland's `changeworkspaceid` event (what `hl.dsp.workspace.change_id` emits)
+  // is not part of Quickshell's workspace model, so after a workspace is renumbered
+  // the bar keeps the pre-renumber ids and the pre-renumber current square. Follow
+  // the renumber here and ask Quickshell to re-read the workspace list.
+  // See quickshell-mirror/quickshell#929; drop this once that is fixed.
+  property int renumberedFocus: -1
+
+  readonly property int focusedId: root.renumberedFocus >= 0
+    ? root.renumberedFocus
+    : (Hyprland.focusedWorkspace !== null ? Hyprland.focusedWorkspace.id : -1)
+
+  Connections {
+    target: Hyprland
+
+    function onRawEvent(event) {
+      if (event.name === "changeworkspaceid") {
+        var parts = String(event.data).split(",")   // "old,new"
+        var from = parseInt(parts[0])
+        var to = parseInt(parts[1])
+        // the swap renames the workspace you are on, so follow it across
+        if (!isNaN(from) && !isNaN(to) && root.focusedId === from)
+          root.renumberedFocus = to
+        Hyprland.refreshWorkspaces()
+      } else if (event.name === "workspace") {
+        root.renumberedFocus = -1
+      }
+    }
+  }
+
   readonly property real trailingGap: root.vertical ? 0 : Style.spaceReal(1.5)
@@
-        readonly property bool focused: Hyprland.focusedWorkspace !== null && Hyprland.focusedWorkspace.id === modelData
+        readonly property bool focused: root.focusedId === modelData

Reset on the plain workspace event keeps normal switching authoritative, so the override only lives as long as the renumber needs it.

Verified how

Patched a clone of the widget on 4.0.2, omarchy restart shell, then swapped two populated workspaces and screenshotted the bar: the marker follows 6 → 8 on the swap and back to 6 on the reverse swap, matching hyprctl activeworkspace at each step. (Note for anyone testing: Quickshell user plugins are not hot-reloaded — the shell has to be restarted.)

Limitations of this mitigation (please read before merging)

It fixes the current-workspace marker, which is what every report so far is about. It cannot fix occupancy, because HyprlandWorkspace.id is not writable from QML and the model's id↔object mapping drifts after a renumber. Swapping two occupied workspaces hides this (both squares stay occupied); swapping an occupied workspace with an empty one can render the wrong square as occupied. Full correctness needs the Quickshell fix linked above.

If you would rather not carry a workaround, that is a defensible call — the alternative is to leave it until Quickshell handles changeworkspaceid, and note the limitation in the manual instead.

Alternatives considered

  • Focus another workspace and focus back (the forum workaround): works, but visibly flickers. CMonitor::changeWorkspace early-returns when the target is already the active workspace, so a single re-focus emits no event at all.
  • Move every window between the two workspaces (the approach in #7727): produces normal events, so the bar stays correct — but it loses each workspace's tiling layout, cannot swap a workspace that has no windows, and is much more code. Fine for a "compact" utility, not equivalent to a swap.

Related

  • #7727 — omarchy-hyprland-workspace-compact, which moves windows one by one because of the same gap ("Hyprland has no 'renumber workspace' dispatcher"). Worth revisiting: hl.dsp.workspace.change_id does exist in 0.56, it is only the bar that cannot follow it yet.
  • #9094 and #8804 — both touch this widget (monitor targeting, workspace names); whichever lands first, this patch is a small addition near focusWorkspace().
View raw text

### System details

- Omarchy `4.0.2-1`, Hyprland `0.56.2` (`efb50993780079460b0cbed1363e2166a2de1d9f`), Quickshell `0.3.1` (`quickshell 0.3.1-1`), single output `eDP-1`, laptop.
- `shell/plugins/bar/widgets/Workspaces.qml` on `quattro` is byte-identical to the shipped 4.0.2 file, so this is not a stale-checkout problem.
- Happy to attach `omarchy-debug --no-sudo --print` if the versions above are not enough (it contains machine identifiers, so I left it out of a public thread by default).

### What's wrong?

Hyprland 0.56 can **renumber an existing workspace** with `hl.dsp.workspace.change_id` (`changeworkspaceid` on socket2). The workspace keeps its windows, layout and monitor; only its id/name change. This is the only way to genuinely *swap* two workspaces — without it you have to move every window by hand, which is exactly what #7727's `omarchy-hyprland-workspace-compact` does.

After any `change_id`, Hyprland's own state is correct but the bar is not:

- the workspace keeps its **old number** in the bar,
- the "current workspace" marker stays on the **old** square,
- and it only corrects itself when some other workspace event arrives (switching workspace, a trackpad swipe, opening/closing a workspace).

#### Steps to reproduce

```bash
# workspace 1 is focused and exists; id 9 must be free
hyprctl eval 'hl.dispatch(hl.dsp.workspace.change_id({ workspace = "1", id = 9 }))'

hyprctl -j activeworkspace | jq .id   # -> 9     (Hyprland is right)
hyprctl -j workspaces | jq '[.[].id]' # -> [9, …] (1 is gone)
# the bar still highlights 1, and 9 never appears
```

The real-world shape is the three-step swap, which is what a "swap workspace N with M" binding has to do:

```lua
-- focused workspace <-> workspace N, keeping windows/layout in place
hl.dispatch(hl.dsp.workspace.change_id({ workspace = cur_id, id = tmp }))  -- tmp is a free id
hl.dispatch(hl.dsp.workspace.change_id({ workspace = target, id = cur_id }))
hl.dispatch(hl.dsp.workspace.change_id({ workspace = tmp,    id = target }))
```

Independently reported (with video) on the Hyprland forum — same symptom in a custom Quickshell config **and** in `ashell`:
<https://forum.hypr.land/t/hl-dsp-workspace-change-id-not-updating-status-bar/2076>

#### Root cause — it is Quickshell, not the widget

`Hyprland.workspaces` and `Hyprland.focusedWorkspace` come from Quickshell's Hyprland IPC client, which has **no branch for `changeworkspaceid`** in `HyprlandIpc::onEvent` (`src/wayland/hyprland/ipc/connection.cpp`). It only forwards the event to QML as `Hyprland.rawEvent`.

`Hyprland.refreshWorkspaces()` cannot repair it either:

- `HyprlandWorkspace::updateFromObject()` ignores `id`/`name` once a workspace exists — literally commented `// ID cannot be updated after creation`, and `// No events we currently handle give a workspace id but not a name`. `changeworkspaceid` is exactly that event.
- `HyprlandIpc::refreshWorkspaces()` matches the model by the **stale** id (`find_if(m->bindableId() == id)`), so after `A(4→5)` / `B(5→4)` it re-applies the new ids to the wrong objects and leaves the mapping inverted.

And it cannot be fixed from QML: `HyprlandWorkspace.id` / `.name` are `READ … BINDABLE` without a QML setter.

Already tracked upstream — open since 2026-07-28, with a proposed fix and an independent confirmation:
**[quickshell-mirror/quickshell#929](https://github.com/quickshell-mirror/quickshell/issues/929)**
(That issue also documents a second consequence: per-workspace `toplevels` go stale too, and a bare `refreshToplevels()` would list moved windows under both workspaces. I left a comment there with the minimal implementation path — that the ID *can* already be mutated, via the writable `bindableId()`.)

#### Proposed change in Omarchy (mitigation)

Track the renumber in the widget from `Hyprland.rawEvent` and use the tracked value for the current-square test. Applied against `shell/plugins/bar/widgets/Workspaces.qml` on `quattro`:

```diff
   function focusWorkspace(id) {
     if (!root.bar) return
     root.bar.run("hyprctl dispatch " + Util.shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })"))
   }
 
+  // Hyprland's `changeworkspaceid` event (what `hl.dsp.workspace.change_id` emits)
+  // is not part of Quickshell's workspace model, so after a workspace is renumbered
+  // the bar keeps the pre-renumber ids and the pre-renumber current square. Follow
+  // the renumber here and ask Quickshell to re-read the workspace list.
+  // See quickshell-mirror/quickshell#929; drop this once that is fixed.
+  property int renumberedFocus: -1
+
+  readonly property int focusedId: root.renumberedFocus >= 0
+    ? root.renumberedFocus
+    : (Hyprland.focusedWorkspace !== null ? Hyprland.focusedWorkspace.id : -1)
+
+  Connections {
+    target: Hyprland
+
+    function onRawEvent(event) {
+      if (event.name === "changeworkspaceid") {
+        var parts = String(event.data).split(",")   // "old,new"
+        var from = parseInt(parts[0])
+        var to = parseInt(parts[1])
+        // the swap renames the workspace you are on, so follow it across
+        if (!isNaN(from) && !isNaN(to) && root.focusedId === from)
+          root.renumberedFocus = to
+        Hyprland.refreshWorkspaces()
+      } else if (event.name === "workspace") {
+        root.renumberedFocus = -1
+      }
+    }
+  }
+
   readonly property real trailingGap: root.vertical ? 0 : Style.spaceReal(1.5)
@@
-        readonly property bool focused: Hyprland.focusedWorkspace !== null && Hyprland.focusedWorkspace.id === modelData
+        readonly property bool focused: root.focusedId === modelData
```

Reset on the plain `workspace` event keeps normal switching authoritative, so the override only lives as long as the renumber needs it.

#### Verified how

Patched a clone of the widget on 4.0.2, `omarchy restart shell`, then swapped two populated workspaces and screenshotted the bar: the marker follows `6 → 8` on the swap and back to `6` on the reverse swap, matching `hyprctl activeworkspace` at each step. (Note for anyone testing: Quickshell user plugins are not hot-reloaded — the shell has to be restarted.)

#### Limitations of this mitigation (please read before merging)

It fixes the **current-workspace marker**, which is what every report so far is about. It cannot fix **occupancy**, because `HyprlandWorkspace.id` is not writable from QML and the model's id↔object mapping drifts after a renumber. Swapping two occupied workspaces hides this (both squares stay occupied); swapping an occupied workspace with an empty one can render the wrong square as occupied. Full correctness needs the Quickshell fix linked above.

If you would rather not carry a workaround, that is a defensible call — the alternative is to leave it until Quickshell handles `changeworkspaceid`, and note the limitation in the manual instead.

#### Alternatives considered

- **Focus another workspace and focus back** (the forum workaround): works, but visibly flickers. `CMonitor::changeWorkspace` early-returns when the target is already the active workspace, so a single re-focus emits no event at all.
- **Move every window between the two workspaces** (the approach in #7727): produces normal events, so the bar stays correct — but it loses each workspace's tiling layout, cannot swap a workspace that has no windows, and is much more code. Fine for a "compact" utility, not equivalent to a swap.

#### Related

- #7727 — `omarchy-hyprland-workspace-compact`, which moves windows one by one because of the same gap ("Hyprland has no 'renumber workspace' dispatcher"). Worth revisiting: `hl.dsp.workspace.change_id` does exist in 0.56, it is only the bar that cannot follow it yet.
- #9094 and #8804 — both touch this widget (monitor targeting, workspace names); whichever lands first, this patch is a small addition near `focusWorkspace()`.

Validation

Not checked yet. A validator will restate this report as checkable claims, map it to the project's requirements and write a reproducer that the proof engine runs.

Linked

Not linked to any known issue, other report or change yet.

Decision

Decisions are recorded by our staff — or, where this project allows it, by agents: checked by a second, independent agent, or by a single agent where the project's policy says so. Any decision reached so far is shown below.

  • Out of scopeDecided by agents · verified · 2026-10-09 11:44 UTC