From 44e5848e7418a7f7909d59aea2066a55f1096378 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 16 Jun 2026 13:30:11 -0500 Subject: [PATCH] feat(desktop): stream subagent activity into watch windows (#47060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): stream subagent replies into watch windows A desktop watch window resumes a child session lazily (no full agent) and mirrors the parent-relayed `subagent.*` events into native child-session stream events. The child's streamed reply text was never relayed, so the window sat blank while the subagent "talked". - delegate_tool: forward the child's `run_conversation` stream tokens up the progress relay as `subagent.text` (inert under CLI/TUI — their progress handlers ignore non-tool event types; only a gateway watch window mirrors it). - server: mirror `subagent.text` -> `message.delta` on the child sid only, and skip the parent emit (per-token frames are meaningless on the parent session, which shows the child via the spawn tree). Demote `subagent.start` to a one-time goal header and drop the noisy `subagent.progress` mirror — tools already mirror natively. - server: guard `_start_agent_build` so a lazy watch session spectating an in-flight child stays lazy; incidental RPCs were upgrading it to a full agent mid-stream and silently killing the mirror. * fix(desktop): keep watch-window chat clear of titlebar chrome Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) hide the titlebar tool cluster + session header, so the transcript ran to the window's top edge and streamed text slid up under the OS traffic lights. - Gate the hidden chrome on `isSecondaryWindow()` everywhere (app-shell, chat header, thread list) instead of the narrower new-session flag. - Add a fixed opaque drag-strip at the top of the secondary-window transcript: content padding alone scrolls away with the text, so the strip masks anything behind it and keeps the window draggable like the main header. * fix: WSL subagent window * fix: subagent window top padding --------- Co-authored-by: Austin Pickett Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- apps/desktop/src/app/chat/index.tsx | 10 +- apps/desktop/src/app/shell/app-shell.tsx | 7 +- .../components/assistant-ui/thread-list.tsx | 34 ++++-- tests/tools/test_delegate.py | 4 +- ...st_delegate_subagent_timeout_diagnostic.py | 2 +- tests/tui_gateway/test_protocol.py | 105 ++++++++++++++++++ .../tui_gateway/test_subagent_child_mirror.py | 60 +++++++++- tools/delegate_tool.py | 21 ++++ tui_gateway/server.py | 44 +++++++- 9 files changed, 261 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 63983caaa1..8982b14d5e 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -42,7 +42,7 @@ import { $sessions, sessionPinId } from '@/store/session' -import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' +import { isSecondaryWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' @@ -121,10 +121,10 @@ function ChatHeader({ ? pinnedSessionIds.includes(selectedSessionId) : false - // A brand-new session has no session to pin/delete/rename, so the header is - // just a dead "New session" label + chevron. Drop it (and its border) - // entirely until there's a real session to act on. - if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) { + // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) + // are compact side panels — they drop the session-actions header + border + // entirely. A brand-new draft has nothing to pin/delete/rename either. + if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) { return null } diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index ade1f8a3c3..7cbcaacfb4 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -16,7 +16,7 @@ import { } from '@/store/layout' import { $paneWidthOverride } from '@/store/panes' import { $connection } from '@/store/session' -import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' +import { isSecondaryWindow } from '@/store/windows' import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants' @@ -80,7 +80,10 @@ export function AppShell({ const connection = useStore($connection) const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen - const hideTitlebarControls = isNewSessionWindow() + // Every secondary window (new-session scratch, subagent watch, cmd-click + // pop-out) is a compact side panel — none of them carry the full titlebar + // tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag. + const hideTitlebarControls = isSecondaryWindow() const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen) // Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero // on macOS, where window controls sit on the left and are reported via diff --git a/apps/desktop/src/components/assistant-ui/thread-list.tsx b/apps/desktop/src/components/assistant-ui/thread-list.tsx index e3faf64547..8c98b88a59 100644 --- a/apps/desktop/src/components/assistant-ui/thread-list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-list.tsx @@ -22,7 +22,7 @@ import { resetThreadScroll, setThreadAtBottom } from '@/store/thread-scroll' -import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' +import { isSecondaryWindow } from '@/store/windows' import { MessageRenderBoundary } from './message-render-boundary' @@ -134,13 +134,20 @@ const ThreadMessageListInner: FC = ({ const hiddenCount = firstVisible const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups const restoreFromBottomRef = useRef(null) - const newSessionWindow = isNewSessionWindow() - const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)' - const threadContentTopPad = newSessionWindow + // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) + // hide the titlebar tool cluster + session header, but the OS traffic lights + // still sit in the top-left, so reserve the titlebar gap above the transcript. + const secondaryWindow = isSecondaryWindow() + // NB: CSS calc() requires whitespace around the +/- operator. This string is + // assigned verbatim to the --sticky-human-top inline style below (it does not + // go through Tailwind, which would auto-space it), so the spaces are load- + // bearing — without them the declaration is invalid, gets dropped, and the + // sticky user bubble falls back to its ~4px default and slides under the OS + // traffic lights. + const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)' + const threadContentTopPad = secondaryWindow ? 'pt-[calc(var(--titlebar-height)+0.75rem)]' - : isSecondaryWindow() - ? 'pt-6' - : 'pt-[calc(var(--titlebar-height)-0.5rem)]' + : 'pt-[calc(var(--titlebar-height)-0.5rem)]' useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom]) useEffect(() => () => resetThreadScroll(), []) @@ -247,10 +254,21 @@ const ThreadMessageListInner: FC = ({ style={ { height: clampToComposer ? 'var(--thread-viewport-height)' : '100%', - ...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {}) + ...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {}) } as CSSProperties } > + {secondaryWindow && ( + // Secondary windows hide the titlebar chrome, so the scroller runs to + // the window's top edge and streamed text slides up under the OS + // traffic lights. Content padding alone scrolls away with the text — a + // fixed opaque strip (the titlebar's drag region) masks anything behind + // it and keeps the window draggable, matching the main window's header. +