From 7cf6758e336c67b1b90cb93928f609008a3a8e5e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 29 Jun 2026 12:06:25 -0500 Subject: [PATCH] feat(desktop): read-only spectator transcript for subagent watch windows Subagent session pop-outs (`watch=1`) spectate a run driven elsewhere, so editing/steering the transcript from there makes no sense. Gate the composer and the user-bubble mutations on `isWatchWindow()`: - hide the composer (folds into `showChatBar`) - user prompts become a read-only button that toggles the 2-line clamp so long prompts stay fully readable, instead of opening the edit composer - drop the stop/restore actions and the checkpoint branch-picker Keyed off the narrow `isWatchWindow()` (not `isSecondaryWindow()`), so the new-session and cmd-click pop-outs are unaffected. --- apps/desktop/src/app/chat/index.tsx | 7 ++- .../src/components/assistant-ui/thread.tsx | 61 +++++++++++++++---- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 5 files changed, 55 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index b61df2337b7..a5216210a79 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -45,7 +45,7 @@ import { $sessions, sessionPinId } from '@/store/session' -import { isSecondaryWindow } from '@/store/windows' +import { isSecondaryWindow, isWatchWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' @@ -342,8 +342,9 @@ export function ChatView({ const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser) // Hide the composer in the exhausted error state too: there's no live runtime - // to send to until a retry rebinds one. - const showChatBar = !loadingSession && !resumeExhausted + // to send to until a retry rebinds one. Watch windows are pure spectators of a + // subagent run driven elsewhere — no composer, transcript is read-only. + const showChatBar = !loadingSession && !resumeExhausted && !isWatchWindow() const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') const modelOptionsQuery = useQuery({ diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 706345f708a..24de1186638 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -104,6 +104,7 @@ import { $activeSessionAwaitingInput } from '@/store/prompts' import { $connection } from '@/store/session' import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scroll' import { $voicePlayback } from '@/store/voice-playback' +import { isWatchWindow } from '@/store/windows' type ThreadLoadingState = 'response' | 'session' interface RestoreMessageTarget { @@ -1020,6 +1021,13 @@ const UserMessage: FC<{ const lastClampHeightRef = useRef(-1) const lineHeightRef = useRef(0) + // Watch windows spectate a subagent run driven elsewhere — prompts can't be + // edited, restored, or stopped from here. The bubble stays a button that + // toggles the 2-line clamp so long prompts are still fully readable. + const readOnly = isWatchWindow() + const [expanded, setExpanded] = useState(false) + const clampActive = !(readOnly && expanded) + const measureClamp = useCallback((entries: readonly ResizeObserverEntry[]) => { const inner = clampInnerRef.current const outer = inner?.parentElement @@ -1070,11 +1078,11 @@ const UserMessage: FC<{ const hasBody = messageText.trim().length > 0 const isLatestUser = messageId === latestUserId - const showStop = isLatestUser && threadRunning && Boolean(onCancel) + const showStop = !readOnly && isLatestUser && threadRunning && Boolean(onCancel) // Restore (re-run this exact prompt) is available everywhere the Stop button // isn't — including mid-stream on older prompts, since the action interrupts // the live turn before rewinding. - const showRestore = !showStop && Boolean(onRequestRestoreConfirm) && hasBody + const showRestore = !readOnly && !showStop && Boolean(onRequestRestoreConfirm) && hasBody const bubbleClassName = cn( USER_BUBBLE_BASE_CLASS, @@ -1086,7 +1094,10 @@ const UserMessage: FC<{ // Render the user's text through a minimal markdown pipeline: // backtick `code` and ``` fenced ``` blocks, with directive chips // (`@file:` etc.) still resolved inside the plain-text spans. -
+
{/* Match the edit composer's collapsed line box (min-h-[1.25rem]) so clicking to edit can't grow the bubble by a sub-pixel and reflow the turn 1px. */} @@ -1114,20 +1125,41 @@ const UserMessage: FC<{
- {/* Always editable — clicking opens the edit composer even while a - turn streams; sending the edit reverts (interrupt + rewind). */} - + {readOnly ? ( + // Spectator transcript: clicking only toggles the clamp so the + // full prompt is readable — never opens an edit composer. - + ) : ( + // Always editable — clicking opens the edit composer even while a + // turn streams; sending the edit reverts (interrupt + rewind). + + + + )} {(showStop || showRestore) && (
{showStop ? ( @@ -1171,7 +1203,10 @@ const UserMessage: FC<{ )}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index f7239b83aae..499c547f640 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2010,6 +2010,7 @@ export const en: Translations = { stopReading: 'Stop reading', readAloud: 'Read aloud', editMessage: 'Edit message', + expandMessage: 'Expand message', scrollToBottom: 'Scroll to bottom', stop: 'Stop', restorePrevious: 'Restore previous checkpoint', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 742bdfa5e7c..41caaafcc96 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1657,6 +1657,7 @@ export interface Translations { stopReading: string readAloud: string editMessage: string + expandMessage: string scrollToBottom: string stop: string restorePrevious: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index aa02579771d..96a09810f9e 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2177,6 +2177,7 @@ export const zh: Translations = { stopReading: '停止朗读', readAloud: '朗读', editMessage: '编辑消息', + expandMessage: '展开消息', scrollToBottom: '滚动到底部', stop: '停止', restorePrevious: '恢复上一个检查点',