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.
This commit is contained in:
Brooklyn Nicholson 2026-06-29 12:06:25 -05:00
parent f1345290ed
commit 7cf6758e33
5 changed files with 55 additions and 16 deletions

View file

@ -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<ModelOptionsResponse>({

View file

@ -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.
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
<div
className={cn(clampActive && 'sticky-human-clamp')}
data-clamped={clampActive && bodyClamped ? 'true' : undefined}
>
{/* 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<{
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
<div className="relative w-full">
{/* Always editable clicking opens the edit composer even while a
turn streams; sending the edit reverts (interrupt + rewind). */}
<ActionBarPrimitive.Edit asChild>
{readOnly ? (
// Spectator transcript: clicking only toggles the clamp so the
// full prompt is readable — never opens an edit composer.
<button
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
onPointerDown={() => notifyThreadEditOpen()}
title={copy.editMessage}
aria-expanded={bodyClamped ? expanded : undefined}
className={cn(bubbleClassName, !bodyClamped && 'cursor-default')}
onClick={() => {
if (!bodyClamped) {
return
}
triggerHaptic('selection')
setExpanded(value => !value)
}}
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
type="button"
>
{bubbleContent}
</button>
</ActionBarPrimitive.Edit>
) : (
// Always editable — clicking opens the edit composer even while a
// turn streams; sending the edit reverts (interrupt + rewind).
<ActionBarPrimitive.Edit asChild>
<button
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
onPointerDown={() => notifyThreadEditOpen()}
title={copy.editMessage}
type="button"
>
{bubbleContent}
</button>
</ActionBarPrimitive.Edit>
)}
{(showStop || showRestore) && (
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
{showStop ? (
@ -1171,7 +1203,10 @@ const UserMessage: FC<{
)}
</div>
<BranchPickerPrimitive.Root
className="checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)"
className={cn(
'checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)',
readOnly && 'hidden'
)}
hideWhenSingleBranch
>
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />

View file

@ -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',

View file

@ -1657,6 +1657,7 @@ export interface Translations {
stopReading: string
readAloud: string
editMessage: string
expandMessage: string
scrollToBottom: string
stop: string
restorePrevious: string

View file

@ -2177,6 +2177,7 @@ export const zh: Translations = {
stopReading: '停止朗读',
readAloud: '朗读',
editMessage: '编辑消息',
expandMessage: '展开消息',
scrollToBottom: '滚动到底部',
stop: '停止',
restorePrevious: '恢复上一个检查点',