fix: show chats started outside the desktop as tabs, plus CLI API-key and attachment fixes (SUPER-3158)
Fixes SUPER-3158.
Root cause
The desktop builds a workspace's tabs from a per-device saved pane layout. The only thing that adds tabs nobody opened is useAutoAdoptBackgroundSessions, and it reads terminal.list only. A chat session (chat-runtime) has no terminal session, so it was never adopted.
Since #9344, whoever creates a chat asks for its tab with host panes.newTab: the CLI, automations, mobile, and the desktop itself. On the host, applyOp throws PRECONDITION_FAILED when no desktop is attached, and only a host-service the desktop started itself has one attached. A cloud sandbox never does, and neither does a host started with superset start. Callers swallow that error, so those chats never got a tab.
How this fits with #9344
- #9344's creator path stays the fast path. With a desktop attached, the tab opens at once and in the right order (#9260).
- This PR adds a safety net for every case where that request can't reach a desktop.
- It is not the hook #9344 removed. There is no prompt-hash guessing and no backoff polling. It adopts only by exact session id or terminal id, and it leaves chats the desktop is creating to their own code paths.
What changed
useSyncChatSessionTabs (first named useAutoAdoptChatSessions), wired next to useAutoAdoptBackgroundSessions. It reads listSessions when the layout is ready, on chat:sessions-changed, on reconnect, and on a 15 s poll. It adds each live chat no pane shows as a background tab, in one store write, with the user's tab still selected.
It skips a chat when:
- a pane claims its session id or its terminal id. A chat with no terminal id (mobile) uses its session id, as
panes.newTabdoes; - the user closed it:
closedChatSessions, plus the sharedpending-chat-stopsentries (session and terminal), so other windows and reloads inside the undo window respect it too; - it sits on an ignored terminal:
- the terminal of a tab the user closed (a session leaked there, #8919, is never a tab);
- a fork-into-chat loader;
- a fork that hasn't been placed yet;
- a launching cloud chat the user closed;
- it isn't among the 10 most recently active live chats in the workspace. There is no time limit, so a long-running agent's chat always gets a tab, and a workspace with many idle automation chats doesn't open a tab for each.
Selection: a workspace opened with no tabs of the user's lands on the newest chat, not on a setup terminal opened a moment earlier. Only the first check after opening does this; chats that arrive later open in the background.
A person's close applies everywhere
closeSessiontakes an optionaluserClosed. chat.db records it asclosed_by_user, migration0002, generated by drizzle-kit.- Who sets it:
- closing a tab in the desktop (after the undo window);
superset sessions closeandsuperset panes close;- mobile's close actions.
- Internal closes leave it unset and never clear it: restarts, account switches, stray stops, and fork loaders.
- Other desktops remove their tab for such a chat and never auto-restart it, unless the user opened it again in that window (history, a link, Undo).
Close paths
- Host
panes.closenow stops the live chats on the closed pane's terminal, unlesskeepTerminalis set. That matches what it already did for terminals. Before, the tab could come back and the chat kept running. - The desktop also marks such a chat closed, so it isn't adopted back.
- If stopping a closed chat fails, it is shown again instead of running with no tab.
focusChatSession has a new addChatTab helper. Adoption uses it so a resumed or imported chat isn't hidden behind an old tab of the same agent session.
CLI: sessions create, resume and import return openedInDesktop and say when no desktop tab was opened yet.
Compatibility (trpc-compat): every change is additive.
closeSessioninput: new optional field. Older hosts drop it.getSession/listSessionsoutput: new field. The desktop readsclosedByUser === true, so a host without it behaves as before.panes.close: same input and output. Only stopping the chat is new.- No host minimum-version bump is needed.
- Repo rule: the tRPC checklist says to keep a schema migration in its own PR. Here it is a host-local chat.db (SQLite) column with a default, not a
packages/dbmigration. If you want the rule applied anyway, I can split0002and the schema into their own PR.
ws create --agent is unchanged, as the ticket asks.
Smaller CLI items from the same report
- API key → "Session expired". The relay accepts only a user JWT, and any 401 prints as "Session expired".
snapshot,agents listandprojects list --host …reach hosts through the relay, so an API key failed there;sessionsandworkspacesreach cloud workspaces through gate tickets, which the API issues for keys.- The CLI now trades an
sk_live_key for a JWT at/api/auth/token, once per run. That is the exchangepages storagealready used, now shared inlib/api-key-jwt. --host cloudon a host command now explains that each cloud workspace runs its own host.snapshot --host cloud --workspace <id>reads that workspace's host.
- The CLI now trades an
- 80 MB attachment → "JSON Parse error". Inline uploads to a host are capped at 25 MB (
MAX_INLINE_ATTACHMENT_BYTES), and the gate answered the larger body with an HTML page.- Files are now checked before upload, with a clear message.
- A cloud workspace takes files up to 200 MB through Paradev's storage (
attachment.createUpload, then hostattachments.importFromCloud). - A host on someone's machine refuses them, by the documented policy.
- Files up to 25 MB still go inline, so older hosts are unaffected.
- A cloud host too old for
importFromCloudgets a clear message.
workspaces create --attachmentin the cloud. The files are uploaded to storage and passed asattachmentFileIds, as the desktop composer does. It needs--agent.- The docs (
cli-reference.mdx) are updated: the cloud no longer rejects attachments, the limits are listed, and--host cloudis explained. - Compatibility: these use only existing procedures. The CLI changes no input or output.
- Verified: 495 CLI tests pass, including new ones for the exchange,
--host cloud, attachment routing and limits, and the cloud create. - Live against the local API: a dev API key traded for a real 60-minute JWT, issued by the API whose keys the relay checks.
- Live CLI:
projects list --host cloudnow explains itself; a 30 MB attachment to a host is refused with the limit before any upload; a small attachment still works. - Not tested live: a real relay hop with the minted JWT, and uploads to cloud storage. The local stack has no relay, and I avoided writing to the shared storage bucket.
- The docs (
How I verified
Automated checks
tscfor desktop and CLI,biome check,bun run check:i18n: all pass.- Desktop renderer suite: 7572 pass. CLI suite: 482 pass.
- Each new adoption guard was mutation-checked: removing it makes a test fail.
Live, round 1 (dev desktop on Xvfb, driven over CDP with real clicks; screenshots in the workspace kit)
- With the hook removed, a chat created via the host's
sessions.createnever got a tab. With it, the tab appeared in about 4 s, and in about 1 s while the workspace was open. - Real CLI
sessions create: one tab, no duplicate. - Closing, closing inside the undo window, and reload all behave as expected.
- A session started on a closed tab's terminal: with the guard removed it came back as a tab in 1 s; with it, no tab after 35 s.
Audit by four parallel agents: three read the code, one drove the app.
- Live: 9 flows plus 3 extra checks, all passed. Covered:
- new chat from "+";
newTabreuse, andsessions.createfollowed bynewTab;- resume, import, and
agents.runwith a chat surface; - moving a chat to the sidebar or into another tab;
- Undo;
- an empty workspace;
- focus with a half-typed draft;
- three chats at once, and adoption while on another page.
- Code audits found the gaps listed under "Close paths" and "What changed". All of them are fixed in the last commit.
Live, open-on-chat: I emptied the workspace, left it, started a terminal and a chat from outside, and opened it again from the sidebar. Before the change the terminal was selected; after, the chat. A chat arriving later, while the terminal was selected, stayed in the background.
Live, after the cross-device fixes
- Background tab, chat closed with
superset sessions close: the tab was removed in 2 s, the user's tab stayed visible, and the host recordedclosedByUser=true. - Visible tab, chat closed elsewhere with
userClosed: the tab was removed in 2 s, and nothing restarted on its terminal (checked after 10 s). - Control, internal close of the visible chat: it recovered on the same terminal in 2 s and the tab stayed.
- A chat closed elsewhere, then reopened from Chat history: the tab stayed and the chat recovered.
panes.closeon a chat: the chat was already stopped when the call returned (130 ms), withclosedByUser=true, and the tab stayed gone. WithkeepTerminal, the chat kept running and got no tab.- Closing a tab in the desktop:
closedByUser=trueon the host after the undo window. - Tests: chat-runtime 455 pass, chat 108, desktop renderer 7575, CLI 482, mobile 256. Typecheck passes for desktop, chat, chat-runtime, host-service and CLI.
- Mobile typecheck: 187 errors with or without this change; none in the touched lines.
- Host-service: 2722 pass, 23 fail. The failures are a health test that reads this sandbox's real boot files, and 22 workspace-creation naming tests that fail only in the full run, with "path traversal" on a worktree base of
undefined. None of this code is touched by the PR. I did not compare against main here.
Live, after the audit fixes
- A mobile-style chat (no terminal id) got a tab in 1 s.
- After a renderer reload the user's tab stayed selected; before, the adopted tab took over.
- A resumed chat got its own tab.
panes.closeon a chat: the tab stayed closed for 35 s, and the chat was stopped on the next mount.
Not verified
- A real cloud sandbox. The cloud condition was reproduced by creating chats without
panes.newTab. - Fork-into-chat and Branch, live. The agent harness has no Claude login here, so it can't produce a conversation to fork. Covered by code reading only.
- Resume where the new session keeps the same agent session id. The harness here started a fresh one, so this is covered by a unit test only.
- Two windows on the same workspace, live. Covered by reasoning and the shared pending-stop check only.
- Two real devices. Simulated by closing through the host from outside this desktop (CLI and
userClosed), which is what another desktop or mobile sends. - A launching cloud chat closed before it starts. Covered by code only.
Risks and known gaps
- Two devices: each device adopts into its own layout, and a teammate's chat also gets a tab. Closing a tab on one device stops the chat and now removes its tab on every other desktop that shows the workspace. If that tab was visible there, it disappears.
- Mobile: the mobile close actions send
userClosed. I checked them by typecheck only; the app was not run. - Ignored terminals are kept in memory. Pending stops carry the terminal only until the stop finishes. A session that leaks onto a closed terminal later, after an app restart, could still be adopted.
- Late cloud launch: if a cloud launch starts more than 3 minutes after the pending tab gave up, it now shows as its own tab. Before, it ran invisibly.
- Chats that stopped before the desktop saw them get no tab, and neither do live chats beyond the 10 most recent in a workspace. Both stay in chat history.
- Load: one
listSessionscall (limit 200) per open workspace every 15 s, plus one perchat:sessions-changedevent.
Summary by CodeRabbit
- New Features
- Chat sessions now sync across workspace tabs, with eligible live chats adopted automatically. Sessions closed elsewhere are removed, while restored sessions can reopen.
- CLI cloud workspace creation supports agent attachments, and snapshots can target cloud workspaces.
- CLI session create, import, and resume commands report whether a desktop tab was opened.
- Bug Fixes
- Closing a chat now distinguishes user-initiated closures from internal stops, improving chat recovery and preventing closed chats from reappearing unexpectedly.
- Clarified CLI host selection and attachment size limits.