feat(chat): /btw — side questions in a temporary forked session (#2796)

* feat(chat): /btw command — side questions in a temporary forked session

/btw <question> forks the current session (full context inheritance) and
opens a compact peek panel docked above the composer. The composer itself
becomes the btw input while the panel is open: sends route to the fork,
the placeholder and a mode chip reflect the target, and the stop button
aborts the fork's turn. Closing the panel (or the chip's ✕) destroys the
fork, leaving the main conversation untouched.

The panel shows only the fork's own tail (messages at/after the fork
creation time) and live permission/question cards scoped to the fork.

- chat/btw/BtwPanel: peek sheet (desktop + mobile), fork-tail view,
  auto-close on disappearance, Esc to close
- lib/btw: startBtwSession (fork + rename + routed send), closeBtwPanel
  (close = destroy), filterBtwTailMessages
- ChatInput: btw-mode send routing via SendMessageOptions.sessionId,
  btw-aware activity (stop/abort), placeholder + mode chip
- useSessionActivity: exported for per-session activity reads
- i18n: btw keys across all 11 locales

* fix(chat): keep btw sends isolated

* refactor(chat): rework /btw into a metadata-scoped peek panel

- Link the active btw fork through the parent session's metadata
  (openchamber.btwSessionID) so the panel exists only in the session that
  invoked /btw, follows parent navigation, and survives reloads; the fork
  carries a kind:'btw' marker with its originalSessionID.
- Replace the wall-clock history boundary with the id of the newest cloned
  message (server-generated ascending ids), stored in fork metadata.
- Derive panel identity in useBtwPanelState; useBtwStore shrinks to
  transient per-parent UI state (collapsed/creating/destroying).
- Panel UX: dropdown-style glass surface, chat ScrollShadow, single
  title+chevron collapse toggle, muted header controls, promote action
  (keep as a full session and navigate to it), Esc collapses instead of
  destroying, reserved Working indicator row, streaming auto-follow via
  ResizeObserver keyed on content readiness.
- Add a 'peek' chat surface mode that suppresses per-message controls and
  turn footers inside the panel; user bubbles keep a small gap below.
- Hide btw forks from the sidebar, session switcher, and command palette
  until promoted; mark the fork before inserting it into local stores.
- Delete/archive lifecycle: removing the fork unlinks the parent; removing
  the parent also removes its temporary fork.
- patchSessionMetadata now mirrors updated sessions into live stores.
- Localize new strings across all 12 dictionaries; add unit tests for
  metadata helpers, the btw flow, and the UI store.

* fix(chat): clamp the btw panel below the app header when the keyboard is open

Reuse useMobileAutocompleteMaxHeight (the composer autocomplete precedent)
on the panel's scroll body, reserving the panel header and bottom spacer
height, so the sheet adapts to the visual viewport instead of riding under
the app header on mobile.

* fix(lint): drop unused destructured bindings in sessionBtwMetadata

CI eslint has no underscore ignore pattern; strip metadata keys with typed
copies and delete instead of discard-destructuring.

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Jay Gupta
2026-08-23 00:40:06 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent a317a156cb
commit 46426e8495
30 changed files with 1553 additions and 59 deletions
+99 -25
View File
@@ -34,6 +34,9 @@ import {
type ChatDraftSnapshot,
} from '@/lib/chatDraftPersistence';
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
import { BtwPanel } from './btw/BtwPanel';
import { useBtwPanelState } from './btw/useBtwPanelState';
import { destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { ToolPopupContent } from './message/types';
@@ -51,7 +54,7 @@ import { PendingChangesBar } from './PendingChangesBar';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { useCurrentSessionActivity, useSessionActivity } from '@/hooks/useSessionActivity';
import { toast } from '@/components/ui';
// useMessageStore removed — messages now come from sync system
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -315,6 +318,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const currentSessionDirectoryForSync = useSessionUIStore(
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
);
// btw mode: the CURRENT session's metadata links an active btw fork and
// the panel is expanded, so this composer's sends route to the fork
// instead of the main session. Collapsed keeps the fork alive (chip stays
// visible) while the composer talks to the main session again.
const btwPanel = useBtwPanelState(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory ?? undefined);
const btwSessionId = btwPanel.btwSessionId;
const btwDirectory = btwPanel.btwDirectory;
const btwSessionRef = React.useMemo<BtwSessionRef | null>(
() => (currentSessionId && btwSessionId && btwDirectory
? { parentSessionId: currentSessionId, btwSessionId, directory: btwDirectory }
: null),
[btwDirectory, btwSessionId, currentSessionId],
);
const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed;
const activeRuntimeKey = getRuntimeKey();
const chatDraftIdentity = React.useMemo(
() => createChatDraftIdentity(
@@ -563,7 +580,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const availableSkills = useSkillsStore((s) => s.skills);
const knownSlashNames = React.useMemo(() => {
const names = new Set<string>([
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'btw', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
]);
if (!isMobile && !isVSCodeRuntime()) names.add('handoff-review');
for (const command of availableCommands) names.add(command.name.toLowerCase());
@@ -833,8 +850,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
prevNewSessionDraftOpenRef.current = newSessionDraftOpen;
}, [newSessionDraftOpen, isMobile]);
// Session activity for queue availability and controls
const { phase: sessionPhase } = useCurrentSessionActivity();
// Session activity for queue availability and controls. In btw mode the
// composer controls the temporary fork, so the stop button and send-button
// state follow the FORK's activity; the queue affordance stays tied to the
// main session (queued messages always belong to the main chat).
const { phase: currentSessionPhase } = useCurrentSessionActivity();
const { phase: btwSessionPhase } = useSessionActivity(btwSessionId, btwDirectory ?? undefined);
const sessionPhase = isBtwActive ? btwSessionPhase : currentSessionPhase;
const autoReviewRunning = useAutoReviewStore(React.useCallback((state) => {
if (!currentSessionId) return false;
const run = state.runsByOriginalSessionID[currentSessionId];
@@ -1032,12 +1054,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
// queued-message auto-send hook delivers it as the next turn once the
// rejected turn winds down and the session returns to idle. This avoids
// aborting the turn (which would surface an "aborted" notice).
if (currentSessionId && !queuedOnly && autoReviewRunning) {
if (currentSessionId && !queuedOnly && autoReviewRunning && !isBtwActive) {
handleQueueMessage();
return;
}
if (currentSessionId && !queuedOnly) {
// btw mode: the child fork's blocking prompts are answered inside the
// panel; the composer send goes straight to the fork (routeMessage
// queues if the fork's own turn is busy).
if (currentSessionId && !queuedOnly && !isBtwActive) {
// Sending is authoritative for blocking prompts: deny pending
// permissions and dismiss open questions for the session subtree,
// then queue the message once if either was open. The deny/clear
@@ -1057,17 +1082,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
}
}
const sendMessageOptions: {
let sendMessageOptions: {
target?: NonNullable<typeof capturedTarget>;
sessionId?: string;
directory?: string;
draftSnapshot?: NonNullable<typeof capturedDraftSnapshot>;
delivery?: 'steer';
} | undefined = (capturedTarget || capturedDraftSnapshot || delivery)
? {
...(capturedTarget ? { target: capturedTarget } : {}),
...(capturedDraftSnapshot ? { draftSnapshot: capturedDraftSnapshot } : {}),
...(delivery ? { delivery } : {}),
}
: undefined;
} | undefined;
if (isBtwActive && btwSessionId && btwDirectory) {
sendMessageOptions = {
sessionId: btwSessionId,
directory: btwDirectory,
};
} else if (capturedTarget || capturedDraftSnapshot || delivery) {
sendMessageOptions = {};
if (capturedTarget) sendMessageOptions.target = capturedTarget;
if (capturedDraftSnapshot) sendMessageOptions.draftSnapshot = capturedDraftSnapshot;
}
if (delivery && sendMessageOptions) sendMessageOptions.delivery = delivery;
const preparedDocumentMentions = new Map<string, AttachedFile[]>();
const reservedFilenames = new Set([
@@ -1208,6 +1240,40 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
}
return;
}
if (commandName === 'btw' && currentSessionId) {
const question = argument.trim();
if (!question) {
toast.error(t('chat.btw.toast.emptyArgument'));
return;
}
const targetDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId)
|| currentDirectory
|| null;
if (!targetDirectory) {
toast.error(t('chat.btw.toast.createFailed'));
return;
}
try {
// A new btw replaces this session's current one: destroy
// the previous fork first so forks never accumulate.
if (btwSessionRef) {
await destroyBtwSession(btwSessionRef);
}
await startBtwSession({
parentSessionId: currentSessionId,
question,
directory: targetDirectory,
providerID: providerIdToSend,
modelID: modelIdToSend,
agent: agentNameToSend,
variant: variantToSend,
});
scrollToBottom?.();
} catch (error) {
toast.error(getSubmitErrorMessage(error, t('chat.btw.toast.createFailed')));
}
return;
}
// The rest render a visible prompt plus synthetic instructions and
// send them as one message.
@@ -1243,7 +1309,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
}
const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory;
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false);
// btw mode: the fork already carries the question plus full history,
// so the response-style instruction never applies there.
const shouldAddResponseStyle = !isBtwActive && (newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false));
if (shouldAddResponseStyle) {
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
if (responseStyleInstruction) {
@@ -1413,7 +1481,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
// Primary action for send/queue button — respects selected follow-up behavior
const handlePrimaryAction = React.useCallback(() => {
const inputSnapshot = getCurrentInputSnapshot();
const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
const canQueue = !isBtwActive && inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
if (followUpBehavior === 'queue' && canQueue) {
handleQueueMessage();
} else if (followUpBehavior === 'steer' && canQueue) {
@@ -1421,7 +1489,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
} else {
void handleSubmitRef.current();
}
}, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage]);
}, [inputMode, getCurrentInputSnapshot, currentSessionId, currentSessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage, isBtwActive]);
// Draft welcome presets: submit immediately.
const submitPresetPrompt = React.useCallback((text: string, type: 'command' | 'skill') => {
@@ -1637,7 +1705,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
// Queueing / steering only works when there's an existing busy
// session (or an active auto-review run).
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
const canQueue = !isBtwActive && inputMode === 'normal' && hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
if (followUpBehavior === 'queue') {
if (isCtrlEnter || !canQueue) {
@@ -1687,8 +1755,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
clearAbortPrompt();
startAbortIndicator();
void abortCurrentOperation(currentSessionId || undefined);
}, [abortCurrentOperation, clearAbortPrompt, currentSessionId, startAbortIndicator]);
// btw mode: the stop button stops the fork's turn, not the main
// session's.
const abortTarget = isBtwActive && btwSessionId ? btwSessionId : currentSessionId;
void abortCurrentOperation(abortTarget || undefined);
}, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive, startAbortIndicator]);
const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => {
const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction);
@@ -2826,11 +2897,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
}}
onFocus={mobileShell.onEditorFocus}
onBlur={mobileShell.onEditorBlur}
placeholder={currentSessionId || newSessionDraftOpen
? inputMode === 'shell'
? t('chat.chatInput.placeholder.shell')
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
: t('chat.chatInput.placeholder.selectSession')}
placeholder={isBtwActive
? t('chat.btw.mainComposerPlaceholder')
: currentSessionId || newSessionDraftOpen
? inputMode === 'shell'
? t('chat.chatInput.placeholder.shell')
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
: t('chat.chatInput.placeholder.selectSession')}
editable={Boolean(currentSessionId || newSessionDraftOpen)}
autoCorrect={isMobile}
autoCapitalize={isMobile ? 'sentences' : 'none'}
@@ -2929,6 +3002,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
className={cn('chat-input-column mt-4', draftPresentationClassName)}
/>
) : null}
{currentSessionId ? <BtwPanel parentSessionId={currentSessionId} panel={btwPanel} /> : null}
</form>
{/* Issue Picker Dialog */}
@@ -12,6 +12,7 @@ import { useSelectionStore } from '@/sync/selection-store';
import { useDeviceInfo } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import MessageBody from './message/MessageBody';
@@ -202,6 +203,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
const isUser = messageRole.isUser;
const chatSurfaceMode = useChatSurfaceMode();
const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader);
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
@@ -1044,7 +1046,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
respectReducedMotion
>
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined)}>
{/* peek: the action row under the bubble is suppressed, so
reserve its gap to the next message here, OUTSIDE the
bubble background. */}
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined, chatSurfaceMode === 'peek' ? 'pb-3' : undefined)}>
<div
style={{
backgroundColor: 'var(--chat-user-message-bg)',
@@ -153,6 +153,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
@@ -227,6 +231,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
@@ -0,0 +1,481 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { useBtwStore } from '@/stores/useBtwStore';
import { useSync } from '@/sync/use-sync';
import {
useSessionMessageRecords,
useSessionRenderable,
useSessionStatus,
useScopedBlockingPermissions,
useScopedBlockingQuestions,
} from '@/sync/sync-context';
import { useStreamingStore } from '@/sync/streaming';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { destroyBtwSession, filterBtwTailMessages, promoteBtwSession, type BtwSessionRef } from '@/lib/btw';
import type { BtwPanelState } from './useBtwPanelState';
import { ChatSurfaceProvider } from '../ChatSurfaceContext';
import { useMobileAutocompleteMaxHeight } from '../useMobileAutocompleteMaxHeight';
import ChatMessage from '../ChatMessage';
import { PermissionCard } from '../PermissionCard';
import { QuestionCard } from '../QuestionCard';
const IDLE_SESSION_STATUS = { type: 'idle' as const };
/** Stable no-op so ChatMessage memoization keeps working in the read-only peek. */
const NOOP_CONTENT_CHANGE = (): void => {};
/**
* The `/btw` peek panel.
*
* Rendered from inside the composer form, so the sheet docks exactly above
* the main composer (`absolute bottom-full` on the composer column) on both
* desktop and mobile — the main composer IS the btw input, so nothing may
* cover it. Identity is derived from the parent session's metadata (see
* `useBtwPanelState`), so the panel belongs to one parent session only.
*
* Three exits: collapse (panel minimizes to the composer chip, the composer
* returns to the main session), promote (the fork becomes a normal session
* and the app navigates to it), destroy (the fork is deleted; the main
* conversation is never touched).
*/
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState }> = ({
parentSessionId,
panel,
}) => {
const { t } = useI18n();
if (panel.btwSessionId && panel.btwDirectory) {
return (
<BtwSheet
sessionRef={{
parentSessionId,
btwSessionId: panel.btwSessionId,
directory: panel.btwDirectory,
}}
title={panel.btwSession?.title?.trim() || t('chat.btw.titleFallback')}
boundaryMessageID={panel.boundaryMessageID}
collapsed={panel.collapsed}
/>
);
}
if (panel.creating) {
return (
<BtwFrame title={t('chat.btw.titleFallback')}>
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
</div>
</BtwFrame>
);
}
return null;
};
const useBtwDestroy = (sessionRef: BtwSessionRef | null): (() => void) => {
const { t } = useI18n();
return React.useCallback(() => {
if (!sessionRef) return;
void destroyBtwSession(sessionRef).then((ok) => {
if (!ok) toast.error(t('chat.btw.toast.destroyFailed'));
});
}, [sessionRef, t]);
};
type BtwSessionData = {
messageRecords: Array<{ info: Message; parts: Part[] }>;
sessionIsWorking: boolean;
streamingMessageId: string | null;
activeStreamingPhase: 'streaming' | 'cooldown' | 'completed' | null;
sessionPermissions: ReturnType<typeof useScopedBlockingPermissions>;
sessionQuestions: ReturnType<typeof useScopedBlockingQuestions>;
isEmpty: boolean;
};
/**
* Live session data for the fork, all keyed by the fork's own ids. Only the
* fork's tail (messages after the inherited-history boundary) is shown.
*/
const useBtwSessionData = (
sessionId: string,
directory: string,
boundaryMessageID: string | null,
): BtwSessionData => {
const sync = useSync();
const renderable = useSessionRenderable(sessionId, directory);
React.useEffect(() => {
if (!renderable) {
void sync.ensureSessionRenderable(sessionId, false, directory);
}
}, [directory, renderable, sessionId, sync]);
const messageRecords = useSessionMessageRecords(sessionId, directory);
const status = useSessionStatus(sessionId, directory) ?? IDLE_SESSION_STATUS;
const streamingMessageId = useStreamingStore(
React.useCallback((s) => s.streamingMessageIds.get(sessionId) ?? null, [sessionId]),
);
const activeStreamingPhase = useStreamingStore(
React.useCallback(
(s) => (streamingMessageId ? s.messageStreamStates.get(streamingMessageId)?.phase ?? null : null),
[streamingMessageId],
),
);
const sessionPermissions = useScopedBlockingPermissions(sessionId, directory);
const sessionQuestions = useScopedBlockingQuestions(sessionId, directory);
const tailRecords = React.useMemo(
() => filterBtwTailMessages(messageRecords, boundaryMessageID),
[boundaryMessageID, messageRecords],
);
const sessionIsWorking = React.useMemo(() => {
if (sessionPermissions.length > 0 || sessionQuestions.length > 0) {
return false;
}
const statusType = status.type ?? 'idle';
if (statusType === 'busy' || statusType === 'retry') {
return true;
}
// SAFETY: reads only the optional `time.completed` field, which the
// SDK Message union does not expose uniformly; a missing value means
// the assistant turn has not completed.
const lastMessage = tailRecords[tailRecords.length - 1]?.info as (Message & { time?: { completed?: number } }) | undefined;
return Boolean(
lastMessage
&& lastMessage.role === 'assistant'
&& typeof lastMessage.time?.completed !== 'number',
);
}, [sessionPermissions.length, sessionQuestions.length, status.type, tailRecords]);
return {
messageRecords: tailRecords,
sessionIsWorking,
streamingMessageId,
activeStreamingPhase,
sessionPermissions,
sessionQuestions,
isEmpty: tailRecords.length === 0,
};
};
/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
const useEscapeToCollapse = (onCollapse: () => void): void => {
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
// SAFETY: keydown targets are DOM elements (or null on window).
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
onCollapse();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCollapse]);
};
/**
* Stick-to-bottom auto-scroll. Streaming grows content inside one message
* without changing the record count, so following the tail needs a
* ResizeObserver on the content wrapper — data-driven effects alone would
* stop following mid-stream.
*/
const useAutoScroll = (
bodyRef: React.RefObject<HTMLDivElement | null>,
contentRef: React.RefObject<HTMLDivElement | null>,
contentReady: boolean,
): ((event: React.UIEvent<HTMLDivElement>) => void) => {
const stickToBottomRef = React.useRef(true);
// `contentReady` is a dependency because the refs are only attached once
// the empty state gives way to the message list; an effect keyed on the
// refs alone would run against `null` and never re-attach the observer.
React.useEffect(() => {
if (!contentReady) return;
const content = contentRef.current;
const element = bodyRef.current;
if (element && stickToBottomRef.current) {
element.scrollTop = element.scrollHeight;
}
if (!content || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
const body = bodyRef.current;
if (body && stickToBottomRef.current) {
body.scrollTop = body.scrollHeight;
}
});
observer.observe(content);
return () => observer.disconnect();
}, [bodyRef, contentReady, contentRef]);
return React.useCallback((event: React.UIEvent<HTMLDivElement>) => {
const element = event.currentTarget;
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
}, []);
};
const BtwFrame: React.FC<{
title: string;
actions?: React.ReactNode;
onTitleClick?: () => void;
titleClickLabel?: string;
collapsed?: boolean;
headerSpinner?: boolean;
children?: React.ReactNode;
}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
<div
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
role="dialog"
aria-label="btw"
>
<div className="oc-glass-popover oc-glass-floating w-full overflow-hidden rounded-xl">
<div className="flex items-center gap-2 px-3 py-1.5">
{onTitleClick ? (
<button
type="button"
onClick={onTitleClick}
aria-label={titleClickLabel}
title={titleClickLabel}
className="flex min-w-0 items-center gap-2 text-left text-muted-foreground transition-colors hover:text-foreground"
>
{headerSpinner ? (
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin" />
) : (
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
)}
<span className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</span>
<Icon name={collapsed ? 'arrow-up-s' : 'arrow-down-s'} className="size-4 shrink-0" />
</button>
) : (
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
<h2 className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</h2>
</span>
)}
<div className="min-w-0 flex-1" />
{actions}
</div>
{children ? (
<>
{children}
<div className="h-2" />
</>
) : null}
</div>
</div>
);
const BtwSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
collapsed: boolean;
}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => {
const { t } = useI18n();
const handleDestroy = useBtwDestroy(sessionRef);
const setCollapsed = React.useCallback((next: boolean) => {
useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next });
}, [sessionRef.parentSessionId]);
const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]);
const handlePromote = React.useCallback(() => {
void promoteBtwSession(sessionRef).catch(() => {
toast.error(t('chat.btw.toast.promoteFailed'));
});
}, [sessionRef, t]);
useEscapeToCollapse(handleCollapse);
const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria');
const headerButtonClass = 'size-7 rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:!bg-transparent active:!bg-transparent';
const actions = (
<div className="flex shrink-0 items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className={headerButtonClass}
onClick={handlePromote}
aria-label={t('chat.btw.promoteAria')}
title={t('chat.btw.promoteAria')}
>
<Icon name="external-link" className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={headerButtonClass}
onClick={handleDestroy}
aria-label={t('chat.btw.destroyAria')}
title={t('chat.btw.destroyAria')}
>
<Icon name="close" className="size-4" />
</Button>
</div>
);
if (collapsed) {
return (
<BtwCollapsedStrip
sessionRef={sessionRef}
title={title}
actions={actions}
onExpand={handleToggleCollapsed}
expandLabel={toggleLabel}
/>
);
}
return (
<BtwExpandedSheet
sessionRef={sessionRef}
title={title}
boundaryMessageID={boundaryMessageID}
actions={actions}
onTitleClick={handleToggleCollapsed}
titleClickLabel={toggleLabel}
/>
);
};
/**
* Collapsed mode: only the header strip stays docked above the composer. The
* fork keeps running in the background; a spinner replaces the header icon
* while it is busy so activity stays visible without the message list.
*/
const BtwCollapsedStrip: React.FC<{
sessionRef: BtwSessionRef;
title: string;
actions: React.ReactNode;
onExpand: () => void;
expandLabel: string;
}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => {
const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS;
const isBusy = status.type === 'busy' || status.type === 'retry';
return (
<BtwFrame
title={title}
actions={actions}
onTitleClick={onExpand}
titleClickLabel={expandLabel}
collapsed
headerSpinner={isBusy}
/>
);
};
const BtwExpandedSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
actions: React.ReactNode;
onTitleClick: () => void;
titleClickLabel: string;
}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID);
const bodyRef = React.useRef<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const handleBodyScroll = useAutoScroll(bodyRef, contentRef, !data.isEmpty);
// With the on-screen keyboard open the composer (this panel's anchor)
// rises, and a vh-based cap would push the panel under the app header.
// Same protection as the composer autocomplete popups: clamp the scroll
// body to the space actually available above the anchor. The hook measures
// room for the scroll body itself, but the panel header and bottom spacer
// sit inside the same frame above/below it — reserve their height too.
const BTW_FRAME_CHROME_PX = 48;
const availableMaxHeight = useMobileAutocompleteMaxHeight(bodyRef, true, 520 + BTW_FRAME_CHROME_PX);
const mobileMaxHeight = availableMaxHeight !== undefined
? Math.max(120, availableMaxHeight - BTW_FRAME_CHROME_PX)
: undefined;
return (
<BtwFrame title={title} actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
<ChatSurfaceProvider mode="peek">
<BtwMessages
data={data}
bodyRef={bodyRef}
contentRef={contentRef}
onBodyScroll={handleBodyScroll}
maxHeight={mobileMaxHeight}
/>
</ChatSurfaceProvider>
</BtwFrame>
);
};
const BtwMessages: React.FC<{
data: BtwSessionData;
bodyRef: React.RefObject<HTMLDivElement | null>;
contentRef: React.RefObject<HTMLDivElement | null>;
onBodyScroll: (event: React.UIEvent<HTMLDivElement>) => void;
maxHeight?: number;
}> = ({ data, bodyRef, contentRef, onBodyScroll, maxHeight }) => {
const { t } = useI18n();
if (data.isEmpty) {
return (
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
</div>
);
}
return (
<ScrollShadow
ref={bodyRef}
onScroll={onBodyScroll}
size={32}
data-scroll-shadow="true"
className="max-h-[min(55vh,520px)] min-h-0 overflow-y-auto px-3 py-1"
style={maxHeight !== undefined ? { maxHeight } : undefined}
>
<div ref={contentRef}>
{data.messageRecords.map((record, index) => (
<ChatMessage
key={record.info.id}
message={record}
previousMessage={data.messageRecords[index - 1]}
nextMessage={data.messageRecords[index + 1]}
onContentChange={NOOP_CONTENT_CHANGE}
isInActiveTurn={index === data.messageRecords.length - 1}
activeStreamingPhase={
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
}
/>
))}
{data.sessionQuestions.length > 0 || data.sessionPermissions.length > 0 ? (
<div>
{data.sessionQuestions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{data.sessionPermissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
) : null}
{/* Always reserve this row so the content does not shift down
by a line when the indicator disappears. */}
<div
className={cn(
'flex items-center gap-2 px-1 py-2 text-xs text-muted-foreground',
!data.sessionIsWorking && 'invisible',
)}
aria-hidden={!data.sessionIsWorking}
>
<Icon name="loader-4" className="size-3.5 animate-spin" />
<span>{t('chat.btw.working')}</span>
</div>
</div>
</ScrollShadow>
);
};
@@ -0,0 +1,54 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useSession } from '@/sync/sync-context';
import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetadata';
import { useBtwStore } from '@/stores/useBtwStore';
export type BtwPanelState = {
/** The active fork for this parent, or null when no panel should exist. */
btwSessionId: string | null;
btwSession: Session | null;
/** The fork's directory identity (may be canonicalized by the server). */
btwDirectory: string | null;
/** Last message id inherited from the parent; the panel shows what's after it. */
boundaryMessageID: string | null;
collapsed: boolean;
creating: boolean;
};
/**
* Derive the `/btw` panel identity for one parent session from authoritative
* session metadata (`openchamber.btwSessionID`), plus the transient UI state
* kept in `useBtwStore`. The panel exists only while the parent's link AND the
* fork itself are present in the live stores, so a fork deleted anywhere
* (sidebar, another client) makes the panel disappear without extra tracking.
*/
export function useBtwPanelState(
parentSessionId: string | null | undefined,
directory: string | undefined,
): BtwPanelState {
const parentSession = useSession(parentSessionId, directory);
const linkedBtwSessionId = getBtwSessionID(parentSession);
const btwSession = useSession(linkedBtwSessionId, directory) ?? null;
const uiState = useBtwStore(
React.useCallback(
(s) => (parentSessionId ? s.byParent[parentSessionId] : undefined),
[parentSessionId],
),
);
const destroying = Boolean(uiState?.destroying);
const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null;
return {
btwSessionId,
btwSession: btwSessionId ? btwSession : null,
// SAFETY: the SDK Session type omits the server's `directory` field; this
// widening only reads it, with the parent's directory as the fallback.
btwDirectory: btwSessionId
? ((btwSession as (Session & { directory?: string | null }) | null)?.directory ?? directory ?? null)
: null,
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
collapsed: Boolean(uiState?.collapsed),
creating: Boolean(uiState?.creating),
};
}
@@ -1,5 +1,11 @@
import React from 'react';
export type ChatSurfaceMode = 'default' | 'mini-chat';
/**
* 'mini-chat' is the browser-panel side chat (compact, no fork/plan actions).
* 'peek' is a read-only glance surface (the /btw panel): messages render with
* no per-message controls at all — no user action row, no assistant action
* buttons, no turn footer.
*/
export type ChatSurfaceMode = 'default' | 'mini-chat' | 'peek';
export const ChatSurfaceContext = React.createContext<ChatSurfaceMode>('default');
@@ -567,7 +567,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
const formatted = formatTimestampForDisplay(messageCreatedAt, timeFormatPreference);
return formatted.length > 0 ? formatted : null;
}, [locale, messageCreatedAt, timeFormatPreference]);
const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
const actionsBlock = chatSurfaceMode !== 'peek' && ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
<div className={cn(
'group/user-actions',
isMobile
@@ -1705,8 +1705,9 @@ const AssistantMessageBody = React.memo(({
const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish;
const showErrorMessage = Boolean(errorMessage);
const errorIconName = errorVariant === 'info' ? 'information' : 'error-warning';
const shouldShowMessageActions = hasCopyableText;
const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
const isPeekSurface = chatSurfaceMode === 'peek';
const shouldShowMessageActions = hasCopyableText && !isPeekSurface;
const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)) && !isPeekSurface;
const shouldRenderActionsInActivity = isSortedRenderMode;
const shouldShowStandaloneMessageActions = showSplitAssistantMessageActions && shouldShowMessageActions && !shouldShowTurnFooter && !shouldRenderActionsInActivity;
@@ -1,5 +1,6 @@
import React from 'react';
import { getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath } from '@/lib/chatDirectories';
import { isBtwSession } from '@/lib/sessionBtwMetadata';
import { mergeSidebarSessionSources } from './sidebar/sidebarSessionSources';
import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui';
@@ -512,11 +513,15 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const merged = mergeSidebarSessionSources(globalActiveSessions, liveFallbackSessions);
return merged.filter((session) => (
(!isVSCode && isChatDirectoryPath(session.directory))
|| isKnownActiveSessionDirectory(session, knownSessionDirectories, {
allowUnknownDirectory: !isVSCode,
allowEmptyDirectorySet: !isVSCode,
})
// btw forks stay hidden until promoted to a full session
!isBtwSession(session)
&& (
(!isVSCode && isChatDirectoryPath(session.directory))
|| isKnownActiveSessionDirectory(session, knownSessionDirectories, {
allowUnknownDirectory: !isVSCode,
allowEmptyDirectorySet: !isVSCode,
})
)
));
}, [globalActiveSessions, isVSCode, knownSessionDirectories, liveFallbackSessions]);
@@ -2,6 +2,7 @@ import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { isBtwSession } from '@/lib/sessionBtwMetadata';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import { useGitAllBranches } from '@/stores/useGitStore';
@@ -117,6 +118,8 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
const parents = activeSessions
.filter((session) => !session.time?.archived)
// btw forks stay hidden until promoted to a full session
.filter((session) => !isBtwSession(session))
.filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session)))
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
.filter((session) => {
@@ -18,6 +18,7 @@ import {
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { isBtwSession } from '@/lib/sessionBtwMetadata';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import {
EMPTY_SESSION_ORDER_RANKS,
@@ -308,7 +309,9 @@ export const CommandPalette: React.FC = () => {
// Sessions
// ---------------------------------------------------------------------------
const orderedActiveSessions = React.useMemo(() => {
return orderSessionsByLifecycleScopes(activeSessions, pinnedSessionIds, sessionOrderRanks);
// btw forks stay hidden until promoted to a full session
const visibleSessions = activeSessions.filter((session) => !isBtwSession(session));
return orderSessionsByLifecycleScopes(visibleSessions, pinnedSessionIds, sessionOrderRanks);
}, [activeSessions, pinnedSessionIds, sessionOrderRanks]);
const allBranches = useGitAllBranches();