Skip to content

[codex] fix terminal workspace ownership checks#4572

Merged
Kitenite merged 6 commits into
mainfrom
debug-tab-workspaces
May 14, 2026
Merged

[codex] fix terminal workspace ownership checks#4572
Kitenite merged 6 commits into
mainfrom
debug-tab-workspaces

Conversation

@Kitenite
Copy link
Copy Markdown
Collaborator

@Kitenite Kitenite commented May 14, 2026

Summary

  • scope terminal create/adopt reuse to the owning workspace
  • send the active workspace id on terminal WebSocket attaches so host-service can reject stale/corrupt pane layouts
  • ensure the terminal-session dropdown creates the host terminal before writing a new terminal id into pane layout
  • add regression coverage for cross-workspace terminal id reuse before and after host-service restart adoption

Root Cause

Terminal sessions were keyed globally by terminalId. If a persisted pane layout in another workspace referenced the same id, host-service could return or adopt the existing terminal before verifying workspace ownership, making workspace A attach to workspace B's session.

One concrete bad-pane writer was also found: the terminal dropdown's “new terminal” action wrote a fresh crypto.randomUUID() directly into pane state instead of using the terminal launcher, which could persist a pane whose terminal never existed on host-service.

Fixes #4434

Validation

  • bun run lint
  • bun --filter @superset/host-service typecheck
  • bun --filter @superset/desktop typecheck

Notes

  • The focused node adoption test could not run locally because this environment fails before the suite reaches the assertions: Node reports @xterm/headless does not provide the named Terminal export, and Bun cannot load better-sqlite3.

Summary by CodeRabbit

  • New Features

    • Terminal connections include the active workspace identifier.
    • Terminal UI integrates a launcher for creating sessions; "New terminal" is disabled while creating and shows a spinner.
  • Bug Fixes

    • Enforced workspace ownership for terminal sessions; cross-workspace reuse/adoption is rejected with clear errors.
  • Tests

    • Added end-to-end tests for cross-workspace terminal adoption/rejection and updated terminal-related test coverage.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Enforce terminal session workspace ownership during creation and WebSocket attach by reading an optional workspaceId query, rejecting cross-workspace reuse/adoption, resetting ownership/status on DB upsert, and adding end-to-end tests. UI wiring passes workspaceId through the terminal transport URL and threads a launcher for session creation.

Changes

Terminal workspace ownership enforcement

Layer / File(s) Summary
Workspace mismatch helper
packages/host-service/src/terminal/terminal.ts
Adds getTerminalWorkspaceMismatchError helper returning a descriptive error when a terminal's recorded owner workspace differs from the requested workspace.
Creation: enforce ownership early
packages/host-service/src/terminal/terminal.ts
createTerminalSessionInternal now rejects creation/attachment when an existing in-memory session or DB terminalSessions row belongs to a different workspace.
Upsert: reset ownership/status on conflict
packages/host-service/src/terminal/terminal.ts
terminalSessions upsert conflict set now explicitly sets originWorkspaceId to the requested workspace, status to \"active\", and clears endedAt.
WebSocket attach: read workspaceId query
packages/host-service/src/terminal/terminal.ts
Attach route reads optional workspaceId query into requestedWorkspaceId for conditional mismatch checks.
WS attach: in-memory session mismatch handling
packages/host-service/src/terminal/terminal.ts
When an in-memory session exists, compare existing.workspaceId to requestedWorkspaceId only if the query param is present; otherwise attach without mismatch rejection.
WS attach: DB record mismatch handling
packages/host-service/src/terminal/terminal.ts
When attaching via DB record, compare record.originWorkspaceId to requestedWorkspaceId only if the query param is present; otherwise continue with adoption/respawn logic.
UI wiring and launcher
apps/desktop/src/renderer/.../TerminalPane/TerminalPane.tsx, .../TerminalSessionDropdown/TerminalSessionDropdown.tsx, .../usePaneRegistry/usePaneRegistry.tsx, .../page.tsx
Thread workspaceId into terminal WebSocket transport URL and add a launcher prop through usePaneRegistry and TerminalSessionDropdown for async terminal creation; UI disables the New Terminal button and shows a spinner during creation.
Tests: cross-workspace adoption coverage
packages/host-service/src/terminal/terminal.adoption.node-test.ts
Adds tests that assert createTerminalSessionInternal and adoption reject attempts to reuse or adopt a live terminal from a different workspace, including a restart simulation and deterministic cleanup helper usage.
Test mock: electron-log
apps/desktop/src/main/lib/auto-updater.test.ts
Defensive test mock: electron-log/main default logger info, warn, error are Bun mock() stubs.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I hopped through sockets, logs, and tests today,
I nudged the workspace id the proper way,
I stitched the owner into create and attach,
So tabs stay true and sessions don't unlatch,
A tiny hop — I guard each terminal's bay.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title '[codex] fix terminal workspace ownership checks' clearly and specifically describes the main change in the changeset.
Description check ✅ Passed The description includes all major sections from the template with clear details about changes, validation steps, and implementation notes.
Linked Issues check ✅ Passed All changes directly address the core objectives from issue #4434: preventing cross-workspace terminal session mixing and ensuring workspace ownership verification.
Out of Scope Changes check ✅ Passed All code changes are directly related to fixing terminal workspace ownership issues and supporting regression tests, with only one tangential update to auto-updater test mocking.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch debug-tab-workspaces

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 14, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 14, 2026

Greptile Summary

This PR scopes terminal session create/adopt/reuse to the owning workspace, fixing a cross-workspace terminal hijack where a persisted pane layout in workspace B could attach to workspace A's terminal session. It also fixes a related bug where the "new terminal" dropdown action wrote a random UUID directly into pane state instead of going through the launcher.

  • terminal.ts adds getTerminalWorkspaceMismatchError and enforces ownership in both createTerminalSessionInternal (always) and the WebSocket attach path (when workspaceId is sent by the client); the onConflictDoUpdate now also writes originWorkspaceId on upsert.
  • TerminalSessionDropdown is converted to an async handler that calls launcher.create() before committing the new terminal ID to pane state, with a loading indicator and duplicate-click guard.
  • TerminalPane appends ?workspaceId=… to the WebSocket URL so the host can reject stale cross-workspace attaches; two regression tests cover the live and post-restart adoption paths.

Confidence Score: 4/5

The cross-workspace terminal hijack fix is correct and well-tested; the one gap is that the WebSocket attach path only enforces ownership when the client sends the workspaceId param.

The core ownership logic in createTerminalSessionInternal is applied unconditionally and backed by two new end-to-end tests. Two minor concerns exist: the WebSocket route workspace check is opt-in rather than mandatory, and terminalPaneLocations is captured before the async launcher call.

terminal.ts (WebSocket attach path — workspace check is conditional) and TerminalSessionDropdown.tsx (stale terminalPaneLocations snapshot before await).

Important Files Changed

Filename Overview
packages/host-service/src/terminal/terminal.ts Adds getTerminalWorkspaceMismatchError helper and enforces workspace ownership in createTerminalSessionInternal (unconditionally) and in the WebSocket attach path (conditionally, only when the client sends workspaceId). The conditional check in the WebSocket route is an intentional backward-compat gap worth noting.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx Converts handleNewTerminal to an async flow using launcher.create(). Adds a duplicate-click guard and spinner. terminalPaneLocations is now captured before the await, so the markTerminalForBackground decision can use stale layout data if the pane state changes during the async call.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx Single-line change: appends ?workspaceId=… to the WebSocket URL before ?themeType=…. Clean and correct.
packages/host-service/src/terminal/terminal.adoption.node-test.ts Adds a second workspace fixture and two regression tests: cross-workspace live-session rejection and cross-workspace adoption-after-restart rejection. Both tests assert the correct error message and verify listTerminalSessions scoping.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx Threads the launcher prop through UsePaneRegistryOptions and into TerminalSessionDropdown. Dependency array updated correctly. No logic changes.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx Passes the pre-existing launcher variable into usePaneRegistry. Trivial plumbing change.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx:229-234
`terminalPaneLocations` is captured from the store snapshot *before* `await launcher.create()`, so if any pane is closed or moved while the async call is in flight, the `markTerminalForBackground` decision will be based on stale layout data. The original synchronous version didn't have this window. Moving the capture to after the `await` ensures the decision uses the current layout.

```suggestion
		const state = context.store.getState();
		try {
			const nextTerminalId = await launcher.create();
			const terminalPaneLocations = getTerminalPaneLocations(context);
			if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) {
				markTerminalForBackground(terminalId);
```

### Issue 2 of 2
packages/host-service/src/terminal/terminal.ts:1454-1492
**Optional workspace check leaves an attach bypass**

The ownership enforcement in `resolveSessionForAttach` is gated on `requestedWorkspaceId` being truthy, so any WebSocket connection that omits the `?workspaceId=` param (e.g., a reconnect triggered before the updated `TerminalPane` builds the URL, or a future caller that forgets the param) will attach to the live session without any cross-workspace check. `createTerminalSessionInternal` enforces the check unconditionally; the same posture would be stronger here. Consider returning an error when `requestedWorkspaceId` is absent and the session already has a known `workspaceId`, or at minimum logging a warning so the gap is visible.

Reviews (1): Last reviewed commit: "fix terminal dropdown session creation" | Re-trigger Greptile

Comment on lines +229 to +234
const state = context.store.getState();
const terminalPaneLocations = getTerminalPaneLocations(context);
if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) {
markTerminalForBackground(terminalId);
try {
const nextTerminalId = await launcher.create();
if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) {
markTerminalForBackground(terminalId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 terminalPaneLocations is captured from the store snapshot before await launcher.create(), so if any pane is closed or moved while the async call is in flight, the markTerminalForBackground decision will be based on stale layout data. The original synchronous version didn't have this window. Moving the capture to after the await ensures the decision uses the current layout.

Suggested change
const state = context.store.getState();
const terminalPaneLocations = getTerminalPaneLocations(context);
if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) {
markTerminalForBackground(terminalId);
try {
const nextTerminalId = await launcher.create();
if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) {
markTerminalForBackground(terminalId);
const state = context.store.getState();
try {
const nextTerminalId = await launcher.create();
const terminalPaneLocations = getTerminalPaneLocations(context);
if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) {
markTerminalForBackground(terminalId);
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx
Line: 229-234

Comment:
`terminalPaneLocations` is captured from the store snapshot *before* `await launcher.create()`, so if any pane is closed or moved while the async call is in flight, the `markTerminalForBackground` decision will be based on stale layout data. The original synchronous version didn't have this window. Moving the capture to after the `await` ensures the decision uses the current layout.

```suggestion
		const state = context.store.getState();
		try {
			const nextTerminalId = await launcher.create();
			const terminalPaneLocations = getTerminalPaneLocations(context);
			if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) {
				markTerminalForBackground(terminalId);
```

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

Comment on lines 1454 to +1492
@@ -1433,6 +1482,14 @@ export function registerWorkspaceTerminalRoute({
error: `Terminal session "${terminalId}" is missing a workspace.`,
};
}
if (requestedWorkspaceId) {
const mismatchError = getTerminalWorkspaceMismatchError({
terminalId,
ownerWorkspaceId: record.originWorkspaceId,
requestedWorkspaceId,
});
if (mismatchError) return { error: mismatchError };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Optional workspace check leaves an attach bypass

The ownership enforcement in resolveSessionForAttach is gated on requestedWorkspaceId being truthy, so any WebSocket connection that omits the ?workspaceId= param (e.g., a reconnect triggered before the updated TerminalPane builds the URL, or a future caller that forgets the param) will attach to the live session without any cross-workspace check. createTerminalSessionInternal enforces the check unconditionally; the same posture would be stronger here. Consider returning an error when requestedWorkspaceId is absent and the session already has a known workspaceId, or at minimum logging a warning so the gap is visible.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/terminal/terminal.ts
Line: 1454-1492

Comment:
**Optional workspace check leaves an attach bypass**

The ownership enforcement in `resolveSessionForAttach` is gated on `requestedWorkspaceId` being truthy, so any WebSocket connection that omits the `?workspaceId=` param (e.g., a reconnect triggered before the updated `TerminalPane` builds the URL, or a future caller that forgets the param) will attach to the live session without any cross-workspace check. `createTerminalSessionInternal` enforces the check unconditionally; the same posture would be stronger here. Consider returning an error when `requestedWorkspaceId` is absent and the session already has a known `workspaceId`, or at minimum logging a warning so the gap is visible.

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

@capy-ai
Copy link
Copy Markdown

capy-ai Bot commented May 14, 2026

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

# Conflicts:
#	apps/desktop/src/main/lib/auto-updater.test.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/host-service/src/terminal/terminal.ts`:
- Line 1425: The code currently allows attaching a WebSocket when
requestedWorkspaceId is missing (requestedWorkspaceId =
c.req.query("workspaceId") || null) which skips workspace mismatch checks;
change the attach flow to reject the request immediately if requestedWorkspaceId
is null/undefined by returning an error (e.g., respond with 400/close the
socket) instead of falling back to terminalId-only attachment. Update all
occurrences where requestedWorkspaceId is read/used (same pattern around the
other attach/mismatch checks) so they early-return with a clear error when the
workspaceId query param is absent.
- Around line 1040-1048: The pre-check using db.query.terminalSessions.findFirst
and getTerminalWorkspaceMismatchError is raceable because the code later
unconditionally rewrites terminalSessions.originWorkspaceId; replace the
check-then-write pattern with an atomic claim/update: perform a single
transactional conditional upsert that only sets originWorkspaceId when it is
NULL or already equals the requesting workspaceId (or fails otherwise), e.g. by
using a database-level conditional update/upsert/INSERT ... ON CONFLICT ...
WHERE clause or a SQL transaction with a WHERE originWorkspaceId IS NULL OR
originWorkspaceId = :workspaceId; ensure the conflict handler that rewrites
originWorkspaceId is changed to enforce this same conditional constraint so
concurrent create attempts cannot steal ownership (refer to terminalSessions.id,
originWorkspaceId, workspaceId, and the existing conflict handler logic).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e7bd30f7-1d5d-4628-9da9-9005672aff2d

📥 Commits

Reviewing files that changed from the base of the PR and between cd56fbb and b1c0145.

📒 Files selected for processing (1)
  • packages/host-service/src/terminal/terminal.ts

Comment on lines +1040 to +1048
const existingRecord = db.query.terminalSessions
.findFirst({ where: eq(terminalSessions.id, terminalId) })
.sync();
const recordMismatchError = getTerminalWorkspaceMismatchError({
terminalId,
ownerWorkspaceId: existingRecord?.originWorkspaceId,
requestedWorkspaceId: workspaceId,
});
if (recordMismatchError) return { error: recordMismatchError };
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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

The ownership check is still raceable across concurrent creates.

These reads happen before several awaits, and the conflict handler later unconditionally rewrites originWorkspaceId. Two concurrent creates for the same terminalId from different workspaces can both pass the precheck; the loser can then adopt/reuse the daemon session and overwrite the recorded owner, reintroducing cross-workspace takeover through a TOCTOU window. The ownership claim needs to be atomic rather than "check first, overwrite on conflict later".

Also applies to: 1174-1181

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/host-service/src/terminal/terminal.ts` around lines 1040 - 1048, The
pre-check using db.query.terminalSessions.findFirst and
getTerminalWorkspaceMismatchError is raceable because the code later
unconditionally rewrites terminalSessions.originWorkspaceId; replace the
check-then-write pattern with an atomic claim/update: perform a single
transactional conditional upsert that only sets originWorkspaceId when it is
NULL or already equals the requesting workspaceId (or fails otherwise), e.g. by
using a database-level conditional update/upsert/INSERT ... ON CONFLICT ...
WHERE clause or a SQL transaction with a WHERE originWorkspaceId IS NULL OR
originWorkspaceId = :workspaceId; ensure the conflict handler that rewrites
originWorkspaceId is changed to enforce this same conditional constraint so
concurrent create attempts cannot steal ownership (refer to terminalSessions.id,
originWorkspaceId, workspaceId, and the existing conflict handler logic).

"/terminal/:terminalId",
upgradeWebSocket((c) => {
const terminalId = c.req.param("terminalId") ?? "";
const requestedWorkspaceId = c.req.query("workspaceId") || null;
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Require workspaceId on WebSocket attach.

If the query param is omitted, both mismatch checks are skipped and the route falls back to attaching by terminalId alone. That keeps the old cross-workspace attach path alive for stale or malformed clients, which undermines the ownership boundary this PR is trying to enforce. Reject missing workspaceId here instead of silently proceeding.

Suggested tightening
-			const requestedWorkspaceId = c.req.query("workspaceId") || null;
+			const requestedWorkspaceId = c.req.query("workspaceId");
…
 			const resolveSessionForAttach = async (): Promise<
 				TerminalSession | { error: string }
 			> => {
+				if (!requestedWorkspaceId) {
+					return { error: "Missing workspaceId" };
+				}
+
 				const existing = sessions.get(terminalId);
 				if (existing) {
-					if (requestedWorkspaceId) {
-						const mismatchError = getTerminalWorkspaceMismatchError({
-							terminalId,
-							ownerWorkspaceId: existing.workspaceId,
-							requestedWorkspaceId,
-						});
-						if (mismatchError) return { error: mismatchError };
-					}
+					const mismatchError = getTerminalWorkspaceMismatchError({
+						terminalId,
+						ownerWorkspaceId: existing.workspaceId,
+						requestedWorkspaceId,
+					});
+					if (mismatchError) return { error: mismatchError };
 					return existing;
 				}
…
-				if (requestedWorkspaceId) {
-					const mismatchError = getTerminalWorkspaceMismatchError({
-						terminalId,
-						ownerWorkspaceId: record.originWorkspaceId,
-						requestedWorkspaceId,
-					});
-					if (mismatchError) return { error: mismatchError };
-				}
+				const mismatchError = getTerminalWorkspaceMismatchError({
+					terminalId,
+					ownerWorkspaceId: record.originWorkspaceId,
+					requestedWorkspaceId,
+				});
+				if (mismatchError) return { error: mismatchError };

Also applies to: 1455-1462, 1485-1492

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/host-service/src/terminal/terminal.ts` at line 1425, The code
currently allows attaching a WebSocket when requestedWorkspaceId is missing
(requestedWorkspaceId = c.req.query("workspaceId") || null) which skips
workspace mismatch checks; change the attach flow to reject the request
immediately if requestedWorkspaceId is null/undefined by returning an error
(e.g., respond with 400/close the socket) instead of falling back to
terminalId-only attachment. Update all occurrences where requestedWorkspaceId is
read/used (same pattern around the other attach/mismatch checks) so they
early-return with a clear error when the workspaceId query param is absent.

@Kitenite Kitenite merged commit f95b8f4 into main May 14, 2026
17 checks passed
@Kitenite Kitenite deleted the debug-tab-workspaces branch May 14, 2026 21:48
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.

Main branch mixing terminal sessions across workspaces

1 participant