From 68317d7c6447a962f2f94b71fe6dff06f11d9443 Mon Sep 17 00:00:00 2001 From: BlitzOS Upstream Prep Date: Wed, 2 Sep 2026 00:28:35 +0000 Subject: [PATCH] feat(components): opt-in props for surfaces an embedding host does not serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three groups of controls render on a hosted or single-member composition and cannot work there, and none of them has a capability that answers the question. Each is a new optional prop defaulting to today's behaviour; no existing call site passes any of them. - `SessionChatInterface.hideCloudMenuItems`, forwarded to `SessionHeaderMenu`: drops "Change owner", "Share with team" and "Copy URL". A single-member workspace has no second member to hand a session to, a host may serve sharing from its own chrome, and "Copy URL" builds a deep link and toasts success either way. Each row's existing gate (`owner`, `sharing`, `onCopyUrl`) answers a different question. - `SessionChatInterface.hideNotificationPrompt`: for a host that mounts no push provider, so Enable asks the browser for a permission nothing consumes. - `SessionChatInterface.hideAgentRoles` and `ChatLanding.hideAgentRoles`, both forwarded to the composer's run-config menu: for a workspace catalog with no Roles and no writer for one, where an empty list renders a "New role" entry opening an editor whose save has nowhere to land. - `ChatLanding.hideProductHints`: the hint band's `no-machine` state resolves `download-client` outside Electron, so a hosted surface tells the member to install the desktop app; `no-agent-config` offers "Go to Settings", which on a host that mounts no settings surface only flips an atom. - `SessionDetail.keyboardShortcutsAvailable`: the composer draws its ⌘L discovery chip from the `session.focusInput` registration, so a host that never calls `commands.attach(window)` advertises a chord nothing answers. Passed as `useCommand`'s existing second argument. `SessionDetail` declares and forwards the three chat-surface props to every surface it mounts. Model: claude-opus-5[1m] --- .../src/components/chat/chat-landing.tsx | 58 +++++++++++----- .../sessions/session-chat-input-area.tsx | 13 +++- .../sessions/session-chat-interface.tsx | 66 +++++++++++++++---- .../components/sessions/session-detail.tsx | 33 +++++++++- 4 files changed, 139 insertions(+), 31 deletions(-) diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx index 369bb4dcd..70f7c8532 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -380,6 +380,24 @@ interface ChatLandingProps { * chat route only; mobile keeps its base-context model. */ onSelectionUrlSync?: (search: ChatLandingSearch) => void; + /** + * Draw no product hint band above the composer. + * + * The band has two states and a host may serve neither. `no-machine` resolves + * `download-client` outside Electron, so it tells a member on a hosted surface + * to install the desktop app, beside a Report-a-bug button that uploads to + * Lody and a link to Lody's Discord. `no-agent-config` offers "Go to Settings", + * which on a host that mounts no settings surface only flips an atom. + * + * Off by default, so every upstream call site keeps both states. + */ + hideProductHints?: boolean; + /** + * Drop the run-config menu's Agent Role row; see + * `SessionChatInputAreaProps.hideAgentRoles`, which is the same prop on the + * session composer. + */ + hideAgentRoles?: boolean; resetDraftKey?: string; resetDraftOnKeyChange?: boolean; } @@ -557,6 +575,8 @@ function WorkspaceChatLanding({ preSelectedProject, preSelectedRepo, onSelectionUrlSync, + hideProductHints = false, + hideAgentRoles = false, resetDraftKey, resetDraftOnKeyChange = true, }: ChatLandingProps) { @@ -3771,14 +3791,18 @@ function WorkspaceChatLanding({ onRecentRunConfigSelect={handleRecentRunConfigSelect} modeOptions={modeOptions} selectedModeId={selectedModeId} - agentRoles={{ - items: composerAgentRoleItems, - selectedRoleId: activeAgentRole?.id ?? null, - onSelect: handleAgentRoleSelect, - onCreate: handleAgentRoleCreate, - onEdit: handleAgentRoleEdit, - machine: scopedMachineId ? (machines.get(scopedMachineId) ?? null) : null, - }} + agentRoles={ + hideAgentRoles + ? undefined + : { + items: composerAgentRoleItems, + selectedRoleId: activeAgentRole?.id ?? null, + onSelect: handleAgentRoleSelect, + onCreate: handleAgentRoleCreate, + onEdit: handleAgentRoleEdit, + machine: scopedMachineId ? (machines.get(scopedMachineId) ?? null) : null, + } + } /> {/* Permission is part of what a Role pins, so behind one it stops being a separate control and is stated in the Role's own face instead. It @@ -4077,12 +4101,16 @@ function WorkspaceChatLanding({ cliType: selectedConfig?.cliType, agentType: selectedConfig?.agentType, }} - agentRoles={{ - items: composerAgentRoleItems, - selectedRoleId: activeAgentRole?.id ?? null, - onSelect: handleAgentRoleSelect, - onCreate: handleAgentRoleCreate, - }} + agentRoles={ + hideAgentRoles + ? undefined + : { + items: composerAgentRoleItems, + selectedRoleId: activeAgentRole?.id ?? null, + onSelect: handleAgentRoleSelect, + onCreate: handleAgentRoleCreate, + } + } /> (function SessionChatInputArea( { session, + hideAgentRoles = false, sessionLocalProjectRootPath, isMachineRemoved, canStopAgent = false, @@ -2165,7 +2174,7 @@ export const SessionChatInputArea = memo( configOptionValues={configOptionValues} onConfigOptionChange={onConfigOptionChange} fallbackAgent={{ cliType: session.cliType, agentType: session.agentType }} - agentRoles={agentRolesProp} + agentRoles={hideAgentRoles ? undefined : agentRolesProp} /> ) : null; const desktopAgentMachineIds = useMemo( @@ -2198,7 +2207,7 @@ export const SessionChatInputArea = memo( onConfigOptionChange={onConfigOptionChange} modeOptions={modeOptions} selectedModeId={selectedModeId} - agentRoles={agentRolesProp} + agentRoles={hideAgentRoles ? undefined : agentRolesProp} /> {sessionAgentRolePinsPermissionMode ? null : ( void | Promise; onDelete?: () => void | Promise; compact?: boolean; + /** + * Drop the three rows that only mean something inside a Lody CLOUD workspace: + * "Change owner", "Share with team" and "Copy URL". + * + * A host that mounts this menu against a single-member local workspace has no + * second member to hand a Session to, serves sharing from its own chrome, and + * has no cloud address for "Copy URL" to build — that last one copies a deep + * link from the daemon slug and toasts success either way, which is the worst + * of the three. Each row's existing gate (`owner`, `sharing`, `onCopyUrl`) + * answers a different question and none of them answers this one. + */ + hideCloudMenuItems?: boolean; t: SessionSharingTranslator; }) { const isArchived = !!session.isArchived; @@ -1340,7 +1353,7 @@ export function SessionHeaderMenu({ )} - {owner && !isArchived ? ( + {owner && !isArchived && !hideCloudMenuItems ? ( @@ -1385,7 +1398,7 @@ export function SessionHeaderMenu({ {/* Copy URL stays in the Copy submenu even for private sessions (the link still works for the owner); sharing is a separate action that only appears while the conversation isn't team-visible. */} - {sharing && sharing.visibility !== 'team' ? ( + {sharing && sharing.visibility !== 'team' && !hideCloudMenuItems ? ( { @@ -1470,14 +1483,16 @@ export function SessionHeaderMenu({ {t('sessions.copyAsMarkdown', 'Copy as Markdown')} - { - void onCopyUrl(); - }} - > - - {t('sessions.copyUrl', 'Copy URL')} - + {hideCloudMenuItems ? null : ( + { + void onCopyUrl(); + }} + > + + {t('sessions.copyUrl', 'Copy URL')} + + )} @@ -1728,6 +1743,24 @@ interface SessionChatInterfaceProps { subHeader?: React.ReactNode; /** When true, hide the message area and input but keep the header and subHeader visible */ hideMessageArea?: boolean; + /** Forwarded to `SessionHeaderMenu`; see the prop's doc comment there. */ + hideCloudMenuItems?: boolean; + /** + * Drop the notification permission prompt. + * + * For a host that mounts no push provider. The prompt's Enable button asks the + * browser for a permission nothing then consumes, and its "Don't remind me" + * writes a preference for a prompt that should not have appeared. + */ + hideNotificationPrompt?: boolean; + /** + * Drop the composer's Agent Role row. + * + * For a host whose workspace catalog carries no Roles and no way to write one: + * the row then renders its own empty state — a "New role" entry opening an + * editor whose save has nowhere to land — rather than disappearing. + */ + hideAgentRoles?: boolean; /** When false, keep the local doc mounted without holding the remote room subscription. */ syncEnabled?: boolean; /** @@ -1897,6 +1930,9 @@ export const SessionChatInterface = memo( titleSyncing, subHeader, hideMessageArea = false, + hideCloudMenuItems = false, + hideNotificationPrompt = false, + hideAgentRoles = false, syncEnabled = !hideMessageArea, isVisible = true, isExternalHistoryRefreshing = false, @@ -5568,6 +5604,7 @@ export const SessionChatInterface = memo( } } onOpenReviewSettings={() => openSettings('preferences')} + hideCloudMenuItems={hideCloudMenuItems} owner={ownerMenuState} openedByRelations={openedByRelations} onArchive={onArchiveSession} @@ -5754,9 +5791,11 @@ export const SessionChatInterface = memo( Visibility + enable/dismiss live inside NotificationPermissionPrompt (owned elsewhere) and the actual grant/deny resolves on the settings page, so these must be emitted from that component via onShown/onEnableClicked/onDismissed callbacks (see crossFileNeeds). */} - + {hideNotificationPrompt ? null : ( + + )} {/* An active auto-review run states itself here rather than only in the "…" menu: the failure mode worth designing @@ -5860,6 +5899,7 @@ export const SessionChatInterface = memo( void; + /** Passed to every chat surface this page mounts; see + * `SessionChatInterfaceProps.hideCloudMenuItems`. */ + hideCloudMenuItems?: boolean; + /** Passed to every chat surface this page mounts; see + * `SessionChatInterfaceProps.hideNotificationPrompt`. */ + hideNotificationPrompt?: boolean; + /** Passed to every chat surface this page mounts; see + * `SessionChatInterfaceProps.hideAgentRoles`. */ + hideAgentRoles?: boolean; + /** + * Whether the host answers keyboard commands at all. + * + * This page registers `session.focusInput`, and the composer reads that + * registration to draw a ⌘L discovery chip. A host that never calls + * `commands.attach(window)` has no dispatcher, so the chip advertises a chord + * that does nothing. On by default, so every existing call site keeps the + * registration and the chip. + */ + keyboardShortcutsAvailable?: boolean; }) => { const { t } = useTranslation(); const router = useRouter(); @@ -3709,6 +3732,11 @@ const SessionDetail = ({ run: handleOpenSearch, }); + // The composer draws its ⌘L discovery chip from this registration + // (`chat-composer.tsx` reads `commands.getKeybindingsFor('session.focusInput')`), + // so a host with no keyboard dispatcher must not make it. Passing the flag as + // `useCommand`'s second argument keeps this to one changed line in a file the + // seam pin reads line by line. useCommand({ id: 'session.focusInput', title: t('commands.session.focusInput', 'Focus Current Input'), @@ -3716,7 +3744,7 @@ const SessionDetail = ({ keybindings: getCommandKeybindings('session.focusInput'), when: () => Boolean(chatRefsMap.current.get(activeTabSessionId)), run: handleFocusActiveInput, - }); + }, keyboardShortcutsAvailable); useCommand({ id: 'session.saveCurrentFile', @@ -5550,6 +5578,9 @@ const SessionDetail = ({ workspaceSession: activeSession, className: 'h-full', hideHeader: true, + hideCloudMenuItems, + hideNotificationPrompt, + hideAgentRoles, syncEnabled: isActive || pendingForkSourceId !== undefined, isVisible, onFileDiffClick: handleOpenFileDiffForChat,