From 5b880dd8d5ccebc6faa49b6973daf642daae74cb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 6 Oct 2026 12:18:38 +0000 Subject: [PATCH 1/4] fix: a live pop-out terminal's session is not re-adopted as a sidebar tab --- src/browser/terminal-window.tsx | 2 +- .../terminal/TerminalRouterContext.tsx | 6 ++- src/browser/terminal/TerminalSessionRouter.ts | 11 ++++- src/common/orpc/schemas/api.ts | 6 ++- src/desktop/terminalWindowManager.ts | 15 ++++++ src/node/orpc/router.ts | 4 +- src/node/orpc/routerSubscriptions.test.ts | 41 +++++++++++++++++ src/node/orpc/routerSubscriptions.ts | 16 +++++-- src/node/services/terminalService.test.ts | 46 +++++++++++++++++++ src/node/services/terminalService.ts | 31 ++++++++++++- 10 files changed, 167 insertions(+), 11 deletions(-) diff --git a/src/browser/terminal-window.tsx b/src/browser/terminal-window.tsx index 714cdbb85a6..a00e5377fb6 100644 --- a/src/browser/terminal-window.tsx +++ b/src/browser/terminal-window.tsx @@ -63,7 +63,7 @@ if (!workspaceId || !sessionId) { // race conditions with WebSocket connections and terminal lifecycle ReactDOM.createRoot(document.getElementById("root")!).render( - + (null); interface TerminalRouterProviderProps { children: React.ReactNode; + /** Set in pop-out terminal windows (terminal-window.tsx). */ + popout?: boolean; } /** @@ -35,12 +37,12 @@ export function TerminalRouterProvider(props: TerminalRouterProviderProps) { } // Create/cleanup after commit to avoid render-time disposal in concurrent mode. - const nextRouter = new TerminalSessionRouter(api); + const nextRouter = new TerminalSessionRouter(api, { popout: props.popout === true }); setRouter(nextRouter); return () => { nextRouter.dispose(); }; - }, [api]); + }, [api, props.popout]); const routerForContext = api && router?.getApi() === api ? router : null; diff --git a/src/browser/terminal/TerminalSessionRouter.ts b/src/browser/terminal/TerminalSessionRouter.ts index e7828edb0bf..a8f08011234 100644 --- a/src/browser/terminal/TerminalSessionRouter.ts +++ b/src/browser/terminal/TerminalSessionRouter.ts @@ -51,8 +51,12 @@ export class TerminalSessionRouter { private readonly api: APIClient; private sessions = new Map(); - constructor(api: APIClient) { + /** True in a pop-out terminal window: its attaches keep the session out of the sidebar. */ + private readonly popout: boolean; + + constructor(api: APIClient, options?: { popout?: boolean }) { this.api = api; + this.popout = options?.popout === true; } /** Get the API client (for identity comparison when recreating router) */ @@ -287,7 +291,10 @@ export class TerminalSessionRouter { // Start attach stream (fire-and-forget, but managed by abort controller) void (async () => { try { - const iterator = await this.api.terminal.attach({ sessionId }, { signal }); + const iterator = await this.api.terminal.attach( + this.popout ? { sessionId, popout: true } : { sessionId }, + { signal } + ); for await (const msg of iterator) { // Check if session was removed (unsubscribed) const currentSession = this.sessions.get(sessionId); diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 7372a73962f..33b8d78622f 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2907,7 +2907,11 @@ export const terminal = { * Guarantees no missed output between state snapshot and live stream. */ attach: { - input: z.object({ sessionId: z.string() }), + input: z.object({ + sessionId: z.string(), + /** Set by pop-out terminal windows; their sessions stay out of listSessions meanwhile. */ + popout: z.boolean().nullish(), + }), output: eventIterator( z.discriminatedUnion("type", [ z.object({ type: z.literal("screenState"), data: z.string() }), diff --git a/src/desktop/terminalWindowManager.ts b/src/desktop/terminalWindowManager.ts index eb2f90b814c..e6a3fb66f9d 100644 --- a/src/desktop/terminalWindowManager.ts +++ b/src/desktop/terminalWindowManager.ts @@ -24,6 +24,7 @@ export class TerminalWindowManager { private windows = new Map>(); // workspaceId -> Set of windows private windowCount = 0; // Counter for unique window IDs private readonly config: Config; + private onSessionWindowClosed: ((sessionId: string) => void) | null = null; constructor( config: Config, @@ -34,6 +35,11 @@ export class TerminalWindowManager { this.config = config; } + /** Called with the session ID when the user closes a pop-out window that showed a session. */ + setSessionWindowClosedHandler(handler: (sessionId: string) => void): void { + this.onSessionWindowClosed = handler; + } + /** * Open a new terminal window for a workspace * Multiple windows can be open for the same workspace @@ -117,6 +123,15 @@ export class TerminalWindowManager { } } log.info(`Terminal window ${windowId} closed for workspace: ${workspaceId}`); + // 'closed' fires only when the window really goes away: its reload and a renderer crash + // keep the window, so the session survives those. + if (sessionId) { + try { + this.onSessionWindowClosed?.(sessionId); + } catch (err) { + log.error(`Failed to end terminal session ${sessionId} after its window closed:`, err); + } + } }); // Load the terminal page diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 59c2f6e0f16..26fbaf16174 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -2492,7 +2492,9 @@ export const router = (authToken?: string) => { attach: t .input(schemas.terminal.attach.input) .output(schemas.terminal.attach.output) - .handler(({ context, input, signal }) => attachTerminal(context, input.sessionId, signal)), + .handler(({ context, input, signal }) => + attachTerminal(context, input.sessionId, signal, input.popout === true) + ), onExit: t .input(schemas.terminal.onExit.input) .output(schemas.terminal.onExit.output) diff --git a/src/node/orpc/routerSubscriptions.test.ts b/src/node/orpc/routerSubscriptions.test.ts index 3267cef021e..3e78582c41b 100644 --- a/src/node/orpc/routerSubscriptions.test.ts +++ b/src/node/orpc/routerSubscriptions.test.ts @@ -14,6 +14,7 @@ import { SUBSCRIPTION_HEARTBEAT_INTERVAL_MS } from "@/constants/orpcSubscription import { disposeAppRuntime, makeAppRuntime } from "@/node/services/di/appRuntime"; import type { ORPCContext } from "./context"; import { + attachTerminal, subscribeWorkspaceActivity, subscribeDesignExperiment, subscribeMetadata, @@ -246,3 +247,43 @@ test("Design subscriptions publish sibling changes only after client shutdown", await stream.return(undefined); } }); + +test("a pop-out attach hides its session from listSessions until the stream ends (#5673)", async () => { + const app = makeAppRuntime(TestClock.layer()); + const sessions = ["popped"]; + let popoutAttaches = 0; + const terminalService = { + onOutput: () => () => undefined, + getScreenState: () => "", + markPopoutAttached: () => { + popoutAttaches++; + return () => { + popoutAttaches--; + }; + }, + }; + // What listSessions returns, given TerminalService's filter. + const listed = () => (popoutAttaches > 0 ? [] : sessions); + const context = { "effect/context": app.context, terminalService } as unknown as ORPCContext; + + const sidebar = new AbortController(); + const sidebarStream = attachTerminal(context, "popped", sidebar.signal); + const popout = new AbortController(); + const popoutStream = attachTerminal(context, "popped", popout.signal, true); + try { + expect((await sidebarStream.next()).value).toEqual({ type: "screenState", data: "" }); + expect((await popoutStream.next()).value).toEqual({ type: "screenState", data: "" }); + // A main-window reload now finds nothing to adopt as a sidebar tab. + expect(listed()).toEqual([]); + + // The pop-out crashed or reloaded: its connection drops and the session can come back. + popout.abort(); + await popoutStream.return(undefined); + expect(listed()).toEqual(["popped"]); + } finally { + sidebar.abort(); + popout.abort(); + await sidebarStream.return(undefined); + await disposeAppRuntime(app.managed); + } +}); diff --git a/src/node/orpc/routerSubscriptions.ts b/src/node/orpc/routerSubscriptions.ts index 4cef42a3126..57e22b1dce2 100644 --- a/src/node/orpc/routerSubscriptions.ts +++ b/src/node/orpc/routerSubscriptions.ts @@ -672,13 +672,23 @@ export function subscribeTerminalOutput( export function attachTerminal( context: ORPCContext, sessionId: string, - signal?: AbortSignal + signal?: AbortSignal, + popout = false ): AsyncGenerator { // Output subscribes before screen capture so attach cannot lose bytes in the handshake. return runtimeSubscription(context, { signal, - subscribe: (emit) => - context.terminalService.onOutput(sessionId, (data) => emit.push({ type: "output", data })), + subscribe: (emit) => { + const unsubscribe = context.terminalService.onOutput(sessionId, (data) => + emit.push({ type: "output", data }) + ); + // The stream's end (window closed, crashed, reloaded or disconnected) releases the mark. + const releasePopout = popout ? context.terminalService.markPopoutAttached(sessionId) : null; + return () => { + releasePopout?.(); + unsubscribe(); + }; + }, initial: () => ({ type: "screenState" as const, data: context.terminalService.getScreenState(sessionId), diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts index 5bf9ded42e6..bf948891581 100644 --- a/src/node/services/terminalService.test.ts +++ b/src/node/services/terminalService.test.ts @@ -121,6 +121,7 @@ const closeTerminalWindowMock = mock(() => { const mockWindowManager = { openTerminalWindow: openTerminalWindowMock, closeTerminalWindow: closeTerminalWindowMock, + setSessionWindowClosedHandler: () => undefined, } as unknown as TerminalWindowManager; describe("TerminalService", () => { @@ -654,6 +655,51 @@ describe("TerminalService", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockPTYService.createSession as any) = createSessionMock; }); + describe("pop-out attachments (#5673)", () => { + it("a session with a live pop-out is not listed, so the sidebar does not adopt it", () => { + getWorkspaceSessionIdsMock.mockImplementation( + () => ["popped", "sidebar"] as unknown as never[] + ); + + const release = service.markPopoutAttached("popped"); + expect(service.getWorkspaceSessionIds("ws-1")).toEqual(["sidebar"]); + + // The pop-out's attach stream ended (window closed, crashed or reloaded): the session can + // come back, for example as a sidebar tab on the next load. + release(); + expect(service.getWorkspaceSessionIds("ws-1")).toEqual(["popped", "sidebar"]); + }); + + it("the session stays hidden until every pop-out attach for it has ended", () => { + getWorkspaceSessionIdsMock.mockImplementation(() => ["popped"] as unknown as never[]); + + const releaseFirst = service.markPopoutAttached("popped"); + const releaseSecond = service.markPopoutAttached("popped"); + releaseFirst(); + releaseFirst(); + expect(service.getWorkspaceSessionIds("ws-1")).toEqual([]); + + releaseSecond(); + expect(service.getWorkspaceSessionIds("ws-1")).toEqual(["popped"]); + }); + + it("closing the desktop pop-out window ends its session", () => { + let onClosed: ((sessionId: string) => void) | undefined; + const windowManager = { + ...mockWindowManager, + setSessionWindowClosedHandler: (handler: (sessionId: string) => void) => { + onClosed = handler; + }, + } as unknown as TerminalWindowManager; + service.setTerminalWindowManager(windowManager); + const closeSpy = spyOn(service, "close"); + + onClosed?.("popped"); + + expect(closeSpy.mock.calls).toEqual([["popped"]]); + }); + }); + describe("terminal activity tracking", () => { let capturedOnData: ((data: string) => void) | undefined; let capturedOnExit: ((code: number) => void) | undefined; diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index e428f24eaa3..84deb356bb5 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -97,6 +97,13 @@ export class TerminalService { // Per-session activity tracking for sidebar indicator. // Maps sessionId -> { workspaceId, isRunning (derived from terminal title) }. private readonly sessionActivity = new Map(); + /** + * Live pop-out window attach streams per session (#5673). listSessions skips these sessions so + * a main-window reload does not adopt a pop-out's terminal as a right-sidebar tab. In memory + * only: each count drops when its attach stream ends (pop-out closed, crashed or reloaded), and + * a backend restart starts with none. + */ + private readonly popoutAttachCounts = new Map(); // In-flight create() reservations per workspace (see create): counted before any await so // archive admission gates observe startups that have not yet registered a session. private readonly pendingSessionCreations = new Map(); @@ -240,6 +247,25 @@ export class TerminalService { setTerminalWindowManager(manager: TerminalWindowManager) { this.terminalWindowManager = manager; + // Closing a desktop pop-out window ends its session, like closing a sidebar terminal tab + // (which does not ask first either). Its reload and a crash do not close the window. + manager.setSessionWindowClosedHandler((sessionId) => this.close(sessionId)); + } + + /** Mark a session as shown in a pop-out window; call the returned function when it detaches. */ + markPopoutAttached(sessionId: string): () => void { + this.popoutAttachCounts.set(sessionId, (this.popoutAttachCounts.get(sessionId) ?? 0) + 1); + let released = false; + return () => { + if (released) return; + released = true; + const remaining = (this.popoutAttachCounts.get(sessionId) ?? 1) - 1; + if (remaining > 0) { + this.popoutAttachCounts.set(sessionId, remaining); + } else { + this.popoutAttachCounts.delete(sessionId); + } + }; } /** @@ -1382,8 +1408,11 @@ export class TerminalService { * Get all session IDs for a workspace. * Used by frontend to discover existing sessions to reattach to after reload. */ + /** Sessions the main window may show as sidebar tabs: those without a live pop-out. */ getWorkspaceSessionIds(workspaceId: string): string[] { - return this.ptyService.getWorkspaceSessionIds(workspaceId); + return this.ptyService + .getWorkspaceSessionIds(workspaceId) + .filter((sessionId) => !this.popoutAttachCounts.has(sessionId)); } private getTrackedSessionIdsForWorkspace(workspaceId: string): string[] { From f71434bfe2017e900dc601fb3552aae744bfa41c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 6 Oct 2026 12:19:28 +0000 Subject: [PATCH 2/4] fix: fade the edges of sideways-scrolling rows; show full skill descriptions --- .../ChatPane/WorkspaceFooterBar.tsx | 2 +- .../SkillIndicator/SkillIndicator.tsx | 4 +- .../RightSidebar/Timeline/TimelinePanel.tsx | 2 +- src/browser/styles/globals.css | 46 +++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/browser/components/ChatPane/WorkspaceFooterBar.tsx b/src/browser/components/ChatPane/WorkspaceFooterBar.tsx index 68d7130f14a..61354053ac4 100644 --- a/src/browser/components/ChatPane/WorkspaceFooterBar.tsx +++ b/src/browser/components/ChatPane/WorkspaceFooterBar.tsx @@ -400,7 +400,7 @@ export const WorkspaceFooterBar: React.FC = (props) => > {/* min-h rather than a fixed height: mobile raises these buttons to 44px touch targets, and a capped row would clip them along the same axis overflow-x-auto makes scrollable. */} -
+
= (props) => { )} {isLoaded && } - + {/* Full text (#5697): the list already scrolls, and a clamp left no way to read + the rest. */} + {skill.description}
diff --git a/src/browser/features/RightSidebar/Timeline/TimelinePanel.tsx b/src/browser/features/RightSidebar/Timeline/TimelinePanel.tsx index 107da0f2ab4..e2dbf70c6c3 100644 --- a/src/browser/features/RightSidebar/Timeline/TimelinePanel.tsx +++ b/src/browser/features/RightSidebar/Timeline/TimelinePanel.tsx @@ -813,7 +813,7 @@ export function TimelinePanelView(props: TimelinePanelViewProps) { }} >
-
+
{FILTERS.map((item) => (