From cfcf186381eff203ea3b340e3d55a648666557e9 Mon Sep 17 00:00:00 2001 From: BlitzOS Upstream Prep Date: Wed, 2 Sep 2026 00:27:17 +0000 Subject: [PATCH] feat(components): add an opt-in readOnly mode to the session surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host may embed a session that the current viewer is allowed to read but not drive. There is no such mode today: every member of a workspace may drive every session they can see, so `SessionChatInterface` has no notion of a viewer. Add `readOnly` to `SessionChatInterface`, defaulting to false. With it on the composer is not rendered, and neither is `FloatingPermissionRequest` — its options are answers, and an answer this viewer cannot write is a button that does nothing. The request still appears in the transcript. `SessionDetail` declares and forwards the same prop to every chat surface it mounts. The two suppressions the component already has, `isArchivedSession` and `isMachineRemoved`, were considered and are not reusable: both put a statement on the screen that would be false here, and both change the header copy as well as the composer. The prop is presentation only — enforcement belongs wherever the writes are applied — but a control that cannot work should not be offered. With the prop absent every call site renders exactly what it rendered before, and no existing call site passes it. The header's "…" menu still offers archive, delete, rename and fork; widening the prop to the menu is a larger change through `headerVariant="toolbar"` and is a follow-up rather than part of this one. Model: claude-opus-5[1m] --- .../sessions/session-chat-interface.tsx | 31 ++++++++++++++----- .../components/sessions/session-detail.tsx | 6 ++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index c16dd80fc..bee5daa0f 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -1728,6 +1728,16 @@ interface SessionChatInterfaceProps { subHeader?: React.ReactNode; /** When true, hide the message area and input but keep the header and subHeader visible */ hideMessageArea?: boolean; + /** + * When true, this surface follows the session without driving it: the + * composer and the permission request's response buttons are not rendered. + * + * For a host that mounts a session the viewer may read but not write. It is + * presentation only — a viewer who cannot write is enforced wherever the + * writes are applied, not here — but a control that cannot work should not be + * offered. + */ + readOnly?: boolean; /** When false, keep the local doc mounted without holding the remote room subscription. */ syncEnabled?: boolean; /** @@ -1897,6 +1907,7 @@ export const SessionChatInterface = memo( titleSyncing, subHeader, hideMessageArea = false, + readOnly = false, syncEnabled = !hideMessageArea, isVisible = true, isExternalHistoryRefreshing = false, @@ -5742,12 +5753,18 @@ export const SessionChatInterface = memo( - {/* Floating permission request - shown when session is waiting for permission */} - + {/* Floating permission request - shown when session is waiting for permission. + A read-only surface does not render it: its options are + answers, and an answer this viewer cannot write is a + button that does nothing. The request still appears in the + transcript. */} + {readOnly ? null : ( + + )} {/* Notification permission prompt - shown when session becomes idle (turn completed) */} {/* TODO(analytics): session/notification_prompt_shown|_permission_granted|_permission_denied. @@ -5856,7 +5873,7 @@ export const SessionChatInterface = memo( {/* Input area - isolated component to prevent full re-renders on typing. Hidden while a permission is pending so the response buttons claim the bottom surface; chat queue is bypassed for the same reason. */} - {shouldReplaceComposerWithPermission ? null : ( + {readOnly || shouldReplaceComposerWithPermission ? null : ( void; + /** Follow the session without driving it. Passed to every chat surface this + * page mounts; see `SessionChatInterfaceProps.readOnly`. */ + readOnly?: boolean; }) => { const { t } = useTranslation(); const router = useRouter(); @@ -4923,6 +4927,7 @@ const SessionDetail = ({ workspaceSession={activeSession} className="h-full" hideHeader + readOnly={readOnly} syncEnabled={isActive || pendingForkSourceId !== undefined} isVisible={isActive} isChildTab={tabSession.id !== sessionId} @@ -5550,6 +5555,7 @@ const SessionDetail = ({ workspaceSession: activeSession, className: 'h-full', hideHeader: true, + readOnly, syncEnabled: isActive || pendingForkSourceId !== undefined, isVisible, onFileDiffClick: handleOpenFileDiffForChat,