Skip to content

feat(web): /workspaces route with host-service terminal viewer#4647

Merged
saddlepaddle merged 4 commits into
mainfrom
platinum-voyage
May 16, 2026
Merged

feat(web): /workspaces route with host-service terminal viewer#4647
saddlepaddle merged 4 commits into
mainfrom
platinum-voyage

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented May 16, 2026

Summary

  • New authed web route at /workspaces: create + list workspaces (search, project & device filters), per-workspace terminal-session picker, and an xterm terminal.
  • The terminal streams through the host-service /terminal/:terminalId WebSocket over the relay's JWT-gated path — the same path the desktop uses. No remote-control HMAC machinery.

Why / Context

The web app had no real terminal UI — only the anonymous-viewer remote-control share-link flow. We want authed users to reach their own workspace terminals from the browser, using the host-service + relay path the desktop already relies on, rather than the bearer-token remote-control mechanism.

How It Works

  • Streaming: the browser fetches a Better Auth JWT (/api/auth/token), gets the relay host URL from a new workspaceTerminal.connection procedure, and opens wss://<relay>/hosts/<routingKey>/terminal/<terminalId>?token=<jwt>. The relay's Path A middleware verifies the JWT + host access; the host-service streams the PTY. Wire protocol mirrors the desktop transport (binary frames = PTY bytes, JSON control messages).
  • List / create terminals: workspaceTerminal.list / create proxy to the host's terminal.listSessions / terminal.createSession tRPC over the relay (cloud mints a short-lived JWT).
  • v2Host.list added so the workspace UI shows device names instead of raw machine IDs.
  • The listTerminals / createTerminal additions briefly made to the remote-control router were reverted — that router is untouched.

Manual QA Checklist

Not yet exercised against a live host + relay — see Known Limitations.

  • /workspaces lists real workspaces; search + project/device filters narrow correctly
  • Create workspace inserts a row and it appears in the list
  • /workspaces/[id] lists host terminal sessions; "New terminal" creates one
  • Terminal connects, streams output, accepts input; resize reflows
  • Soft keyboard on mobile does not cover the prompt (visualViewport refit)

Testing

  • bun run lint — clean
  • bun run typecheck — clean (28/28 packages)
  • No runtime QA yet (requires a live host-service + relay).

Design Decisions

  • Host-service /terminal/:terminalId over remote-control: remote-control is a bearer-token mechanism built for anonymous viewers; authed web users should use the relay's JWT-gated path like the desktop. Streaming now does exactly that.
  • connection procedure returns the relay URL: the web app has no NEXT_PUBLIC_RELAY_URL; the cloud owns RELAY_URL and resolves the routing key, so a tiny procedure hands back the WS base.

Known Limitations

  • Not runtime-tested — needs a live host + relay. Most likely thing to need a tweak: the minted JWT's scope claim if the host rejects it for terminal.*.
  • "Create workspace" inserts the cloud v2Workspaces row only; it does not run the host-side worktree-creation saga, so a created workspace has no real worktree yet. Listing and the terminal view for existing workspaces are the solid paths.
  • list/create proxy through the cloud rather than calling the host directly like the desktop; the terminal stream is direct.

Follow-ups

  • Host-side workspace creation (worktree saga) for a fully functional create flow.

Open in Stage

Summary by cubic

Adds an authenticated /workspaces route with workspace list/create and a per-workspace terminal viewer. The web app connects to the relay directly for terminal WebSocket and host-service tRPC with a Better Auth JWT, and supports a PostHog relay URL override.

  • New Features

    • Web: /workspaces list with search and project/device filters, plus create form; /workspaces/[id] with session picker and “New terminal”.
    • Terminal: @xterm/xterm viewer (with @xterm/addon-fit) and mobile visualViewport refit so the soft keyboard doesn’t cover the prompt.
    • Transport: browser fetches a Better Auth JWT (/api/auth/token), resolves relay from NEXT_PUBLIC_RELAY_URL or the relay-url-override PostHog flag, and connects to wss://…/hosts/<routingKey>/terminal/<terminalId>; session list/create call relay …/trpc/terminal.* directly (JWT). Protocol mirrors desktop (binary PTY + JSON control).
    • Hosts: added v2Host.list so the UI shows device names instead of machine IDs.
  • Refactors

    • Removed the cloud workspaceTerminal proxy; added trpc/host-client.ts for direct browser → relay host tRPC.
    • Added NEXT_PUBLIC_RELAY_URL (env + deploy workflows) and allowed the relay HTTP origin in CSP connect-src.

Written for commit 33e6fd3. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • New Features
    • Interactive web terminal for workspace sessions with realtime input/output, resize handling, status banner, and quick-action buttons (Tab, Esc, Ctrl‑C/D, arrows)
    • Workspace terminal page: create, list, select, and auto-open terminal sessions
    • Workspaces page: create and filter/search workspaces
    • Web relay connectivity: browser relay URL exposed to web builds for host communication

Review Change Stack

New authed web UI at /workspaces:
- workspace list + create form (v2Workspace.list/create)
- per-workspace view with a terminal-session dropdown and "New terminal"
- xterm-based terminal viewer with mobile fixes (100dvh + visualViewport
  refit so the soft keyboard no longer covers the prompt)

Adds relayQuery to the relay client and remoteControl.listTerminals /
remoteControl.createTerminal cloud procedures, which proxy to the host
terminal router over the JWT-gated relay path. The terminal stream itself
still rides the existing remote-control session machinery.
Replaces the interim remote-control terminal stream with the same path
the desktop uses: the host-service /terminal/:terminalId WebSocket reached
through the relay's JWT-gated Path A. The browser fetches a Better Auth
JWT, gets the relay host URL from a new workspaceTerminal.connection
procedure, and speaks the desktop terminal protocol (binary PTY frames +
JSON control messages). No remote-control HMAC anywhere in this feature.

- new workspaceTerminal cloud router (list / create / connection)
- revert the listTerminals/createTerminal additions to the remote-control
  router; remote-control is left untouched
- v2Host.list so the workspace UI can show device names, not machine ids
- /workspaces: search + All projects / All devices filters
@capy-ai
Copy link
Copy Markdown

capy-ai Bot commented May 16, 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.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 16, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9ec882d9-cf43-43b1-8ddf-3553db4609ab

📥 Commits

Reviewing files that changed from the base of the PR and between c94c027 and 33e6fd3.

📒 Files selected for processing (3)
  • apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx
  • apps/web/src/trpc/host-client.ts
  • apps/web/src/trpc/relay-url.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/web/src/trpc/relay-url.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/src/trpc/host-client.ts
  • apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx

📝 Walkthrough

Walkthrough

Adds a workspace-scoped remote terminal: environment/CSP and deploy wiring, relay auth and client utilities, backend host listing, workspace listing/creation UI, a terminal session management page, and an xterm.js WebTerminal component that streams PTY data via a relay WebSocket.

Changes

Workspace Terminal Feature

Layer / File(s) Summary
Environment configuration and CSP setup
.env.example, apps/web/src/env.ts, apps/web/next.config.ts, .github/workflows/deploy-preview.yml, .github/workflows/deploy-production.yml
NEXT_PUBLIC_RELAY_URL added to env example, client schema, and runtime mapping; Next.js CSP updated to allow relay HTTP/WS origins; deploy workflows pass NEXT_PUBLIC_RELAY_URL to Vercel.
Relay auth and host-call client
apps/web/src/trpc/auth-token.ts, apps/web/src/trpc/relay-url.ts, apps/web/src/trpc/host-client.ts
getAuthToken() with module-level caching and TTL; getRelayUrl() using PostHog override or NEXT_PUBLIC_RELAY_URL; hostCall helper plus listHostTerminals and createHostTerminal wrappers for relay-trpc calls (SuperJSON + bearer auth).
Host enumeration TRPC query
packages/trpc/src/router/v2-host/v2-host.ts
Adds v2HostRouter.list returning accessible hosts (machineId, name) for the active organization and session user.
Workspaces listing and creation page
apps/web/src/app/workspaces/page.tsx
WorkspacesPage loads active org, workspaces, projects, and hosts; renders searchable/filterable list; and provides form UI to create a workspace with project/device selection.
Workspace terminal session management page
apps/web/src/app/workspaces/[workspaceId]/page.tsx
WorkspaceTerminalPage computes routingKey, lists and manages host terminals via host client, supports creating terminals, auto-selects an available session, and renders WebTerminal or loading/empty UI.
WebTerminal xterm.js component
apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx, apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/index.ts
WebTerminal initializes xterm.js with theme and FitAddon, connects to relay WebSocket using routingKey and terminalId, writes PTY frames, handles JSON control messages (attached/exit/error), sends input/resize messages, manages resize observers and cleanup, and exposes control buttons.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped to code a terminal bright,

Keys and PTY frames dancing in flight,
Relay paths stitched from near to far,
Workspaces hum with each tiny char,
A rabbit's cheer for sessions alight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.43% 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 clearly and concisely summarizes the main change: adding a /workspaces route with host-service terminal functionality to the web app.
Description check ✅ Passed The description is comprehensive and well-structured, covering summary, context, implementation details, testing status, design decisions, and known limitations, though the PR description template sections are not explicitly followed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 platinum-voyage

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.

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

@stage-review
Copy link
Copy Markdown

stage-review Bot commented May 16, 2026

Ready to review this PR? Stage has broken it down into 5 individual chapters for you:

Title
1 Configure relay environment variables and CSP
2 Implement relay connection and auth utilities
3 Add host listing and terminal proxy clients
4 Build the Xterm-based web terminal component
5 Create workspace management and terminal views
Open in Stage

Chapters generated by Stage for commit 33e6fd3 on May 16, 2026 10:38pm UTC.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 16, 2026

Greptile Summary

This PR adds an authenticated /workspaces route to the web app — a workspace list/create page with search and filter, and a per-workspace terminal page backed by a new workspaceTerminal tRPC router that proxies terminal.listSessions/terminal.createSession through the relay and hands the browser a WebSocket URL for live PTY streaming via xterm.js.

  • workspaceTerminal router (list, create, connection): verifies workspace ownership and host membership before proxying to the host-service over the relay; connection returns only the relay WS base URL so the browser opens its own authenticated connection.
  • WebTerminal component: fetches a BetterAuth JWT and relay connection URL in parallel, opens a WebSocket with the token as a query parameter, handles binary PTY frames and JSON control messages, and refits the terminal on ResizeObserver/visualViewport events for mobile soft-keyboard support.
  • v2Host.list added to expose host names for the workspace create form; relayQuery added alongside relayMutation for GET-based tRPC proxy calls.

Confidence Score: 3/5

The list and create terminal flows are likely broken on any live relay due to the unconfirmed JWT scope; the WebSocket streaming path may work independently.

The mintRelayJwt function uses scope remote-control for procedures unrelated to the remote-control router, and the PR author flags this as the most probable production breakage. Until the correct scope is confirmed, list and create are broken against a live host.

packages/trpc/src/router/workspace-terminal/workspace-terminal.ts — the mintRelayJwt scope claim used for list and create relay calls.

Security Review

Auth token in WebSocket URL (WebTerminal.tsx): The BetterAuth JWT is appended as a query parameter to the WebSocket upgrade URL. Relay and proxy access logs will record the full URL, leaving a live credential in log storage for the token TTL. No injection or SSRF risk identified.

Important Files Changed

Filename Overview
packages/trpc/src/router/workspace-terminal/workspace-terminal.ts New tRPC router for workspace terminals; authorization logic is correct, but mintRelayJwt mints with scope remote-control which may be wrong for terminal.listSessions/terminal.createSession.
apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx New xterm-based terminal component; auth JWT passed as WebSocket URL query parameter; no reconnect path on disconnect; async setup/teardown and resize-observer logic is sound.
packages/trpc/src/router/automation/relay-client.ts Adds relayQuery (GET) alongside the existing relayMutation (POST); mirrors the tRPC HTTP transport correctly with SuperJSON encoding, timeout/abort, and consistent error handling.
packages/trpc/src/router/v2-host/v2-host.ts Adds a list procedure returning hosts scoped to the active org where the calling user is a member; join and WHERE are correct.
apps/web/src/app/workspaces/page.tsx New workspace list/create page; client-side filtering/sorting is clean; error handling on load and create is present.
apps/web/src/app/workspaces/[workspaceId]/page.tsx New workspace terminal picker page; auto-selects first non-exited terminal and handles visualViewport height for mobile soft-keyboard.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
packages/trpc/src/router/workspace-terminal/workspace-terminal.ts:81-87
The JWT minted here for relaying `terminal.listSessions` and `terminal.createSession` uses `scope: "remote-control"`. The existing `remote-control` router uses that scope for `terminal.remoteControl.*` procedures, but those are a different namespace from `terminal.listSessions`/`terminal.createSession`. If the relay's host-service tRPC path enforces scope and these terminal procedures require `terminal.*` or `automation-run`, every `list` and `create` call will return 401/403. The PR description explicitly flags this as the most likely production failure. It's worth resolving before landing.

```suggestion
	return mintUserJwt({
		userId,
		email: owner?.email,
		organizationIds: [organizationId],
		scope: "terminal", // TODO: confirm the scope the relay's Path A middleware expects for terminal.listSessions / terminal.createSession
		ttlSeconds: 300,
	});
```

### Issue 2 of 3
apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx:164-165
**Auth token exposed in WebSocket URL**

The BetterAuth JWT is appended as a query parameter in the WebSocket URL. While browsers cannot set custom headers on WebSocket upgrades (making query-param auth a common workaround), the token will appear verbatim in relay access logs, browser history, and any CDN or proxy logs that record the full request path. Consider using a dedicated short-lived one-time token for this connection to bound the exposure window.

### Issue 3 of 3
apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx:200-210
**No reconnect path after WebSocket close**

`socket.onclose` transitions the state to `"error"` and `socket.onerror` sets the message, but neither exposes a reconnect action. A transient network blip will land the user on the static error banner with no recovery path other than a full page refresh. A reconnect button that re-runs the same setup effect would be a minimal improvement for a terminal UI that explicitly targets mobile.

Reviews (1): Last reviewed commit: "feat(web): route /workspaces terminal th..." | Re-trigger Greptile

Comment on lines +81 to +87
return mintUserJwt({
userId,
email: owner?.email,
organizationIds: [organizationId],
scope: "remote-control",
ttlSeconds: 300,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 The JWT minted here for relaying terminal.listSessions and terminal.createSession uses scope: "remote-control". The existing remote-control router uses that scope for terminal.remoteControl.* procedures, but those are a different namespace from terminal.listSessions/terminal.createSession. If the relay's host-service tRPC path enforces scope and these terminal procedures require terminal.* or automation-run, every list and create call will return 401/403. The PR description explicitly flags this as the most likely production failure. It's worth resolving before landing.

Suggested change
return mintUserJwt({
userId,
email: owner?.email,
organizationIds: [organizationId],
scope: "remote-control",
ttlSeconds: 300,
});
return mintUserJwt({
userId,
email: owner?.email,
organizationIds: [organizationId],
scope: "terminal", // TODO: confirm the scope the relay's Path A middleware expects for terminal.listSessions / terminal.createSession
ttlSeconds: 300,
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/trpc/src/router/workspace-terminal/workspace-terminal.ts
Line: 81-87

Comment:
The JWT minted here for relaying `terminal.listSessions` and `terminal.createSession` uses `scope: "remote-control"`. The existing `remote-control` router uses that scope for `terminal.remoteControl.*` procedures, but those are a different namespace from `terminal.listSessions`/`terminal.createSession`. If the relay's host-service tRPC path enforces scope and these terminal procedures require `terminal.*` or `automation-run`, every `list` and `create` call will return 401/403. The PR description explicitly flags this as the most likely production failure. It's worth resolving before landing.

```suggestion
	return mintUserJwt({
		userId,
		email: owner?.email,
		organizationIds: [organizationId],
		scope: "terminal", // TODO: confirm the scope the relay's Path A middleware expects for terminal.listSessions / terminal.createSession
		ttlSeconds: 300,
	});
```

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

Comment on lines +164 to +165
if (event.data instanceof ArrayBuffer) {
terminal?.write(new Uint8Array(event.data));
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 security Auth token exposed in WebSocket URL

The BetterAuth JWT is appended as a query parameter in the WebSocket URL. While browsers cannot set custom headers on WebSocket upgrades (making query-param auth a common workaround), the token will appear verbatim in relay access logs, browser history, and any CDN or proxy logs that record the full request path. Consider using a dedicated short-lived one-time token for this connection to bound the exposure window.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx
Line: 164-165

Comment:
**Auth token exposed in WebSocket URL**

The BetterAuth JWT is appended as a query parameter in the WebSocket URL. While browsers cannot set custom headers on WebSocket upgrades (making query-param auth a common workaround), the token will appear verbatim in relay access logs, browser history, and any CDN or proxy logs that record the full request path. Consider using a dedicated short-lived one-time token for this connection to bound the exposure window.

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

Comment on lines +200 to +210
};

socket.onerror = () => {
setErrorMessage("WebSocket connection failed.");
};

terminal.onData((data) => {
const activeSocket = socketRef.current;
if (activeSocket?.readyState === WebSocket.OPEN) {
activeSocket.send(JSON.stringify({ type: "input", data }));
}
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 No reconnect path after WebSocket close

socket.onclose transitions the state to "error" and socket.onerror sets the message, but neither exposes a reconnect action. A transient network blip will land the user on the static error banner with no recovery path other than a full page refresh. A reconnect button that re-runs the same setup effect would be a minimal improvement for a terminal UI that explicitly targets mobile.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx
Line: 200-210

Comment:
**No reconnect path after WebSocket close**

`socket.onclose` transitions the state to `"error"` and `socket.onerror` sets the message, but neither exposes a reconnect action. A transient network blip will land the user on the static error banner with no recovery path other than a full page refresh. A reconnect button that re-runs the same setup effect would be a minimal improvement for a terminal UI that explicitly targets mobile.

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

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: 4

🧹 Nitpick comments (1)
packages/trpc/src/router/automation/relay-client.ts (1)

103-158: 💤 Low value

Consider extracting shared response-parsing logic.

The response handling in relayQuery (lines 128–157) duplicates relayMutation (lines 65–94). Extracting a parseRelayResponse<T>(response, rawBody) helper would reduce maintenance overhead.

This is optional given the limited scope of this module.

🤖 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/trpc/src/router/automation/relay-client.ts` around lines 103 - 158,
relayQuery contains duplicated response-parsing/error-handling logic also
present in relayMutation; extract that shared logic into a helper like
parseRelayResponse<T>(response: Response, rawBody: string): T that performs
JSON.parse, validates the TrpcEnvelope/result.data shape, throws
RelayDispatchError with response.status and rawBody on invalid JSON or missing
data, and returns SuperJSON.deserialize(parsed.result.data) typed as T; then
replace the duplicated blocks in both relayQuery and relayMutation to call
parseRelayResponse<TOutput>(response, rawBody) (or the appropriate generic) and
return its result to remove redundancy and centralize error messages.
🤖 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
`@apps/web/src/app/workspaces/`[workspaceId]/components/WebTerminal/WebTerminal.tsx:
- Around line 202-204: The WebSocket onerror handler currently only calls
setErrorMessage which leaves the connection state stuck at "connecting"; update
the onerror handler (socket.onerror) to also update the connection state by
calling the state setter used in this component (e.g.,
setConnectionState("failed") or setConnectionState("disconnected")), so the UI
no longer shows "connecting" after an error; keep the existing setErrorMessage
call and add the connection-state update in the same handler.

In `@apps/web/src/app/workspaces/`[workspaceId]/page.tsx:
- Around line 51-56: The effect that initializes selection only runs when
selectedTerminalId is falsy, so if the currently selected terminal disappears on
a refresh the selection is never reconciled; update the useEffect (watching
terminals and selectedTerminalId) to also check whether the current
selectedTerminalId exists in the new terminals list and if not pick a
replacement (prefer the first non-exited terminal or terminals[0]) and call
setSelectedTerminalId with that id; reference the existing identifiers
useEffect, selectedTerminalId, terminals, setSelectedTerminalId, and the earlier
logic that chooses first = terminals.find((t)=>!t.exited) ?? terminals[0].

In `@apps/web/src/app/workspaces/page.tsx`:
- Around line 169-204: The inputs and selects for creating/filtering workspaces
(state variables name, branch, projectId, hostId and the iterated projects and
hosts lists using project.id and host.machineId) lack programmatic labels; add
accessible labels by either adding <label htmlFor="..."> elements and matching
id attributes on the corresponding input/select elements (e.g., ids like
workspace-name, workspace-branch, workspace-project, workspace-host) or by
adding clear aria-label attributes if visual labels are not desired; ensure
option lists remain unchanged and use hostLabel(host) for visible option text
while the select has an associated label for screen readers.

In `@packages/trpc/src/router/workspace-terminal/workspace-terminal.ts`:
- Around line 75-88: In mintRelayJwt, the DB lookup can return no user so owner
may be undefined and email passed as undefined to mintUserJwt; update
mintRelayJwt to guard the lookup result (the owner variable from the select on
users) and handle the missing user case before calling mintUserJwt — e.g., throw
a clear error or return a controlled failure when owner is falsy, or
fetch/derive a fallback email if appropriate, ensuring mintUserJwt is never
called with email: undefined.

---

Nitpick comments:
In `@packages/trpc/src/router/automation/relay-client.ts`:
- Around line 103-158: relayQuery contains duplicated
response-parsing/error-handling logic also present in relayMutation; extract
that shared logic into a helper like parseRelayResponse<T>(response: Response,
rawBody: string): T that performs JSON.parse, validates the
TrpcEnvelope/result.data shape, throws RelayDispatchError with response.status
and rawBody on invalid JSON or missing data, and returns
SuperJSON.deserialize(parsed.result.data) typed as T; then replace the
duplicated blocks in both relayQuery and relayMutation to call
parseRelayResponse<TOutput>(response, rawBody) (or the appropriate generic) and
return its result to remove redundancy and centralize error messages.
🪄 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: 9619d1f3-e836-4a64-bb6e-b26e3b583fb2

📥 Commits

Reviewing files that changed from the base of the PR and between 6c41c69 and f13e8fd.

📒 Files selected for processing (9)
  • apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx
  • apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/index.ts
  • apps/web/src/app/workspaces/[workspaceId]/page.tsx
  • apps/web/src/app/workspaces/page.tsx
  • packages/trpc/src/root.ts
  • packages/trpc/src/router/automation/relay-client.ts
  • packages/trpc/src/router/v2-host/v2-host.ts
  • packages/trpc/src/router/workspace-terminal/index.ts
  • packages/trpc/src/router/workspace-terminal/workspace-terminal.ts

Comment on lines +202 to +204
socket.onerror = () => {
setErrorMessage("WebSocket connection failed.");
};
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 | 🟡 Minor | ⚡ Quick win

Set connection state on WebSocket error events.

onerror sets only the message; state can remain "connecting" longer than needed.

Suggested fix
 socket.onerror = () => {
   setErrorMessage("WebSocket connection failed.");
+  setState("error");
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
socket.onerror = () => {
setErrorMessage("WebSocket connection failed.");
};
socket.onerror = () => {
setErrorMessage("WebSocket connection failed.");
setState("error");
};
🤖 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
`@apps/web/src/app/workspaces/`[workspaceId]/components/WebTerminal/WebTerminal.tsx
around lines 202 - 204, The WebSocket onerror handler currently only calls
setErrorMessage which leaves the connection state stuck at "connecting"; update
the onerror handler (socket.onerror) to also update the connection state by
calling the state setter used in this component (e.g.,
setConnectionState("failed") or setConnectionState("disconnected")), so the UI
no longer shows "connecting" after an error; keep the existing setErrorMessage
call and add the connection-state update in the same handler.

Comment on lines +51 to +56
useEffect(() => {
if (selectedTerminalId || !terminals) return;
const first =
terminals.find((terminal) => !terminal.exited) ?? terminals[0];
if (first) setSelectedTerminalId(first.terminalId);
}, [terminals, selectedTerminalId]);
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 | 🟡 Minor | ⚡ Quick win

Reconcile selectedTerminalId against refreshed terminal lists.

Selection is only initialized when null; it is not corrected when the current terminal is missing after reload.

Suggested fix
 useEffect(() => {
-  if (selectedTerminalId || !terminals) return;
-  const first =
-    terminals.find((terminal) => !terminal.exited) ?? terminals[0];
-  if (first) setSelectedTerminalId(first.terminalId);
+  if (!terminals) return;
+  const stillExists =
+    selectedTerminalId !== null &&
+    terminals.some((terminal) => terminal.terminalId === selectedTerminalId);
+  if (stillExists) return;
+
+  const first =
+    terminals.find((terminal) => !terminal.exited) ?? terminals[0] ?? null;
+  setSelectedTerminalId(first?.terminalId ?? null);
 }, [terminals, selectedTerminalId]);
🤖 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 `@apps/web/src/app/workspaces/`[workspaceId]/page.tsx around lines 51 - 56, The
effect that initializes selection only runs when selectedTerminalId is falsy, so
if the currently selected terminal disappears on a refresh the selection is
never reconciled; update the useEffect (watching terminals and
selectedTerminalId) to also check whether the current selectedTerminalId exists
in the new terminals list and if not pick a replacement (prefer the first
non-exited terminal or terminals[0]) and call setSelectedTerminalId with that
id; reference the existing identifiers useEffect, selectedTerminalId, terminals,
setSelectedTerminalId, and the earlier logic that chooses first =
terminals.find((t)=>!t.exited) ?? terminals[0].

Comment on lines +169 to +204
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Name"
className="rounded-md border bg-transparent px-3 py-2 text-sm"
/>
<input
value={branch}
onChange={(event) => setBranch(event.target.value)}
placeholder="Branch"
className="rounded-md border bg-transparent px-3 py-2 text-sm"
/>
<select
value={projectId}
onChange={(event) => setProjectId(event.target.value)}
className="rounded-md border bg-transparent px-3 py-2 text-sm"
>
<option value="">Select project…</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<select
value={hostId}
onChange={(event) => setHostId(event.target.value)}
className="rounded-md border bg-transparent px-3 py-2 text-sm"
>
<option value="">Select device…</option>
{hosts.map((host) => (
<option key={host.machineId} value={host.machineId}>
{hostLabel(host)}
</option>
))}
</select>
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

Add explicit labels for form and filter controls.

These controls are missing programmatic labels, which makes the create/filter flows hard to use with assistive tech.

Suggested fix
+<label htmlFor="workspace-name" className="sr-only">Workspace name</label>
 <input
+  id="workspace-name"
   value={name}
   onChange={(event) => setName(event.target.value)}
   placeholder="Name"
   className="rounded-md border bg-transparent px-3 py-2 text-sm"
 />

+<label htmlFor="workspace-branch" className="sr-only">Branch</label>
 <input
+  id="workspace-branch"
   value={branch}
   onChange={(event) => setBranch(event.target.value)}
   placeholder="Branch"
   className="rounded-md border bg-transparent px-3 py-2 text-sm"
 />

+<label htmlFor="workspace-project" className="sr-only">Project</label>
 <select
+  id="workspace-project"
   value={projectId}
   onChange={(event) => setProjectId(event.target.value)}
   className="rounded-md border bg-transparent px-3 py-2 text-sm"
 >

+<label htmlFor="workspace-host" className="sr-only">Device</label>
 <select
+  id="workspace-host"
   value={hostId}
   onChange={(event) => setHostId(event.target.value)}
   className="rounded-md border bg-transparent px-3 py-2 text-sm"
 >

Also applies to: 224-253

🤖 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 `@apps/web/src/app/workspaces/page.tsx` around lines 169 - 204, The inputs and
selects for creating/filtering workspaces (state variables name, branch,
projectId, hostId and the iterated projects and hosts lists using project.id and
host.machineId) lack programmatic labels; add accessible labels by either adding
<label htmlFor="..."> elements and matching id attributes on the corresponding
input/select elements (e.g., ids like workspace-name, workspace-branch,
workspace-project, workspace-host) or by adding clear aria-label attributes if
visual labels are not desired; ensure option lists remain unchanged and use
hostLabel(host) for visible option text while the select has an associated label
for screen readers.

Comment thread packages/trpc/src/router/workspace-terminal/workspace-terminal.ts Outdated
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 16, 2026

🚀 Preview Deployment

🔗 Preview Links

Service Status Link
Neon Database (Neon) View Branch
Vercel API (Vercel) Open Preview
Vercel Web (Vercel) Open Preview
Vercel Marketing (Vercel) Open Preview
Vercel Admin (Vercel) Open Preview
Vercel Docs (Vercel) Open Preview

Preview updates automatically with new commits

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 9 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/web/src/app/workspaces/[workspaceId]/page.tsx">

<violation number="1" location="apps/web/src/app/workspaces/[workspaceId]/page.tsx:34">
P2: `loadTerminals` updates state unconditionally, so overlapping calls can race and let stale responses overwrite newer terminal data.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic

workspaceId,
});
setLoadError(null);
setTerminals(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: loadTerminals updates state unconditionally, so overlapping calls can race and let stale responses overwrite newer terminal data.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/app/workspaces/[workspaceId]/page.tsx, line 34:

<comment>`loadTerminals` updates state unconditionally, so overlapping calls can race and let stale responses overwrite newer terminal data.</comment>

<file context>
@@ -0,0 +1,159 @@
+				workspaceId,
+			});
+			setLoadError(null);
+			setTerminals(
+				result.map((terminal) => ({
+					terminalId: terminal.terminalId,
</file context>

Drops the cloud-side workspaceTerminal tRPC proxy. The web app now talks
to the relay directly — the same path the desktop uses:

- terminal list/create: a browser fetch client (trpc/host-client.ts) to
  the relay's /hosts/<routingKey>/trpc endpoint, JWT-authed
- terminal stream: WebSocket straight to the relay, no cloud hop
- the user JWT comes from Better Auth /api/auth/token (trpc/auth-token.ts)

Removes the workspace-terminal router and the relayQuery helper added
earlier; remote-control is left untouched. Adds NEXT_PUBLIC_RELAY_URL
(env, deploy workflows, .env.example) and the relay HTTP origin to the
web CSP connect-src.
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 `@apps/web/next.config.ts`:
- Around line 24-33: The CSP origins are being derived from
process.env.RELAY_URL while the browser uses process.env.NEXT_PUBLIC_RELAY_URL;
update the logic that computes relayWsOrigin and relayHttpOrigin (and the other
occurrence around the same block referenced as the second occurrence) to prefer
process.env.NEXT_PUBLIC_RELAY_URL as the source-of-truth (use
NEXT_PUBLIC_RELAY_URL if present, fall back to RELAY_URL if not), ensure ws/http
scheme conversion is applied to the chosen value, and keep the same production
default fallbacks ("wss://relay.superset.sh" / "https://relay.superset.sh") and
null behavior when neither is set.

In `@apps/web/src/app/workspaces/`[workspaceId]/page.tsx:
- Around line 54-80: The effect that resolves the workspace can leave stale
terminal state when resolution fails or races; update the effect in the
workspace loading logic (the async useEffect that calls
trpcClient.organization.getActive and trpcClient.v2Workspace.getFromHost, and
the similar block later) to clear any previous terminal context immediately when
starting and on error: call setRoutingKey(null) and setSelectedTerminalId(null)
before attempting lookups, and also ensure the catch block sets
setRoutingKey(null) and setSelectedTerminalId(null) in addition to
setTerminals([]) and setLoadError(...); this guarantees the page won't keep
rendering an old terminal while workspace resolution is in flight or fails.
🪄 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: 94d532ea-033a-4813-900c-0e0df2b37036

📥 Commits

Reviewing files that changed from the base of the PR and between f13e8fd and c94c027.

📒 Files selected for processing (9)
  • .env.example
  • .github/workflows/deploy-preview.yml
  • .github/workflows/deploy-production.yml
  • apps/web/next.config.ts
  • apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx
  • apps/web/src/app/workspaces/[workspaceId]/page.tsx
  • apps/web/src/env.ts
  • apps/web/src/trpc/auth-token.ts
  • apps/web/src/trpc/host-client.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx

Comment thread apps/web/next.config.ts
Comment on lines 24 to +33
const relayWsOrigin = process.env.RELAY_URL
? new URL(process.env.RELAY_URL).origin.replace(/^http/, "ws")
: isProduction
? "wss://relay.superset.sh"
: null;
const relayHttpOrigin = process.env.RELAY_URL
? new URL(process.env.RELAY_URL).origin
: isProduction
? "https://relay.superset.sh"
: 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

Use NEXT_PUBLIC_RELAY_URL as the CSP relay source-of-truth.

connect-src relay origins are derived from RELAY_URL, while browser calls use NEXT_PUBLIC_RELAY_URL. If these differ (or only the public var is set), relay requests can be blocked by CSP.

Suggested fix
-const relayWsOrigin = process.env.RELAY_URL
-	? new URL(process.env.RELAY_URL).origin.replace(/^http/, "ws")
-	: isProduction
-		? "wss://relay.superset.sh"
-		: null;
-const relayHttpOrigin = process.env.RELAY_URL
-	? new URL(process.env.RELAY_URL).origin
-	: isProduction
-		? "https://relay.superset.sh"
-		: null;
+const relayBaseUrl =
+	process.env.NEXT_PUBLIC_RELAY_URL ?? process.env.RELAY_URL;
+const relayHttpOrigin = relayBaseUrl
+	? new URL(relayBaseUrl).origin
+	: isProduction
+		? "https://relay.superset.sh"
+		: null;
+const relayWsOrigin = relayHttpOrigin
+	? relayHttpOrigin.replace(/^http/, "ws")
+	: null;

Also applies to: 42-42

🤖 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 `@apps/web/next.config.ts` around lines 24 - 33, The CSP origins are being
derived from process.env.RELAY_URL while the browser uses
process.env.NEXT_PUBLIC_RELAY_URL; update the logic that computes relayWsOrigin
and relayHttpOrigin (and the other occurrence around the same block referenced
as the second occurrence) to prefer process.env.NEXT_PUBLIC_RELAY_URL as the
source-of-truth (use NEXT_PUBLIC_RELAY_URL if present, fall back to RELAY_URL if
not), ensure ws/http scheme conversion is applied to the chosen value, and keep
the same production default fallbacks ("wss://relay.superset.sh" /
"https://relay.superset.sh") and null behavior when neither is set.

Comment on lines +54 to +80
useEffect(() => {
(async () => {
try {
const organization = await trpcClient.organization.getActive.query();
if (!organization) {
setLoadError("No active organization.");
setTerminals([]);
return;
}
const workspace = await trpcClient.v2Workspace.getFromHost.query({
organizationId: organization.id,
id: workspaceId,
});
if (!workspace) {
setLoadError("Workspace not found.");
setTerminals([]);
return;
}
const key = buildHostRoutingKey(organization.id, workspace.hostId);
setRoutingKey(key);
await loadTerminals(key);
} catch (caught) {
setLoadError(caught instanceof Error ? caught.message : String(caught));
setTerminals([]);
}
})();
}, [workspaceId, loadTerminals]);
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

Reset stale terminal context before/after failed workspace resolution.

The loader effect can leave previous routingKey/selectedTerminalId active when navigation races or lookup fails, so the page may continue rendering an old terminal context on the new route.

Suggested fix
 useEffect(() => {
+	let cancelled = false;
+	setRoutingKey(null);
+	setSelectedTerminalId(null);
+	setTerminals(null);
+	setLoadError(null);
+
 	(async () => {
 		try {
 			const organization = await trpcClient.organization.getActive.query();
+			if (cancelled) return;
 			if (!organization) {
+				setRoutingKey(null);
+				setSelectedTerminalId(null);
 				setLoadError("No active organization.");
 				setTerminals([]);
 				return;
 			}
 			const workspace = await trpcClient.v2Workspace.getFromHost.query({
 				organizationId: organization.id,
 				id: workspaceId,
 			});
+			if (cancelled) return;
 			if (!workspace) {
+				setRoutingKey(null);
+				setSelectedTerminalId(null);
 				setLoadError("Workspace not found.");
 				setTerminals([]);
 				return;
 			}
 			const key = buildHostRoutingKey(organization.id, workspace.hostId);
+			if (cancelled) return;
 			setRoutingKey(key);
 			await loadTerminals(key);
 		} catch (caught) {
+			if (cancelled) return;
+			setRoutingKey(null);
+			setSelectedTerminalId(null);
 			setLoadError(caught instanceof Error ? caught.message : String(caught));
 			setTerminals([]);
 		}
 	})();
+	return () => {
+		cancelled = true;
+	};
 }, [workspaceId, loadTerminals]);

Also applies to: 173-179

🤖 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 `@apps/web/src/app/workspaces/`[workspaceId]/page.tsx around lines 54 - 80, The
effect that resolves the workspace can leave stale terminal state when
resolution fails or races; update the effect in the workspace loading logic (the
async useEffect that calls trpcClient.organization.getActive and
trpcClient.v2Workspace.getFromHost, and the similar block later) to clear any
previous terminal context immediately when starting and on error: call
setRoutingKey(null) and setSelectedTerminalId(null) before attempting lookups,
and also ensure the catch block sets setRoutingKey(null) and
setSelectedTerminalId(null) in addition to setTerminals([]) and
setLoadError(...); this guarantees the page won't keep rendering an old terminal
while workspace resolution is in flight or fails.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

3 issues found across 13 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/web/src/trpc/host-client.ts">

<violation number="1" location="apps/web/src/trpc/host-client.ts:41">
P2: Include relay/host response text in non-OK errors so failure diagnostics are not lost.

(Based on your team's feedback about preserving useful diagnostic context in errors.) [FEEDBACK_USED]</violation>

<violation number="2" location="apps/web/src/trpc/host-client.ts:44">
P3: Wrap JSON parsing so malformed relay responses throw a procedure-scoped error instead of a generic SyntaxError.

(Based on your team's feedback about preserving useful diagnostic context in errors.) [FEEDBACK_USED]</violation>
</file>

<file name="apps/web/next.config.ts">

<violation number="1" location="apps/web/next.config.ts:29">
P2: Derive relay CSP origins from the same public relay URL used by browser calls; otherwise terminal and host tRPC requests can be blocked by `connect-src` when `RELAY_URL` and `NEXT_PUBLIC_RELAY_URL` diverge.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic

body: method === "POST" ? JSON.stringify(encoded) : undefined,
});
if (!response.ok) {
throw new Error(`host ${procedure} failed (${response.status})`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Include relay/host response text in non-OK errors so failure diagnostics are not lost.

(Based on your team's feedback about preserving useful diagnostic context in errors.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/trpc/host-client.ts, line 41:

<comment>Include relay/host response text in non-OK errors so failure diagnostics are not lost.

(Based on your team's feedback about preserving useful diagnostic context in errors.) </comment>

<file context>
@@ -0,0 +1,67 @@
+		body: method === "POST" ? JSON.stringify(encoded) : undefined,
+	});
+	if (!response.ok) {
+		throw new Error(`host ${procedure} failed (${response.status})`);
+	}
+
</file context>

Comment thread apps/web/next.config.ts
: isProduction
? "wss://relay.superset.sh"
: null;
const relayHttpOrigin = process.env.RELAY_URL
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Derive relay CSP origins from the same public relay URL used by browser calls; otherwise terminal and host tRPC requests can be blocked by connect-src when RELAY_URL and NEXT_PUBLIC_RELAY_URL diverge.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/next.config.ts, line 29:

<comment>Derive relay CSP origins from the same public relay URL used by browser calls; otherwise terminal and host tRPC requests can be blocked by `connect-src` when `RELAY_URL` and `NEXT_PUBLIC_RELAY_URL` diverge.</comment>

<file context>
@@ -16,16 +16,21 @@ const isProduction = process.env.NODE_ENV === "production";
 	: isProduction
 		? "wss://relay.superset.sh"
 		: null;
+const relayHttpOrigin = process.env.RELAY_URL
+	? new URL(process.env.RELAY_URL).origin
+	: isProduction
</file context>

throw new Error(`host ${procedure} failed (${response.status})`);
}

const parsed = (await response.json()) as { result?: { data?: unknown } };
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.

P3: Wrap JSON parsing so malformed relay responses throw a procedure-scoped error instead of a generic SyntaxError.

(Based on your team's feedback about preserving useful diagnostic context in errors.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/trpc/host-client.ts, line 44:

<comment>Wrap JSON parsing so malformed relay responses throw a procedure-scoped error instead of a generic SyntaxError.

(Based on your team's feedback about preserving useful diagnostic context in errors.) </comment>

<file context>
@@ -0,0 +1,67 @@
+		throw new Error(`host ${procedure} failed (${response.status})`);
+	}
+
+	const parsed = (await response.json()) as { result?: { data?: unknown } };
+	if (!parsed.result || parsed.result.data === undefined) {
+		throw new Error(`host ${procedure}: malformed relay response`);
</file context>

Mirrors the desktop's useRelayUrl: getRelayUrl() reads the
`relay-url-override` flag payload and falls back to NEXT_PUBLIC_RELAY_URL.
Used for both the host tRPC client and the terminal WebSocket.
@saddlepaddle saddlepaddle merged commit ebe2981 into main May 16, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant