Skip to content

feat: per-client active workspace views (review fixes) - #7

Open
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2
Open

feat: per-client active workspace views (review fixes)#7
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2

Conversation

@Castrozan

Copy link
Copy Markdown
Owner

Preview of the upstream change against ogulcancelik/herdr, rebuilt on the same upstream-master base (4617456) as #5 so the diff is exactly the feature commit plus the fixes. Supersedes #5.

Makes the active workspace per-client through a ClientView context swap, so each attached terminal holds its own independent workspace view (the tmux model, no shared mirror), and then closes the four bugs the review on discussion #651 found in that swap.

What the review found and what changed

P1 — deferred request_* actions ran against whichever view was loaded. The one-tick-deferred flags live on the shared AppState and are not part of ClientView, so they survived view swaps and executed against whoever was swapped in when the batch drained. Client A clicking "+ new workspace" force-switched client B into the new workspace; a worktree-create submit from A ran against B's view, found worktree_create: None, and dropped silently.

Fixed by harvesting the flags into a DeferredClientRequests bundle immediately after each client's input batch, while that client still owns the loaded view, then replaying each bundle with its own view focused (harvest_deferred_requests_from_client / handle_deferred_requests_headless). Requests raised outside client input (socket API, timers) still run against the loaded view, as before.

P1 — reconciliation missed index-bearing state.reconcile_client_views_with_workspaces re-pointed only the id-based fields. Saved views also carry raw indices restored verbatim: context_menu, workspace_press, tab_press, drag, and mode-carrying dialogs such as ConfirmClose. A menu left open on workspace 2 while another client closed a workspace could close the wrong workspace, or panic the server through the unchecked indexing in public_workspace_id.

Fixed both ways the review suggested: a workspace-tree change now cancels the transient index-bearing state in every saved view and in the loaded one (cancel_workspace_index_state, gated on workspace_tree_changed_since_last_reconcile so the cost is a comparison per swap, not a clear per swap), and the index-driven entry points (focus_workspace_idx_via_api, close_workspace_idx_via_api, move_workspace_via_api) go through a checked workspace_id_at, so a stale index degrades to a no-op instead of a crash.

P2 — per-client transients had global expiry deadlines.copy_feedback and the selection highlight/autoscroll state were saved per client, but their deadlines lived on App. A deadline firing while that client's view was swapped out cleared someone else's state, and the original transient came back on restore with no deadline and stuck.

Fixed by moving the three deadlines into ClientView, snapshotting and restoring them through App::snapshot_client_view / App::restore_client_view. expire_due_client_view_transients focuses each client whose saved deadline is due before expiring it, and the loop deadline now mins in deadlines parked in saved views so a non-loaded client still wakes on time (selection autoscroll ticks at 30ms, well under the 250ms accept poll).

P2 — mouse capture was computed from one view and broadcast to all. With mouse_capture = false, the desired capture state depends on each client's own mode and its focused terminal's mouse reporting. stream_host_mouse_capture_mode now computes the value per client from that client's view (should_capture_host_mouse_in_view) and sends each client its own.

Tests

src/server/headless/tests/client_view.rs gets one test per finding, each verified to fail against the unfixed code:

  • deferred_workspace_create_lands_on_the_requesting_client_only
  • workspace_tree_change_cancels_index_bearing_state_in_saved_views
  • copy_feedback_expires_against_the_client_that_raised_it
  • host_mouse_capture_follows_each_clients_own_view

just check is green: 2591 tests pass, formatting clean. just windows-lint was not run here because it shells out to rustup, which is not in the flake dev shell.

Known limitations, unchanged by this PR

Per the hands-on testing earlier in the discussion: all clients still share one render grid, so differently sized clients clip; ClientView is still ephemeral, so a reattaching client lands on the default workspace; the API exposes no per-client view state; and the done -> idle seen transition is still consumed by whichever client looks first. Those are feature and API-shape decisions rather than defects in the swap, and they need a maintainer call on scope.

Castrozanand others added 2 commits July 30, 2026 11:28
Each attached app client now views its own workspace independently
instead of mirroring one shared foreground view. A ClientView bundle
holds the per-client, workspace-relative state: active and selected
workspace by stable id, mode, view geometry, the navigator, the
interaction singletons (copy mode, selection, context menu, drag,
press tracking), the modal payloads (worktree create/open/remove,
rename target, release notes, product announcement, keybind help),
the name input, and the per-view scrolls and collapse set. The bundle
is saved per ClientConnection and swapped into the single AppState
around each client's render and input boundaries, including the resize
path before a client is promoted to foreground.
Saved views are reconciled against the live workspace set whenever a
view is loaded, so workspace removal falls a client back to its
selected or the first workspace, and clients attached before the first
workspace existed adopt it once created. Server-wide truth (the
workspace tree itself, global settings and palette, terminal
appearance) stays in shared AppState and is not carried per client.
The view swap made the active workspace per-client, but the rest of the
server still assumed one loaded state, so deferred commands, expiry
timers and broadcast decisions landed on whichever view happened to be
swapped in. Four paths are corrected.
Deferred requests are harvested from the client that raised them while
its view is still loaded, then replayed with that view focused. Without
this, one client clicking "+ new workspace" force-switched a different
client into the new workspace, and a worktree-create submit could drop
silently when the handler ran against a view whose dialog payload was
None.
Index-bearing view state is canceled when the workspace tree changes.
Saved views restore context menus, presses and drags verbatim, and those
payloads carry raw indices, so a menu left open while another client
closed a workspace could close the wrong one or index out of bounds.
Workspace id lookups driven by such an index are now checked, so the
worst case is a no-op instead of a panic that takes down every client.
Transient expiry deadlines travel with the view that owns the transient.
copy_feedback and the selection highlight are per-client, but their
deadlines lived on App, so a deadline firing while another view was
loaded cleared the wrong state and left the original stuck with nothing
to expire it. The loop deadline now also accounts for deadlines parked
in saved views.
Host mouse capture is computed per client instead of broadcasting one
value. With mouse_capture disabled, capture depends on each client's own
mode and focused pane, so a client on another workspace was told the
wrong mode and stopped receiving the mouse events it needed.
Sign up for freeto 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.

1 participant

@Castrozan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content

feat: per-client active workspace views (review fixes) - #7

Open
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2
Open

feat: per-client active workspace views (review fixes)#7
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2

Conversation

@Castrozan

Copy link
Copy Markdown
Owner

Preview of the upstream change against ogulcancelik/herdr, rebuilt on the same upstream-master base (4617456) as #5 so the diff is exactly the feature commit plus the fixes. Supersedes #5.

Makes the active workspace per-client through a ClientView context swap, so each attached terminal holds its own independent workspace view (the tmux model, no shared mirror), and then closes the four bugs the review on discussion #651 found in that swap.

What the review found and what changed

P1 — deferred request_* actions ran against whichever view was loaded. The one-tick-deferred flags live on the shared AppState and are not part of ClientView, so they survived view swaps and executed against whoever was swapped in when the batch drained. Client A clicking "+ new workspace" force-switched client B into the new workspace; a worktree-create submit from A ran against B's view, found worktree_create: None, and dropped silently.

Fixed by harvesting the flags into a DeferredClientRequests bundle immediately after each client's input batch, while that client still owns the loaded view, then replaying each bundle with its own view focused (harvest_deferred_requests_from_client / handle_deferred_requests_headless). Requests raised outside client input (socket API, timers) still run against the loaded view, as before.

P1 — reconciliation missed index-bearing state.reconcile_client_views_with_workspaces re-pointed only the id-based fields. Saved views also carry raw indices restored verbatim: context_menu, workspace_press, tab_press, drag, and mode-carrying dialogs such as ConfirmClose. A menu left open on workspace 2 while another client closed a workspace could close the wrong workspace, or panic the server through the unchecked indexing in public_workspace_id.

Fixed both ways the review suggested: a workspace-tree change now cancels the transient index-bearing state in every saved view and in the loaded one (cancel_workspace_index_state, gated on workspace_tree_changed_since_last_reconcile so the cost is a comparison per swap, not a clear per swap), and the index-driven entry points (focus_workspace_idx_via_api, close_workspace_idx_via_api, move_workspace_via_api) go through a checked workspace_id_at, so a stale index degrades to a no-op instead of a crash.

P2 — per-client transients had global expiry deadlines.copy_feedback and the selection highlight/autoscroll state were saved per client, but their deadlines lived on App. A deadline firing while that client's view was swapped out cleared someone else's state, and the original transient came back on restore with no deadline and stuck.

Fixed by moving the three deadlines into ClientView, snapshotting and restoring them through App::snapshot_client_view / App::restore_client_view. expire_due_client_view_transients focuses each client whose saved deadline is due before expiring it, and the loop deadline now mins in deadlines parked in saved views so a non-loaded client still wakes on time (selection autoscroll ticks at 30ms, well under the 250ms accept poll).

P2 — mouse capture was computed from one view and broadcast to all. With mouse_capture = false, the desired capture state depends on each client's own mode and its focused terminal's mouse reporting. stream_host_mouse_capture_mode now computes the value per client from that client's view (should_capture_host_mouse_in_view) and sends each client its own.

Tests

src/server/headless/tests/client_view.rs gets one test per finding, each verified to fail against the unfixed code:

  • deferred_workspace_create_lands_on_the_requesting_client_only
  • workspace_tree_change_cancels_index_bearing_state_in_saved_views
  • copy_feedback_expires_against_the_client_that_raised_it
  • host_mouse_capture_follows_each_clients_own_view

just check is green: 2591 tests pass, formatting clean. just windows-lint was not run here because it shells out to rustup, which is not in the flake dev shell.

Known limitations, unchanged by this PR

Per the hands-on testing earlier in the discussion: all clients still share one render grid, so differently sized clients clip; ClientView is still ephemeral, so a reattaching client lands on the default workspace; the API exposes no per-client view state; and the done -> idle seen transition is still consumed by whichever client looks first. Those are feature and API-shape decisions rather than defects in the swap, and they need a maintainer call on scope.

Castrozanand others added 2 commits July 30, 2026 11:28
Each attached app client now views its own workspace independently
instead of mirroring one shared foreground view. A ClientView bundle
holds the per-client, workspace-relative state: active and selected
workspace by stable id, mode, view geometry, the navigator, the
interaction singletons (copy mode, selection, context menu, drag,
press tracking), the modal payloads (worktree create/open/remove,
rename target, release notes, product announcement, keybind help),
the name input, and the per-view scrolls and collapse set. The bundle
is saved per ClientConnection and swapped into the single AppState
around each client's render and input boundaries, including the resize
path before a client is promoted to foreground.
Saved views are reconciled against the live workspace set whenever a
view is loaded, so workspace removal falls a client back to its
selected or the first workspace, and clients attached before the first
workspace existed adopt it once created. Server-wide truth (the
workspace tree itself, global settings and palette, terminal
appearance) stays in shared AppState and is not carried per client.
The view swap made the active workspace per-client, but the rest of the
server still assumed one loaded state, so deferred commands, expiry
timers and broadcast decisions landed on whichever view happened to be
swapped in. Four paths are corrected.
Deferred requests are harvested from the client that raised them while
its view is still loaded, then replayed with that view focused. Without
this, one client clicking "+ new workspace" force-switched a different
client into the new workspace, and a worktree-create submit could drop
silently when the handler ran against a view whose dialog payload was
None.
Index-bearing view state is canceled when the workspace tree changes.
Saved views restore context menus, presses and drags verbatim, and those
payloads carry raw indices, so a menu left open while another client
closed a workspace could close the wrong one or index out of bounds.
Workspace id lookups driven by such an index are now checked, so the
worst case is a no-op instead of a panic that takes down every client.
Transient expiry deadlines travel with the view that owns the transient.
copy_feedback and the selection highlight are per-client, but their
deadlines lived on App, so a deadline firing while another view was
loaded cleared the wrong state and left the original stuck with nothing
to expire it. The loop deadline now also accounts for deadlines parked
in saved views.
Host mouse capture is computed per client instead of broadcasting one
value. With mouse_capture disabled, capture depends on each client's own
mode and focused pane, so a client on another workspace was told the
wrong mode and stopped receiving the mouse events it needed.
Sign up for freeto 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.

1 participant

@Castrozan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content

feat: per-client active workspace views (review fixes) - #7

Open
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2
Open

feat: per-client active workspace views (review fixes)#7
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2

Conversation

@Castrozan

Copy link
Copy Markdown
Owner

Preview of the upstream change against ogulcancelik/herdr, rebuilt on the same upstream-master base (4617456) as #5 so the diff is exactly the feature commit plus the fixes. Supersedes #5.

Makes the active workspace per-client through a ClientView context swap, so each attached terminal holds its own independent workspace view (the tmux model, no shared mirror), and then closes the four bugs the review on discussion #651 found in that swap.

What the review found and what changed

P1 — deferred request_* actions ran against whichever view was loaded. The one-tick-deferred flags live on the shared AppState and are not part of ClientView, so they survived view swaps and executed against whoever was swapped in when the batch drained. Client A clicking "+ new workspace" force-switched client B into the new workspace; a worktree-create submit from A ran against B's view, found worktree_create: None, and dropped silently.

Fixed by harvesting the flags into a DeferredClientRequests bundle immediately after each client's input batch, while that client still owns the loaded view, then replaying each bundle with its own view focused (harvest_deferred_requests_from_client / handle_deferred_requests_headless). Requests raised outside client input (socket API, timers) still run against the loaded view, as before.

P1 — reconciliation missed index-bearing state.reconcile_client_views_with_workspaces re-pointed only the id-based fields. Saved views also carry raw indices restored verbatim: context_menu, workspace_press, tab_press, drag, and mode-carrying dialogs such as ConfirmClose. A menu left open on workspace 2 while another client closed a workspace could close the wrong workspace, or panic the server through the unchecked indexing in public_workspace_id.

Fixed both ways the review suggested: a workspace-tree change now cancels the transient index-bearing state in every saved view and in the loaded one (cancel_workspace_index_state, gated on workspace_tree_changed_since_last_reconcile so the cost is a comparison per swap, not a clear per swap), and the index-driven entry points (focus_workspace_idx_via_api, close_workspace_idx_via_api, move_workspace_via_api) go through a checked workspace_id_at, so a stale index degrades to a no-op instead of a crash.

P2 — per-client transients had global expiry deadlines.copy_feedback and the selection highlight/autoscroll state were saved per client, but their deadlines lived on App. A deadline firing while that client's view was swapped out cleared someone else's state, and the original transient came back on restore with no deadline and stuck.

Fixed by moving the three deadlines into ClientView, snapshotting and restoring them through App::snapshot_client_view / App::restore_client_view. expire_due_client_view_transients focuses each client whose saved deadline is due before expiring it, and the loop deadline now mins in deadlines parked in saved views so a non-loaded client still wakes on time (selection autoscroll ticks at 30ms, well under the 250ms accept poll).

P2 — mouse capture was computed from one view and broadcast to all. With mouse_capture = false, the desired capture state depends on each client's own mode and its focused terminal's mouse reporting. stream_host_mouse_capture_mode now computes the value per client from that client's view (should_capture_host_mouse_in_view) and sends each client its own.

Tests

src/server/headless/tests/client_view.rs gets one test per finding, each verified to fail against the unfixed code:

  • deferred_workspace_create_lands_on_the_requesting_client_only
  • workspace_tree_change_cancels_index_bearing_state_in_saved_views
  • copy_feedback_expires_against_the_client_that_raised_it
  • host_mouse_capture_follows_each_clients_own_view

just check is green: 2591 tests pass, formatting clean. just windows-lint was not run here because it shells out to rustup, which is not in the flake dev shell.

Known limitations, unchanged by this PR

Per the hands-on testing earlier in the discussion: all clients still share one render grid, so differently sized clients clip; ClientView is still ephemeral, so a reattaching client lands on the default workspace; the API exposes no per-client view state; and the done -> idle seen transition is still consumed by whichever client looks first. Those are feature and API-shape decisions rather than defects in the swap, and they need a maintainer call on scope.

Castrozanand others added 2 commits July 30, 2026 11:28
Each attached app client now views its own workspace independently
instead of mirroring one shared foreground view. A ClientView bundle
holds the per-client, workspace-relative state: active and selected
workspace by stable id, mode, view geometry, the navigator, the
interaction singletons (copy mode, selection, context menu, drag,
press tracking), the modal payloads (worktree create/open/remove,
rename target, release notes, product announcement, keybind help),
the name input, and the per-view scrolls and collapse set. The bundle
is saved per ClientConnection and swapped into the single AppState
around each client's render and input boundaries, including the resize
path before a client is promoted to foreground.
Saved views are reconciled against the live workspace set whenever a
view is loaded, so workspace removal falls a client back to its
selected or the first workspace, and clients attached before the first
workspace existed adopt it once created. Server-wide truth (the
workspace tree itself, global settings and palette, terminal
appearance) stays in shared AppState and is not carried per client.
The view swap made the active workspace per-client, but the rest of the
server still assumed one loaded state, so deferred commands, expiry
timers and broadcast decisions landed on whichever view happened to be
swapped in. Four paths are corrected.
Deferred requests are harvested from the client that raised them while
its view is still loaded, then replayed with that view focused. Without
this, one client clicking "+ new workspace" force-switched a different
client into the new workspace, and a worktree-create submit could drop
silently when the handler ran against a view whose dialog payload was
None.
Index-bearing view state is canceled when the workspace tree changes.
Saved views restore context menus, presses and drags verbatim, and those
payloads carry raw indices, so a menu left open while another client
closed a workspace could close the wrong one or index out of bounds.
Workspace id lookups driven by such an index are now checked, so the
worst case is a no-op instead of a panic that takes down every client.
Transient expiry deadlines travel with the view that owns the transient.
copy_feedback and the selection highlight are per-client, but their
deadlines lived on App, so a deadline firing while another view was
loaded cleared the wrong state and left the original stuck with nothing
to expire it. The loop deadline now also accounts for deadlines parked
in saved views.
Host mouse capture is computed per client instead of broadcasting one
value. With mouse_capture disabled, capture depends on each client's own
mode and focused pane, so a client on another workspace was told the
wrong mode and stopped receiving the mouse events it needed.
Sign up for freeto 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.

1 participant

@Castrozan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content

feat: per-client active workspace views (review fixes) - #7

Open
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2
Open

feat: per-client active workspace views (review fixes)#7
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2

Conversation

@Castrozan

Copy link
Copy Markdown
Owner

Preview of the upstream change against ogulcancelik/herdr, rebuilt on the same upstream-master base (4617456) as #5 so the diff is exactly the feature commit plus the fixes. Supersedes #5.

Makes the active workspace per-client through a ClientView context swap, so each attached terminal holds its own independent workspace view (the tmux model, no shared mirror), and then closes the four bugs the review on discussion #651 found in that swap.

What the review found and what changed

P1 — deferred request_* actions ran against whichever view was loaded. The one-tick-deferred flags live on the shared AppState and are not part of ClientView, so they survived view swaps and executed against whoever was swapped in when the batch drained. Client A clicking "+ new workspace" force-switched client B into the new workspace; a worktree-create submit from A ran against B's view, found worktree_create: None, and dropped silently.

Fixed by harvesting the flags into a DeferredClientRequests bundle immediately after each client's input batch, while that client still owns the loaded view, then replaying each bundle with its own view focused (harvest_deferred_requests_from_client / handle_deferred_requests_headless). Requests raised outside client input (socket API, timers) still run against the loaded view, as before.

P1 — reconciliation missed index-bearing state.reconcile_client_views_with_workspaces re-pointed only the id-based fields. Saved views also carry raw indices restored verbatim: context_menu, workspace_press, tab_press, drag, and mode-carrying dialogs such as ConfirmClose. A menu left open on workspace 2 while another client closed a workspace could close the wrong workspace, or panic the server through the unchecked indexing in public_workspace_id.

Fixed both ways the review suggested: a workspace-tree change now cancels the transient index-bearing state in every saved view and in the loaded one (cancel_workspace_index_state, gated on workspace_tree_changed_since_last_reconcile so the cost is a comparison per swap, not a clear per swap), and the index-driven entry points (focus_workspace_idx_via_api, close_workspace_idx_via_api, move_workspace_via_api) go through a checked workspace_id_at, so a stale index degrades to a no-op instead of a crash.

P2 — per-client transients had global expiry deadlines.copy_feedback and the selection highlight/autoscroll state were saved per client, but their deadlines lived on App. A deadline firing while that client's view was swapped out cleared someone else's state, and the original transient came back on restore with no deadline and stuck.

Fixed by moving the three deadlines into ClientView, snapshotting and restoring them through App::snapshot_client_view / App::restore_client_view. expire_due_client_view_transients focuses each client whose saved deadline is due before expiring it, and the loop deadline now mins in deadlines parked in saved views so a non-loaded client still wakes on time (selection autoscroll ticks at 30ms, well under the 250ms accept poll).

P2 — mouse capture was computed from one view and broadcast to all. With mouse_capture = false, the desired capture state depends on each client's own mode and its focused terminal's mouse reporting. stream_host_mouse_capture_mode now computes the value per client from that client's view (should_capture_host_mouse_in_view) and sends each client its own.

Tests

src/server/headless/tests/client_view.rs gets one test per finding, each verified to fail against the unfixed code:

  • deferred_workspace_create_lands_on_the_requesting_client_only
  • workspace_tree_change_cancels_index_bearing_state_in_saved_views
  • copy_feedback_expires_against_the_client_that_raised_it
  • host_mouse_capture_follows_each_clients_own_view

just check is green: 2591 tests pass, formatting clean. just windows-lint was not run here because it shells out to rustup, which is not in the flake dev shell.

Known limitations, unchanged by this PR

Per the hands-on testing earlier in the discussion: all clients still share one render grid, so differently sized clients clip; ClientView is still ephemeral, so a reattaching client lands on the default workspace; the API exposes no per-client view state; and the done -> idle seen transition is still consumed by whichever client looks first. Those are feature and API-shape decisions rather than defects in the swap, and they need a maintainer call on scope.

Castrozanand others added 2 commits July 30, 2026 11:28
Each attached app client now views its own workspace independently
instead of mirroring one shared foreground view. A ClientView bundle
holds the per-client, workspace-relative state: active and selected
workspace by stable id, mode, view geometry, the navigator, the
interaction singletons (copy mode, selection, context menu, drag,
press tracking), the modal payloads (worktree create/open/remove,
rename target, release notes, product announcement, keybind help),
the name input, and the per-view scrolls and collapse set. The bundle
is saved per ClientConnection and swapped into the single AppState
around each client's render and input boundaries, including the resize
path before a client is promoted to foreground.
Saved views are reconciled against the live workspace set whenever a
view is loaded, so workspace removal falls a client back to its
selected or the first workspace, and clients attached before the first
workspace existed adopt it once created. Server-wide truth (the
workspace tree itself, global settings and palette, terminal
appearance) stays in shared AppState and is not carried per client.
The view swap made the active workspace per-client, but the rest of the
server still assumed one loaded state, so deferred commands, expiry
timers and broadcast decisions landed on whichever view happened to be
swapped in. Four paths are corrected.
Deferred requests are harvested from the client that raised them while
its view is still loaded, then replayed with that view focused. Without
this, one client clicking "+ new workspace" force-switched a different
client into the new workspace, and a worktree-create submit could drop
silently when the handler ran against a view whose dialog payload was
None.
Index-bearing view state is canceled when the workspace tree changes.
Saved views restore context menus, presses and drags verbatim, and those
payloads carry raw indices, so a menu left open while another client
closed a workspace could close the wrong one or index out of bounds.
Workspace id lookups driven by such an index are now checked, so the
worst case is a no-op instead of a panic that takes down every client.
Transient expiry deadlines travel with the view that owns the transient.
copy_feedback and the selection highlight are per-client, but their
deadlines lived on App, so a deadline firing while another view was
loaded cleared the wrong state and left the original stuck with nothing
to expire it. The loop deadline now also accounts for deadlines parked
in saved views.
Host mouse capture is computed per client instead of broadcasting one
value. With mouse_capture disabled, capture depends on each client's own
mode and focused pane, so a client on another workspace was told the
wrong mode and stopped receiving the mouse events it needed.
Sign up for freeto 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.

1 participant

@Castrozan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content

feat: per-client active workspace views (review fixes) - #7

Open
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2
Open

feat: per-client active workspace views (review fixes)#7
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2

Conversation

@Castrozan

Copy link
Copy Markdown
Owner

Preview of the upstream change against ogulcancelik/herdr, rebuilt on the same upstream-master base (4617456) as #5 so the diff is exactly the feature commit plus the fixes. Supersedes #5.

Makes the active workspace per-client through a ClientView context swap, so each attached terminal holds its own independent workspace view (the tmux model, no shared mirror), and then closes the four bugs the review on discussion #651 found in that swap.

What the review found and what changed

P1 — deferred request_* actions ran against whichever view was loaded. The one-tick-deferred flags live on the shared AppState and are not part of ClientView, so they survived view swaps and executed against whoever was swapped in when the batch drained. Client A clicking "+ new workspace" force-switched client B into the new workspace; a worktree-create submit from A ran against B's view, found worktree_create: None, and dropped silently.

Fixed by harvesting the flags into a DeferredClientRequests bundle immediately after each client's input batch, while that client still owns the loaded view, then replaying each bundle with its own view focused (harvest_deferred_requests_from_client / handle_deferred_requests_headless). Requests raised outside client input (socket API, timers) still run against the loaded view, as before.

P1 — reconciliation missed index-bearing state.reconcile_client_views_with_workspaces re-pointed only the id-based fields. Saved views also carry raw indices restored verbatim: context_menu, workspace_press, tab_press, drag, and mode-carrying dialogs such as ConfirmClose. A menu left open on workspace 2 while another client closed a workspace could close the wrong workspace, or panic the server through the unchecked indexing in public_workspace_id.

Fixed both ways the review suggested: a workspace-tree change now cancels the transient index-bearing state in every saved view and in the loaded one (cancel_workspace_index_state, gated on workspace_tree_changed_since_last_reconcile so the cost is a comparison per swap, not a clear per swap), and the index-driven entry points (focus_workspace_idx_via_api, close_workspace_idx_via_api, move_workspace_via_api) go through a checked workspace_id_at, so a stale index degrades to a no-op instead of a crash.

P2 — per-client transients had global expiry deadlines.copy_feedback and the selection highlight/autoscroll state were saved per client, but their deadlines lived on App. A deadline firing while that client's view was swapped out cleared someone else's state, and the original transient came back on restore with no deadline and stuck.

Fixed by moving the three deadlines into ClientView, snapshotting and restoring them through App::snapshot_client_view / App::restore_client_view. expire_due_client_view_transients focuses each client whose saved deadline is due before expiring it, and the loop deadline now mins in deadlines parked in saved views so a non-loaded client still wakes on time (selection autoscroll ticks at 30ms, well under the 250ms accept poll).

P2 — mouse capture was computed from one view and broadcast to all. With mouse_capture = false, the desired capture state depends on each client's own mode and its focused terminal's mouse reporting. stream_host_mouse_capture_mode now computes the value per client from that client's view (should_capture_host_mouse_in_view) and sends each client its own.

Tests

src/server/headless/tests/client_view.rs gets one test per finding, each verified to fail against the unfixed code:

  • deferred_workspace_create_lands_on_the_requesting_client_only
  • workspace_tree_change_cancels_index_bearing_state_in_saved_views
  • copy_feedback_expires_against_the_client_that_raised_it
  • host_mouse_capture_follows_each_clients_own_view

just check is green: 2591 tests pass, formatting clean. just windows-lint was not run here because it shells out to rustup, which is not in the flake dev shell.

Known limitations, unchanged by this PR

Per the hands-on testing earlier in the discussion: all clients still share one render grid, so differently sized clients clip; ClientView is still ephemeral, so a reattaching client lands on the default workspace; the API exposes no per-client view state; and the done -> idle seen transition is still consumed by whichever client looks first. Those are feature and API-shape decisions rather than defects in the swap, and they need a maintainer call on scope.

Castrozanand others added 2 commits July 30, 2026 11:28
Each attached app client now views its own workspace independently
instead of mirroring one shared foreground view. A ClientView bundle
holds the per-client, workspace-relative state: active and selected
workspace by stable id, mode, view geometry, the navigator, the
interaction singletons (copy mode, selection, context menu, drag,
press tracking), the modal payloads (worktree create/open/remove,
rename target, release notes, product announcement, keybind help),
the name input, and the per-view scrolls and collapse set. The bundle
is saved per ClientConnection and swapped into the single AppState
around each client's render and input boundaries, including the resize
path before a client is promoted to foreground.
Saved views are reconciled against the live workspace set whenever a
view is loaded, so workspace removal falls a client back to its
selected or the first workspace, and clients attached before the first
workspace existed adopt it once created. Server-wide truth (the
workspace tree itself, global settings and palette, terminal
appearance) stays in shared AppState and is not carried per client.
The view swap made the active workspace per-client, but the rest of the
server still assumed one loaded state, so deferred commands, expiry
timers and broadcast decisions landed on whichever view happened to be
swapped in. Four paths are corrected.
Deferred requests are harvested from the client that raised them while
its view is still loaded, then replayed with that view focused. Without
this, one client clicking "+ new workspace" force-switched a different
client into the new workspace, and a worktree-create submit could drop
silently when the handler ran against a view whose dialog payload was
None.
Index-bearing view state is canceled when the workspace tree changes.
Saved views restore context menus, presses and drags verbatim, and those
payloads carry raw indices, so a menu left open while another client
closed a workspace could close the wrong one or index out of bounds.
Workspace id lookups driven by such an index are now checked, so the
worst case is a no-op instead of a panic that takes down every client.
Transient expiry deadlines travel with the view that owns the transient.
copy_feedback and the selection highlight are per-client, but their
deadlines lived on App, so a deadline firing while another view was
loaded cleared the wrong state and left the original stuck with nothing
to expire it. The loop deadline now also accounts for deadlines parked
in saved views.
Host mouse capture is computed per client instead of broadcasting one
value. With mouse_capture disabled, capture depends on each client's own
mode and focused pane, so a client on another workspace was told the
wrong mode and stopped receiving the mouse events it needed.
Sign up for freeto 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.

1 participant

@Castrozan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content

feat: per-client active workspace views (review fixes) - #7

Open
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2
Open

feat: per-client active workspace views (review fixes)#7
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2

Conversation

@Castrozan

Copy link
Copy Markdown
Owner

Preview of the upstream change against ogulcancelik/herdr, rebuilt on the same upstream-master base (4617456) as #5 so the diff is exactly the feature commit plus the fixes. Supersedes #5.

Makes the active workspace per-client through a ClientView context swap, so each attached terminal holds its own independent workspace view (the tmux model, no shared mirror), and then closes the four bugs the review on discussion #651 found in that swap.

What the review found and what changed

P1 — deferred request_* actions ran against whichever view was loaded. The one-tick-deferred flags live on the shared AppState and are not part of ClientView, so they survived view swaps and executed against whoever was swapped in when the batch drained. Client A clicking "+ new workspace" force-switched client B into the new workspace; a worktree-create submit from A ran against B's view, found worktree_create: None, and dropped silently.

Fixed by harvesting the flags into a DeferredClientRequests bundle immediately after each client's input batch, while that client still owns the loaded view, then replaying each bundle with its own view focused (harvest_deferred_requests_from_client / handle_deferred_requests_headless). Requests raised outside client input (socket API, timers) still run against the loaded view, as before.

P1 — reconciliation missed index-bearing state.reconcile_client_views_with_workspaces re-pointed only the id-based fields. Saved views also carry raw indices restored verbatim: context_menu, workspace_press, tab_press, drag, and mode-carrying dialogs such as ConfirmClose. A menu left open on workspace 2 while another client closed a workspace could close the wrong workspace, or panic the server through the unchecked indexing in public_workspace_id.

Fixed both ways the review suggested: a workspace-tree change now cancels the transient index-bearing state in every saved view and in the loaded one (cancel_workspace_index_state, gated on workspace_tree_changed_since_last_reconcile so the cost is a comparison per swap, not a clear per swap), and the index-driven entry points (focus_workspace_idx_via_api, close_workspace_idx_via_api, move_workspace_via_api) go through a checked workspace_id_at, so a stale index degrades to a no-op instead of a crash.

P2 — per-client transients had global expiry deadlines.copy_feedback and the selection highlight/autoscroll state were saved per client, but their deadlines lived on App. A deadline firing while that client's view was swapped out cleared someone else's state, and the original transient came back on restore with no deadline and stuck.

Fixed by moving the three deadlines into ClientView, snapshotting and restoring them through App::snapshot_client_view / App::restore_client_view. expire_due_client_view_transients focuses each client whose saved deadline is due before expiring it, and the loop deadline now mins in deadlines parked in saved views so a non-loaded client still wakes on time (selection autoscroll ticks at 30ms, well under the 250ms accept poll).

P2 — mouse capture was computed from one view and broadcast to all. With mouse_capture = false, the desired capture state depends on each client's own mode and its focused terminal's mouse reporting. stream_host_mouse_capture_mode now computes the value per client from that client's view (should_capture_host_mouse_in_view) and sends each client its own.

Tests

src/server/headless/tests/client_view.rs gets one test per finding, each verified to fail against the unfixed code:

  • deferred_workspace_create_lands_on_the_requesting_client_only
  • workspace_tree_change_cancels_index_bearing_state_in_saved_views
  • copy_feedback_expires_against_the_client_that_raised_it
  • host_mouse_capture_follows_each_clients_own_view

just check is green: 2591 tests pass, formatting clean. just windows-lint was not run here because it shells out to rustup, which is not in the flake dev shell.

Known limitations, unchanged by this PR

Per the hands-on testing earlier in the discussion: all clients still share one render grid, so differently sized clients clip; ClientView is still ephemeral, so a reattaching client lands on the default workspace; the API exposes no per-client view state; and the done -> idle seen transition is still consumed by whichever client looks first. Those are feature and API-shape decisions rather than defects in the swap, and they need a maintainer call on scope.

Castrozanand others added 2 commits July 30, 2026 11:28
Each attached app client now views its own workspace independently
instead of mirroring one shared foreground view. A ClientView bundle
holds the per-client, workspace-relative state: active and selected
workspace by stable id, mode, view geometry, the navigator, the
interaction singletons (copy mode, selection, context menu, drag,
press tracking), the modal payloads (worktree create/open/remove,
rename target, release notes, product announcement, keybind help),
the name input, and the per-view scrolls and collapse set. The bundle
is saved per ClientConnection and swapped into the single AppState
around each client's render and input boundaries, including the resize
path before a client is promoted to foreground.
Saved views are reconciled against the live workspace set whenever a
view is loaded, so workspace removal falls a client back to its
selected or the first workspace, and clients attached before the first
workspace existed adopt it once created. Server-wide truth (the
workspace tree itself, global settings and palette, terminal
appearance) stays in shared AppState and is not carried per client.
The view swap made the active workspace per-client, but the rest of the
server still assumed one loaded state, so deferred commands, expiry
timers and broadcast decisions landed on whichever view happened to be
swapped in. Four paths are corrected.
Deferred requests are harvested from the client that raised them while
its view is still loaded, then replayed with that view focused. Without
this, one client clicking "+ new workspace" force-switched a different
client into the new workspace, and a worktree-create submit could drop
silently when the handler ran against a view whose dialog payload was
None.
Index-bearing view state is canceled when the workspace tree changes.
Saved views restore context menus, presses and drags verbatim, and those
payloads carry raw indices, so a menu left open while another client
closed a workspace could close the wrong one or index out of bounds.
Workspace id lookups driven by such an index are now checked, so the
worst case is a no-op instead of a panic that takes down every client.
Transient expiry deadlines travel with the view that owns the transient.
copy_feedback and the selection highlight are per-client, but their
deadlines lived on App, so a deadline firing while another view was
loaded cleared the wrong state and left the original stuck with nothing
to expire it. The loop deadline now also accounts for deadlines parked
in saved views.
Host mouse capture is computed per client instead of broadcasting one
value. With mouse_capture disabled, capture depends on each client's own
mode and focused pane, so a client on another workspace was told the
wrong mode and stopped receiving the mouse events it needed.
Sign up for freeto 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.

1 participant

@Castrozan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content

feat: per-client active workspace views (review fixes) - #7

Open
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2
Open

feat: per-client active workspace views (review fixes)#7
Castrozan wants to merge 2 commits into
upstream-masterfrom
feat/per-client-active-workspace-v2

Conversation

@Castrozan

Copy link
Copy Markdown
Owner

Preview of the upstream change against ogulcancelik/herdr, rebuilt on the same upstream-master base (4617456) as #5 so the diff is exactly the feature commit plus the fixes. Supersedes #5.

Makes the active workspace per-client through a ClientView context swap, so each attached terminal holds its own independent workspace view (the tmux model, no shared mirror), and then closes the four bugs the review on discussion #651 found in that swap.

What the review found and what changed

P1 — deferred request_* actions ran against whichever view was loaded. The one-tick-deferred flags live on the shared AppState and are not part of ClientView, so they survived view swaps and executed against whoever was swapped in when the batch drained. Client A clicking "+ new workspace" force-switched client B into the new workspace; a worktree-create submit from A ran against B's view, found worktree_create: None, and dropped silently.

Fixed by harvesting the flags into a DeferredClientRequests bundle immediately after each client's input batch, while that client still owns the loaded view, then replaying each bundle with its own view focused (harvest_deferred_requests_from_client / handle_deferred_requests_headless). Requests raised outside client input (socket API, timers) still run against the loaded view, as before.

P1 — reconciliation missed index-bearing state.reconcile_client_views_with_workspaces re-pointed only the id-based fields. Saved views also carry raw indices restored verbatim: context_menu, workspace_press, tab_press, drag, and mode-carrying dialogs such as ConfirmClose. A menu left open on workspace 2 while another client closed a workspace could close the wrong workspace, or panic the server through the unchecked indexing in public_workspace_id.

Fixed both ways the review suggested: a workspace-tree change now cancels the transient index-bearing state in every saved view and in the loaded one (cancel_workspace_index_state, gated on workspace_tree_changed_since_last_reconcile so the cost is a comparison per swap, not a clear per swap), and the index-driven entry points (focus_workspace_idx_via_api, close_workspace_idx_via_api, move_workspace_via_api) go through a checked workspace_id_at, so a stale index degrades to a no-op instead of a crash.

P2 — per-client transients had global expiry deadlines.copy_feedback and the selection highlight/autoscroll state were saved per client, but their deadlines lived on App. A deadline firing while that client's view was swapped out cleared someone else's state, and the original transient came back on restore with no deadline and stuck.

Fixed by moving the three deadlines into ClientView, snapshotting and restoring them through App::snapshot_client_view / App::restore_client_view. expire_due_client_view_transients focuses each client whose saved deadline is due before expiring it, and the loop deadline now mins in deadlines parked in saved views so a non-loaded client still wakes on time (selection autoscroll ticks at 30ms, well under the 250ms accept poll).

P2 — mouse capture was computed from one view and broadcast to all. With mouse_capture = false, the desired capture state depends on each client's own mode and its focused terminal's mouse reporting. stream_host_mouse_capture_mode now computes the value per client from that client's view (should_capture_host_mouse_in_view) and sends each client its own.

Tests

src/server/headless/tests/client_view.rs gets one test per finding, each verified to fail against the unfixed code:

  • deferred_workspace_create_lands_on_the_requesting_client_only
  • workspace_tree_change_cancels_index_bearing_state_in_saved_views
  • copy_feedback_expires_against_the_client_that_raised_it
  • host_mouse_capture_follows_each_clients_own_view

just check is green: 2591 tests pass, formatting clean. just windows-lint was not run here because it shells out to rustup, which is not in the flake dev shell.

Known limitations, unchanged by this PR

Per the hands-on testing earlier in the discussion: all clients still share one render grid, so differently sized clients clip; ClientView is still ephemeral, so a reattaching client lands on the default workspace; the API exposes no per-client view state; and the done -> idle seen transition is still consumed by whichever client looks first. Those are feature and API-shape decisions rather than defects in the swap, and they need a maintainer call on scope.

Castrozanand others added 2 commits July 30, 2026 11:28
Each attached app client now views its own workspace independently
instead of mirroring one shared foreground view. A ClientView bundle
holds the per-client, workspace-relative state: active and selected
workspace by stable id, mode, view geometry, the navigator, the
interaction singletons (copy mode, selection, context menu, drag,
press tracking), the modal payloads (worktree create/open/remove,
rename target, release notes, product announcement, keybind help),
the name input, and the per-view scrolls and collapse set. The bundle
is saved per ClientConnection and swapped into the single AppState
around each client's render and input boundaries, including the resize
path before a client is promoted to foreground.
Saved views are reconciled against the live workspace set whenever a
view is loaded, so workspace removal falls a client back to its
selected or the first workspace, and clients attached before the first
workspace existed adopt it once created. Server-wide truth (the
workspace tree itself, global settings and palette, terminal
appearance) stays in shared AppState and is not carried per client.
The view swap made the active workspace per-client, but the rest of the
server still assumed one loaded state, so deferred commands, expiry
timers and broadcast decisions landed on whichever view happened to be
swapped in. Four paths are corrected.
Deferred requests are harvested from the client that raised them while
its view is still loaded, then replayed with that view focused. Without
this, one client clicking "+ new workspace" force-switched a different
client into the new workspace, and a worktree-create submit could drop
silently when the handler ran against a view whose dialog payload was
None.
Index-bearing view state is canceled when the workspace tree changes.
Saved views restore context menus, presses and drags verbatim, and those
payloads carry raw indices, so a menu left open while another client
closed a workspace could close the wrong one or index out of bounds.
Workspace id lookups driven by such an index are now checked, so the
worst case is a no-op instead of a panic that takes down every client.
Transient expiry deadlines travel with the view that owns the transient.
copy_feedback and the selection highlight are per-client, but their
deadlines lived on App, so a deadline firing while another view was
loaded cleared the wrong state and left the original stuck with nothing
to expire it. The loop deadline now also accounts for deadlines parked
in saved views.
Host mouse capture is computed per client instead of broadcasting one
value. With mouse_capture disabled, capture depends on each client's own
mode and focused pane, so a client on another workspace was told the
wrong mode and stopped receiving the mouse events it needed.
Sign up for freeto 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.

1 participant

@Castrozan