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();
+1 -1
View File
@@ -27,7 +27,7 @@ const IDLE_RESULT: SessionActivityResult = {
* question indicator takes priority, and the send button must stay available so
* the user can supersede the prompt with a new message).
*/
function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult {
export function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult {
const status = useSessionStatus(sessionId ?? '', directory);
const messages = useSessionMessages(sessionId ?? '', directory);
const permissions = useSessionPermissions(sessionId ?? '', directory);
+242
View File
@@ -0,0 +1,242 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { Message, Part, Session } from '@opencode-ai/sdk/v2';
let forkSessionImpl: (sessionId: string, messageId?: string, directory?: string | null) => Promise<Session>;
let getSessionMessagesImpl: (id: string, limit?: number, directory?: string | null) => Promise<Array<{ info: Message; parts: Part[] }>>;
let sendMessageImpl: (...args: unknown[]) => Promise<unknown>;
let deleteSessionImpl: (sessionId: string) => Promise<boolean>;
let updateSessionTitleImpl: (sessionId: string, title: string) => Promise<void>;
let patchSessionMetadataImpl: (
sessionId: string,
directory: string | null | undefined,
updater: (metadata: Record<string, unknown>) => Record<string, unknown>,
) => Promise<Session>;
const registeredDirectories: string[] = [];
const upsertedSessions: unknown[] = [];
const childStoreSessions: Session[] = [];
const currentSessionSwitches: string[] = [];
const metadataPatches: Array<{ sessionId: string; result: Record<string, unknown> }> = [];
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
forkSession: (sessionId: string, messageId?: string, directory?: string | null) =>
forkSessionImpl(sessionId, messageId, directory),
getSessionMessages: (id: string, limit?: number, directory?: string | null) =>
getSessionMessagesImpl(id, limit, directory),
},
}));
mock.module('@/sync/session-actions', () => ({
waitForConnectionOrThrow: () => Promise.resolve(),
deleteSession: (sessionId: string) => deleteSessionImpl(sessionId),
updateSessionTitle: (sessionId: string, title: string) => updateSessionTitleImpl(sessionId, title),
patchSessionMetadata: (
sessionId: string,
directory: string | null | undefined,
updater: (metadata: Record<string, unknown>) => Record<string, unknown>,
) => patchSessionMetadataImpl(sessionId, directory, updater),
}));
mock.module('@/sync/session-ui-store', () => ({
useSessionUIStore: {
getState: () => ({
sendMessage: (...args: unknown[]) => sendMessageImpl(...args),
setCurrentSession: (sessionId: string) => { currentSessionSwitches.push(sessionId); },
}),
},
}));
mock.module('@/stores/useGlobalSessionsStore', () => ({
useGlobalSessionsStore: { getState: () => ({ upsertSession: (session: unknown) => { upsertedSessions.push(session); } }) },
}));
mock.module('@/sync/sync-refs', () => ({
registerSessionDirectory: (sessionId: string, directory: string) => { registeredDirectories.push(`${sessionId}:${directory}`); },
getSyncChildStores: () => ({
children: new Map([['/project', {
getState: () => ({ session: childStoreSessions }),
setState: (patch: { session: Session[] }) => { childStoreSessions.length = 0; childStoreSessions.push(...patch.session); },
}]]),
}),
}));
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages } =
await import('@/lib/btw');
const { useBtwStore } = await import('@/stores/useBtwStore');
const makeSession = (id: string, directory?: string): Session => ({
id,
directory,
title: 'btw: q',
time: { created: Date.now(), updated: Date.now() },
parentID: undefined,
version: 1,
}) as unknown as Session;
const record = (id: string): { info: Message; parts: Part[] } => ({
info: { id, role: 'user', time: { created: 1 } } as unknown as Message,
parts: [],
});
const startInput = {
parentSessionId: 'parent-1',
question: 'wtf is kafka',
directory: '/project',
providerID: 'provider',
modelID: 'model',
agent: 'build',
variant: 'v',
};
beforeEach(() => {
registeredDirectories.length = 0;
upsertedSessions.length = 0;
childStoreSessions.length = 0;
currentSessionSwitches.length = 0;
metadataPatches.length = 0;
useBtwStore.setState({ byParent: {} });
forkSessionImpl = () => Promise.reject(new Error('no forkSession stub'));
getSessionMessagesImpl = () => Promise.resolve([record('msg-boundary')]);
sendMessageImpl = () => Promise.resolve();
deleteSessionImpl = () => Promise.resolve(true);
updateSessionTitleImpl = () => Promise.resolve();
patchSessionMetadataImpl = (sessionId, _directory, updater) => {
const result = updater({});
metadataPatches.push({ sessionId, result });
return Promise.resolve(makeSession(sessionId));
};
});
describe('btwSessionTitle', () => {
test('prefixes the question', () => {
expect(btwSessionTitle('wtf is kafka')).toBe('btw: wtf is kafka');
});
});
describe('filterBtwTailMessages', () => {
test('keeps only messages after the boundary id', () => {
const records = [record('msg-1'), record('msg-2'), record('msg-3')];
expect(filterBtwTailMessages(records, 'msg-2').map((r) => r.info.id)).toEqual(['msg-3']);
});
test('a null boundary keeps everything (fork of an empty parent)', () => {
const records = [record('msg-1'), record('msg-2')];
expect(filterBtwTailMessages(records, null)).toBe(records);
});
});
describe('startBtwSession', () => {
test('forks, marks the fork, links the parent, and routes the question to the fork', async () => {
forkSessionImpl = (sessionId, messageId, directory) => {
expect(sessionId).toBe('parent-1');
expect(messageId).toBe(undefined);
return Promise.resolve(makeSession('fork-1', directory ?? '/project'));
};
let sentText: unknown = null;
let sentOptions: unknown = null;
sendMessageImpl = (...args) => {
sentText = args[0];
sentOptions = args[9];
return Promise.resolve();
};
const session = await startBtwSession(startInput);
expect(session.id).toBe('fork-1');
expect(registeredDirectories).toEqual(['fork-1:/project']);
expect(childStoreSessions.map((s) => s.id)).toEqual(['fork-1']);
expect(sentText).toBe('wtf is kafka');
expect(sentOptions).toEqual({ sessionId: 'fork-1', directory: '/project' });
expect(metadataPatches).toEqual([
{ sessionId: 'fork-1', result: { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-boundary' } } },
{ sessionId: 'parent-1', result: { openchamber: { btwSessionID: 'fork-1' } } },
]);
// Transient creating flag is cleared once the flow settles.
expect(useBtwStore.getState().byParent).toEqual({});
});
test('an empty parent produces a marker without a boundary', async () => {
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
getSessionMessagesImpl = () => Promise.resolve([]);
await startBtwSession(startInput);
expect(metadataPatches[0]?.result).toEqual({ openchamber: { kind: 'btw', originalSessionID: 'parent-1' } });
});
test('a failed first send unlinks the parent and deletes the fork', async () => {
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
sendMessageImpl = () => Promise.reject(new Error('send failed'));
const deleted: string[] = [];
deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); };
await expect(startBtwSession(startInput)).rejects.toThrow('send failed');
expect(deleted).toEqual(['fork-1']);
// marker, link, then unlink rollback
expect(metadataPatches.map((p) => p.sessionId)).toEqual(['fork-1', 'parent-1', 'parent-1']);
expect(metadataPatches[2]?.result).toEqual({});
expect(useBtwStore.getState().byParent).toEqual({});
});
test('a failed boundary fetch deletes the fork', async () => {
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
getSessionMessagesImpl = () => Promise.reject(new Error('messages failed'));
const deleted: string[] = [];
deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); };
await expect(startBtwSession(startInput)).rejects.toThrow('messages failed');
expect(deleted).toEqual(['fork-1']);
expect(metadataPatches).toEqual([]);
});
});
describe('destroyBtwSession', () => {
const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' };
test('unlinks the parent and deletes the fork', async () => {
const deleted: string[] = [];
deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); };
expect(await destroyBtwSession(ref)).toBe(true);
expect(metadataPatches).toEqual([{ sessionId: 'parent-1', result: {} }]);
expect(deleted).toEqual(['fork-1']);
expect(useBtwStore.getState().byParent).toEqual({});
});
test('reports an unconfirmed delete and still cleans UI state', async () => {
deleteSessionImpl = () => Promise.resolve(false);
expect(await destroyBtwSession(ref)).toBe(false);
expect(useBtwStore.getState().byParent).toEqual({});
});
test('a failed unlink still attempts the delete', async () => {
patchSessionMetadataImpl = () => Promise.reject(new Error('patch failed'));
const deleted: string[] = [];
deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); };
expect(await destroyBtwSession(ref)).toBe(true);
expect(deleted).toEqual(['fork-1']);
});
});
describe('promoteBtwSession', () => {
const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' };
test('unlinks the parent, strips the marker, and navigates to the fork', async () => {
patchSessionMetadataImpl = (sessionId, _directory, updater) => {
const base = sessionId === 'fork-1'
? { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } }
: { openchamber: { btwSessionID: 'fork-1' } };
const result = updater(base);
metadataPatches.push({ sessionId, result });
return Promise.resolve(makeSession(sessionId));
};
await promoteBtwSession(ref);
expect(metadataPatches).toEqual([
{ sessionId: 'parent-1', result: {} },
{ sessionId: 'fork-1', result: {} },
]);
expect(currentSessionSwitches).toEqual(['fork-1']);
});
test('a failed unlink aborts the promote without navigating', async () => {
patchSessionMetadataImpl = () => Promise.reject(new Error('patch failed'));
await expect(promoteBtwSession(ref)).rejects.toThrow('patch failed');
expect(currentSessionSwitches).toEqual([]);
});
});
+170
View File
@@ -0,0 +1,170 @@
import type { Message, Part, Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client';
import * as sessionActions from '@/sync/session-actions';
import { withBtwSessionLink, withBtwSessionMarker, withoutBtwSessionLink, withoutBtwSessionMarker } from '@/lib/sessionBtwMetadata';
import { useBtwStore } from '@/stores/useBtwStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getSyncChildStores, registerSessionDirectory } from '@/sync/sync-refs';
import { Binary } from '@/sync/binary';
/**
* `/btw <question>`: fork the main session into a temporary session and send
* the question there.
*
* A fork (not an empty child) gives the agent the full inherited conversation
* as its window context. The fork is created through the SDK directly (like
* reviewFlow) so the main chat's `currentSessionId` is never switched; the
* prompt is routed to the fork with `SendMessageOptions.sessionId`.
*
* The parent session's metadata carries `openchamber.btwSessionID` (see
* `sessionBtwMetadata`), so the panel belongs to the parent session alone,
* follows the user as they navigate between sessions, and survives reloads.
*/
export type StartBtwInput = {
parentSessionId: string;
question: string;
directory: string;
providerID: string;
modelID: string;
agent?: string;
variant?: string;
};
export const btwSessionTitle = (question: string): string => `btw: ${question}`;
/**
* Insert the fork into its directory child store so the sidebar picks it up
* immediately, mirroring `forkFromMessage` in session-actions.
*/
function insertForkIntoDirectoryStore(session: Session, directory: string): void {
const store = getSyncChildStores().children.get(directory);
if (!store) return;
const current = store.getState();
const sessions = [...current.session];
const searchResult = Binary.search(sessions, session.id, (s) => s.id);
if (!searchResult.found) {
sessions.splice(searchResult.index, 0, session);
store.setState({ session: sessions });
}
}
export async function startBtwSession(input: StartBtwInput): Promise<Session> {
const { setPanelState, clearPanelState } = useBtwStore.getState();
setPanelState(input.parentSessionId, { creating: true });
try {
await sessionActions.waitForConnectionOrThrow();
const forked = await opencodeClient.forkSession(input.parentSessionId, undefined, input.directory);
// The server may canonicalize the worktree path; the prompt must use the
// same directory identity as the forked session.
// SAFETY: the SDK Session type omits the server's `directory` field; this
// widening only reads it, with the requested directory as the fallback.
const sessionDirectory = (forked as Session & { directory?: string | null }).directory ?? input.directory;
registerSessionDirectory(forked.id, sessionDirectory);
try {
// The boundary between inherited history and the fork's own tail is the
// id of the newest cloned message. Message ids are server-generated and
// ascending, so everything the fork produces sorts after it.
const newestCloned = await opencodeClient.getSessionMessages(forked.id, 1, sessionDirectory);
const boundaryMessageID = newestCloned[newestCloned.length - 1]?.info.id ?? null;
// The fork inherits the parent's metadata and title wholesale: replace
// the metadata with the btw marker, and rename it (rename is
// best-effort — a failed rename must not fail the btw flow).
// The marker lands BEFORE the fork is inserted into local stores: btw
// forks are hidden from session lists by this marker, so inserting an
// unmarked fork first would flash it in the sidebar.
const marked = await sessionActions.patchSessionMetadata(forked.id, sessionDirectory, (metadata) =>
withBtwSessionMarker(metadata, input.parentSessionId, boundaryMessageID));
// patchSessionMetadata already upserted the marked fork into the global
// store; the directory child store still needs the explicit insert.
insertForkIntoDirectoryStore(marked, sessionDirectory);
void sessionActions.updateSessionTitle(forked.id, btwSessionTitle(input.question)).catch(() => undefined);
// Link the parent before sending so the panel opens as soon as the
// metadata lands; the question streams into it.
await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) =>
withBtwSessionLink(metadata, forked.id));
try {
await useSessionUIStore.getState().sendMessage(
input.question,
input.providerID,
input.modelID,
input.agent,
[],
undefined,
undefined,
input.variant,
'normal',
{ sessionId: forked.id, directory: sessionDirectory },
);
} catch (error) {
// A fork without its first question is not a usable btw session:
// unlink the parent again before deleting the fork.
await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) =>
withoutBtwSessionLink(metadata, forked.id)).catch(() => undefined);
throw error;
}
} catch (error) {
await sessionActions.deleteSession(forked.id).catch(() => undefined);
throw error;
}
return forked;
} finally {
clearPanelState(input.parentSessionId);
}
}
/**
* Keep only the fork's own tail: messages after the last message cloned from
* the parent. A `null` boundary means the fork inherited nothing.
*/
export function filterBtwTailMessages(
records: Array<{ info: Message; parts: Part[] }>,
boundaryMessageID: string | null,
): Array<{ info: Message; parts: Part[] }> {
if (!boundaryMessageID) return records;
return records.filter((record) => record.info.id > boundaryMessageID);
}
export type BtwSessionRef = {
parentSessionId: string;
btwSessionId: string;
directory: string;
};
/**
* Destroy the temporary fork. The panel disappears immediately (optimistic
* `destroying` flag); the parent is unlinked and the fork deleted in the
* background. Resolves `false` when the server could not confirm deletion
* the fork then remains in the sidebar and the caller should surface that.
*/
export async function destroyBtwSession(ref: BtwSessionRef): Promise<boolean> {
const { setPanelState, clearPanelState } = useBtwStore.getState();
setPanelState(ref.parentSessionId, { destroying: true });
try {
// deleteSession's metadata cleanup also unlinks the parent; doing it first
// makes the panel close authoritative even if the delete then fails.
await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) =>
withoutBtwSessionLink(metadata, ref.btwSessionId)).catch(() => undefined);
return await sessionActions.deleteSession(ref.btwSessionId);
} finally {
clearPanelState(ref.parentSessionId);
}
}
/**
* Keep the fork as a normal session: unlink it from the parent, drop its btw
* marker, and navigate to it. The conversation continues there as a regular
* session.
*/
export async function promoteBtwSession(ref: BtwSessionRef): Promise<void> {
await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) =>
withoutBtwSessionLink(metadata, ref.btwSessionId));
await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, withoutBtwSessionMarker)
.catch(() => undefined);
useBtwStore.getState().clearPanelState(ref.parentSessionId);
useSessionUIStore.getState().setCurrentSession(ref.btwSessionId);
}
+13
View File
@@ -1938,6 +1938,7 @@ export const dict = {
'chat.commandAutocomplete.command.catchUpDescription': 'Kontext wiederherstellen: Was du getan hast und wo du weitermachen sollst.',
'chat.commandAutocomplete.command.debugDescription': 'Geführte Ursachenforschung für einen Fehler, bevor eine Lösung vorgeschlagen wird.',
'chat.commandAutocomplete.command.weighDescription': 'Zwei bis drei Ansätze mit Kompromissen und einer Empfehlung bewerten, bevor du dich entscheidest.',
'chat.commandAutocomplete.command.btwDescription': 'Stelle eine Neben-Frage in einer temporären Kind-Sitzung, ohne diesen Chat zu unterbrechen.',
'chat.commandAutocomplete.command.exploreDescription': 'Vertraut machen mit diesem Codebase: Eine Übersicht über die Architektur und Hauptbestandteile.',
'chat.commandAutocomplete.badge.skill': 'Fähigkeit',
'chat.commandAutocomplete.badge.command': 'Befehl',
@@ -1958,6 +1959,18 @@ export const dict = {
'chat.container.returnToParent.titleNamed': 'Zurück zu: {title}',
'chat.container.returnToParent.title': 'Zurück zur übergeordneten Sitzung',
'chat.container.returnToParent.label': 'Übergeordnet',
'chat.btw.destroyAria': 'Diese btw-Sitzung löschen',
'chat.btw.titleFallback': 'btw-Sitzung',
'chat.btw.mainComposerPlaceholder': 'In dieser btw-Sitzung fragen…',
'chat.btw.loading': 'btw-Sitzung wird gestartet…',
'chat.btw.toast.emptyArgument': 'Gib eine Frage nach /btw ein',
'chat.btw.toast.createFailed': 'Die btw-Sitzung konnte nicht gestartet werden',
'chat.btw.toast.destroyFailed': 'Die btw-Sitzung konnte nicht gelöscht werden. Sie bleibt in der Seitenleiste.',
'chat.btw.working': 'Arbeitet…',
'chat.btw.collapseAria': 'btw-Panel einklappen',
'chat.btw.expandAria': 'btw-Panel ausklappen',
'chat.btw.promoteAria': 'Als eigene Sitzung behalten',
'chat.btw.toast.promoteFailed': 'Die btw-Sitzung konnte nicht behalten werden',
'chat.container.readOnlySubagentPromptBanner': 'Subagent-Sitzungen können nicht abgefragt werden.',
'chat.unifiedControls.title': 'Steuerung',
'chat.unifiedControls.model.title': 'Modell',
+13
View File
@@ -2104,6 +2104,7 @@ export const dict = {
'chat.commandAutocomplete.command.debugDescription': 'Guided root-cause investigation for a bug before proposing a fix.',
'chat.commandAutocomplete.command.weighDescription': 'Weigh 2-3 approaches with trade-offs and a recommendation before you commit.',
'chat.commandAutocomplete.command.exploreDescription': 'Get oriented in this codebase: a high-level tour of the architecture and main parts.',
'chat.commandAutocomplete.command.btwDescription': 'Ask a side question in a temporary child session without derailing this chat.',
'chat.commandAutocomplete.badge.skill': 'skill',
'chat.commandAutocomplete.badge.command': 'command',
'chat.commandAutocomplete.badge.system': 'system',
@@ -2124,6 +2125,18 @@ export const dict = {
'chat.container.returnToParent.title': 'Return to parent session',
'chat.container.returnToParent.label': 'Parent',
'chat.container.readOnlySubagentPromptBanner': 'Subagent sessions cannot be prompted.',
'chat.btw.destroyAria': 'Destroy this btw session',
'chat.btw.titleFallback': 'btw session',
'chat.btw.mainComposerPlaceholder': 'Ask in this btw session…',
'chat.btw.loading': 'Starting btw session…',
'chat.btw.toast.emptyArgument': 'Type a question after /btw',
'chat.btw.toast.createFailed': 'Failed to start the btw session',
'chat.btw.toast.destroyFailed': 'Failed to destroy the btw session. It will remain in the sidebar.',
'chat.btw.working': 'Working…',
'chat.btw.collapseAria': 'Collapse the btw panel',
'chat.btw.expandAria': 'Expand the btw panel',
'chat.btw.promoteAria': 'Keep as a separate session',
'chat.btw.toast.promoteFailed': 'Failed to keep the btw session',
'chat.container.sessionLoadError.title': 'Session could not be loaded',
'chat.container.sessionLoadError.description': 'Check the connection and try loading this session again.',
'chat.container.sessionLoadError.retry': 'Try again',
+13
View File
@@ -2081,6 +2081,7 @@ export const dict: Record<I18nKey, string> = {
"chat.commandAutocomplete.command.catchUpDescription": "Recupera el contexto: qué estabas haciendo y por dónde continuar.",
"chat.commandAutocomplete.command.debugDescription": "Investigación guiada de la causa raíz de un error antes de proponer una solución.",
"chat.commandAutocomplete.command.weighDescription": "Compara 2-3 enfoques con sus ventajas y desventajas y una recomendación antes de decidir.",
'chat.commandAutocomplete.command.btwDescription': 'Haz una pregunta paralela en una sesión hija temporal sin desviar este chat.',
"chat.commandAutocomplete.command.exploreDescription": "Oriéntate en este código: un recorrido general de la arquitectura y las partes principales.",
"chat.commandAutocomplete.badge.skill": "habilidad",
"chat.commandAutocomplete.badge.command": "comando",
@@ -2101,6 +2102,18 @@ export const dict: Record<I18nKey, string> = {
"chat.container.returnToParent.titleNamed": "Volver a: {title}",
"chat.container.returnToParent.title": "Volver a la sesión principal",
"chat.container.returnToParent.label": "Principal",
'chat.btw.destroyAria': 'Destruir esta sesión btw',
'chat.btw.titleFallback': 'sesión btw',
'chat.btw.mainComposerPlaceholder': 'Pregunta en esta sesión btw…',
'chat.btw.loading': 'Iniciando sesión btw…',
'chat.btw.toast.emptyArgument': 'Escribe una pregunta después de /btw',
'chat.btw.toast.createFailed': 'No se pudo iniciar la sesión btw',
'chat.btw.toast.destroyFailed': 'No se pudo destruir la sesión btw. Permanecerá en la barra lateral.',
'chat.btw.working': 'Trabajando…',
'chat.btw.collapseAria': 'Contraer el panel btw',
'chat.btw.expandAria': 'Expandir el panel btw',
'chat.btw.promoteAria': 'Conservar como sesión aparte',
'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw',
"chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.",
"chat.container.sessionLoadError.title": "No se pudo cargar la sesión",
"chat.container.sessionLoadError.description": "Comprueba la conexión e intenta cargar esta sesión de nuevo.",
+13
View File
@@ -1855,6 +1855,18 @@ export const dict = {
'chat.container.returnToParent.titleNamed': 'Retourner à : {title}',
'chat.container.returnToParent.title': 'Retour à la session parents',
'chat.container.returnToParent.label': 'Mère',
'chat.btw.destroyAria': 'Détruire cette session btw',
'chat.btw.titleFallback': 'session btw',
'chat.btw.mainComposerPlaceholder': 'Poser une question dans cette session btw…',
'chat.btw.loading': 'Démarrage de la session btw…',
'chat.btw.toast.emptyArgument': 'Saisissez une question après /btw',
'chat.btw.toast.createFailed': 'Échec du démarrage de la session btw',
'chat.btw.toast.destroyFailed': 'Échec de la suppression de la session btw. Elle restera dans la barre latérale.',
'chat.btw.working': 'En cours…',
'chat.btw.collapseAria': 'Réduire le panneau btw',
'chat.btw.expandAria': 'Développer le panneau btw',
'chat.btw.promoteAria': 'Conserver comme session à part',
'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw',
'chat.container.readOnlySubagentPromptBanner': 'Les sessions de sous-agent ne peuvent pas être invitées.',
'chat.container.sessionLoadError.title': 'Impossible de charger la session',
'chat.container.sessionLoadError.description': 'Vérifiez la connexion et essayez de charger à nouveau cette session.',
@@ -3021,6 +3033,7 @@ export const dict = {
'chat.commandAutocomplete.command.catchUpDescription': 'Rétablir le contexte : ce que vous faisiez et où reprendre.',
'chat.commandAutocomplete.command.debugDescription': 'Investigation guidée de la cause racine dun bug avant de proposer une correction.',
'chat.commandAutocomplete.command.weighDescription': 'Comparer 2 à 3 approches avec compromis et recommandation avant de vous engager.',
'chat.commandAutocomplete.command.btwDescription': 'Posez une question annexe dans une session enfant temporaire sans interrompre cette conversation.',
'chat.commandAutocomplete.command.exploreDescription': 'Vous orienter dans ce codebase : tour densemble de larchitecture et des parties principales.',
'chat.questionCard.submitFailed': 'Impossible denvoyer la réponse',
'chat.questionCard.dismissFailed': 'Impossible dignorer la question',
+13
View File
@@ -2099,6 +2099,7 @@ export const dict: Record<I18nKey, string> = {
'chat.commandAutocomplete.command.catchUpDescription': 'コンテキストを再確立: 何をしていたか、どこから再開するか。',
'chat.commandAutocomplete.command.debugDescription': '修正を提案する前に、バグのガイド付き根本原因調査。',
'chat.commandAutocomplete.command.weighDescription': 'トレードオフと推奨事項を含む2~3のアプローチを比較検討してからコミット。',
'chat.commandAutocomplete.command.btwDescription': 'このチャットを乱さず、一時的な子セッションで脇の質問をする',
'chat.commandAutocomplete.command.exploreDescription': 'このコードベースに慣れる: アーキテクチャと主要部分の概要ツアー。',
'chat.commandAutocomplete.badge.skill': 'スキル',
'chat.commandAutocomplete.badge.command': 'コマンド',
@@ -2119,6 +2120,18 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.titleNamed': '戻る: {title}',
'chat.container.returnToParent.title': '親セッションに戻る',
'chat.container.returnToParent.label': '親',
'chat.btw.destroyAria': 'このbtwセッションを破棄',
'chat.btw.titleFallback': 'btwセッション',
'chat.btw.mainComposerPlaceholder': 'このbtwセッションで質問する…',
'chat.btw.loading': 'btwセッションを開始中…',
'chat.btw.toast.emptyArgument': '/btwの後に質問を入力してください',
'chat.btw.toast.createFailed': 'btwセッションを開始できませんでした',
'chat.btw.toast.destroyFailed': 'btwセッションを破棄できませんでした。サイドバーに残ります。',
'chat.btw.working': '処理中…',
'chat.btw.collapseAria': 'btwパネルを折りたたむ',
'chat.btw.expandAria': 'btwパネルを展開する',
'chat.btw.promoteAria': '独立したセッションとして保持',
'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした',
'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。',
'chat.container.sessionLoadError.title': 'セッションを読み込めませんでした',
'chat.container.sessionLoadError.description': '接続を確認して、このセッションをもう一度読み込んでください。',
+13
View File
@@ -2105,6 +2105,7 @@ export const dict: Record<I18nKey, string> = {
'chat.commandAutocomplete.command.catchUpDescription': '맥락을 다시 파악합니다: 무엇을 하고 있었고 어디서 이어서 할지.',
'chat.commandAutocomplete.command.debugDescription': '수정안을 제시하기 전에 버그의 근본 원인을 단계적으로 조사합니다.',
'chat.commandAutocomplete.command.weighDescription': '결정하기 전에 2~3가지 접근 방식을 장단점과 함께 비교하고 추천을 제시합니다.',
'chat.commandAutocomplete.command.btwDescription': '이 채팅을 방해하지 않고 임시 하위 세션에서 별도 질문하기',
'chat.commandAutocomplete.command.exploreDescription': '코드베이스에 대한 방향을 잡습니다: 아키텍처와 주요 부분을 한눈에 살펴봅니다.',
'chat.commandAutocomplete.badge.skill': '스킬',
'chat.commandAutocomplete.badge.command': '명령',
@@ -2125,6 +2126,18 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.titleNamed': '돌아가기: {title}',
'chat.container.returnToParent.title': '상위 세션으로 돌아가기',
'chat.container.returnToParent.label': '상위',
'chat.btw.destroyAria': '이 btw 세션 삭제',
'chat.btw.titleFallback': 'btw 세션',
'chat.btw.mainComposerPlaceholder': '이 btw 세션에서 질문하세요…',
'chat.btw.loading': 'btw 세션 시작 중…',
'chat.btw.toast.emptyArgument': '/btw 뒤에 질문을 입력하세요',
'chat.btw.toast.createFailed': 'btw 세션을 시작하지 못했습니다',
'chat.btw.toast.destroyFailed': 'btw 세션을 삭제하지 못했습니다. 사이드바에 남아 있습니다.',
'chat.btw.working': '작업 중…',
'chat.btw.collapseAria': 'btw 패널 접기',
'chat.btw.expandAria': 'btw 패널 펼치기',
'chat.btw.promoteAria': '별도 세션으로 유지',
'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다',
'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.',
'chat.container.sessionLoadError.title': '세션을 불러올 수 없습니다',
'chat.container.sessionLoadError.description': '연결을 확인한 후 이 세션을 다시 불러오세요.',
+13
View File
@@ -801,6 +801,7 @@ export const dict: Record<I18nKey, string> = {
'chat.commandAutocomplete.command.catchUpDescription': 'Przywróć kontekst: nad czym pracowałeś i od czego kontynuować.',
'chat.commandAutocomplete.command.debugDescription': 'Prowadzone badanie pierwotnej przyczyny błędu przed zaproponowaniem poprawki.',
'chat.commandAutocomplete.command.weighDescription': 'Rozważ 2-3 podejścia z kompromisami i rekomendacją, zanim się zdecydujesz.',
'chat.commandAutocomplete.command.btwDescription': 'Zadaj pytanie poboczne w tymczasowej sesji potomnej, nie przerywając tego czatu.',
'chat.commandAutocomplete.command.exploreDescription': 'Zorientuj się w bazie kodu: ogólny przegląd architektury i głównych części.',
'chat.commandAutocomplete.badge.skill': 'skill',
'chat.commandAutocomplete.badge.command': 'polecenie',
@@ -821,6 +822,18 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.titleNamed': 'Powrót do: {title}',
'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej',
'chat.container.returnToParent.label': 'Nadrzędna',
'chat.btw.destroyAria': 'Zniszcz tę sesję btw',
'chat.btw.titleFallback': 'sesja btw',
'chat.btw.mainComposerPlaceholder': 'Zadaj pytanie w tej sesji btw…',
'chat.btw.loading': 'Uruchamianie sesji btw…',
'chat.btw.toast.emptyArgument': 'Wpisz pytanie po /btw',
'chat.btw.toast.createFailed': 'Nie udało się uruchomić sesji btw',
'chat.btw.toast.destroyFailed': 'Nie udało się zniszczyć sesji btw. Pozostanie na pasku bocznym.',
'chat.btw.working': 'Pracuje…',
'chat.btw.collapseAria': 'Zwiń panel btw',
'chat.btw.expandAria': 'Rozwiń panel btw',
'chat.btw.promoteAria': 'Zachowaj jako osobną sesję',
'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw',
'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.',
'chat.container.sessionLoadError.title': 'Nie udało się wczytać sesji',
'chat.container.sessionLoadError.description': 'Sprawdź połączenie i spróbuj ponownie wczytać tę sesję.',
@@ -2081,6 +2081,7 @@ export const dict: Record<I18nKey, string> = {
"chat.commandAutocomplete.command.catchUpDescription": "Retome o contexto: o que você estava fazendo e por onde continuar.",
"chat.commandAutocomplete.command.debugDescription": "Investigação guiada da causa raiz de um bug antes de propor uma correção.",
"chat.commandAutocomplete.command.weighDescription": "Compare 2-3 abordagens com seus prós e contras e uma recomendação antes de decidir.",
'chat.commandAutocomplete.command.btwDescription': 'Faça uma pergunta paralela em uma sessão filha temporária sem desviar este chat.',
"chat.commandAutocomplete.command.exploreDescription": "Oriente-se neste código: um tour geral pela arquitetura e pelas partes principais.",
"chat.commandAutocomplete.badge.skill": "habilidade",
"chat.commandAutocomplete.badge.command": "comando",
@@ -2101,6 +2102,18 @@ export const dict: Record<I18nKey, string> = {
"chat.container.returnToParent.titleNamed": "Voltar para: {title}",
"chat.container.returnToParent.title": "Voltar para a sessão principal",
"chat.container.returnToParent.label": "Principal",
'chat.btw.destroyAria': 'Destruir esta sessão btw',
'chat.btw.titleFallback': 'sessão btw',
'chat.btw.mainComposerPlaceholder': 'Pergunte nesta sessão btw…',
'chat.btw.loading': 'Iniciando sessão btw…',
'chat.btw.toast.emptyArgument': 'Digite uma pergunta depois de /btw',
'chat.btw.toast.createFailed': 'Falha ao iniciar a sessão btw',
'chat.btw.toast.destroyFailed': 'Falha ao destruir a sessão btw. Ela permanecerá na barra lateral.',
'chat.btw.working': 'Trabalhando…',
'chat.btw.collapseAria': 'Recolher o painel btw',
'chat.btw.expandAria': 'Expandir o painel btw',
'chat.btw.promoteAria': 'Manter como sessão separada',
'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw',
"chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.",
"chat.container.sessionLoadError.title": "Não foi possível carregar a sessão",
"chat.container.sessionLoadError.description": "Verifique a conexão e tente carregar esta sessão novamente.",
+13
View File
@@ -2081,6 +2081,7 @@ export const dict: Record<I18nKey, string> = {
"chat.commandAutocomplete.command.catchUpDescription": "Повернутись у контекст: над чим працювали і звідки продовжити.",
"chat.commandAutocomplete.command.debugDescription": "Кероване дослідження першопричини бага перед тим, як пропонувати фікс.",
"chat.commandAutocomplete.command.weighDescription": "Зважити 2-3 підходи з trade-offs і рекомендацією перш ніж братися до роботи.",
'chat.commandAutocomplete.command.btwDescription': 'Поставте побічне питання в тимчасовій дочірній сесії, не відволікаючи цей чат.',
"chat.commandAutocomplete.command.exploreDescription": "Зорієнтуватись у кодовій базі: високорівневий тур архітектурою й основними частинами.",
"chat.commandAutocomplete.badge.skill": "навичка",
"chat.commandAutocomplete.badge.command": "команда",
@@ -2101,6 +2102,18 @@ export const dict: Record<I18nKey, string> = {
"chat.container.returnToParent.titleNamed": "Повернутися до: {title}",
"chat.container.returnToParent.title": "Повернутися до батьківської сесії",
"chat.container.returnToParent.label": "Батьківська",
'chat.btw.destroyAria': 'Знищити цю сесію btw',
'chat.btw.titleFallback': 'сесія btw',
'chat.btw.mainComposerPlaceholder': 'Поставте питання в цій сесії btw…',
'chat.btw.loading': 'Запуск сесії btw…',
'chat.btw.toast.emptyArgument': 'Введіть питання після /btw',
'chat.btw.toast.createFailed': 'Не вдалося запустити сесію btw',
'chat.btw.toast.destroyFailed': 'Не вдалося знищити сесію btw. Вона залишиться в бічній панелі.',
'chat.btw.working': 'Працює…',
'chat.btw.collapseAria': 'Згорнути панель btw',
'chat.btw.expandAria': 'Розгорнути панель btw',
'chat.btw.promoteAria': 'Залишити як окрему сесію',
'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw',
"chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.",
"chat.container.sessionLoadError.title": "Не вдалося завантажити сесію",
"chat.container.sessionLoadError.description": "Перевірте з’єднання та спробуйте завантажити цю сесію ще раз.",
@@ -2069,6 +2069,7 @@ export const dict: Record<I18nKey, string> = {
'chat.commandAutocomplete.command.catchUpDescription': '重新进入上下文:你之前在做什么、从哪里继续。',
'chat.commandAutocomplete.command.debugDescription': '在提出修复方案前,引导式地排查 bug 的根本原因。',
'chat.commandAutocomplete.command.weighDescription': '在动手前,权衡 2-3 种方案的利弊并给出推荐。',
'chat.commandAutocomplete.command.btwDescription': '在临时子会话中提问,不打断当前对话',
'chat.commandAutocomplete.command.exploreDescription': '快速熟悉这个代码库:对架构和主要部分的概览。',
'chat.commandAutocomplete.badge.skill': '技能',
'chat.commandAutocomplete.badge.command': '命令',
@@ -2089,6 +2090,18 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.titleNamed': '返回到:{title}',
'chat.container.returnToParent.title': '返回父会话',
'chat.container.returnToParent.label': '父级',
'chat.btw.destroyAria': '销毁此 btw 会话',
'chat.btw.titleFallback': 'btw 会话',
'chat.btw.mainComposerPlaceholder': '在此 btw 会话中提问…',
'chat.btw.loading': '正在启动 btw 会话…',
'chat.btw.toast.emptyArgument': '在 /btw 后输入问题',
'chat.btw.toast.createFailed': '启动 btw 会话失败',
'chat.btw.toast.destroyFailed': '销毁 btw 会话失败。它将保留在侧边栏中。',
'chat.btw.working': '处理中…',
'chat.btw.collapseAria': '收起 btw 面板',
'chat.btw.expandAria': '展开 btw 面板',
'chat.btw.promoteAria': '保留为独立会话',
'chat.btw.toast.promoteFailed': '保留 btw 会话失败',
'chat.container.readOnlySubagentPromptBanner': '无法向子智能体会话发送提示。',
'chat.container.sessionLoadError.title': '无法加载会话',
'chat.container.sessionLoadError.description': '请检查连接,然后重新加载此会话。',
@@ -2073,6 +2073,7 @@ export const dict: Record<I18nKey, string> = {
'chat.commandAutocomplete.command.catchUpDescription': '重新進入上下文:你之前在做什麼、從哪裡繼續。',
'chat.commandAutocomplete.command.debugDescription': '在提出修復方案前,引導式地排查 bug 的根本原因。',
'chat.commandAutocomplete.command.weighDescription': '在動手前,權衡 2-3 種方案的利弊並給出推薦。',
'chat.commandAutocomplete.command.btwDescription': '在臨時子工作階段中提問,不打斷目前對話',
'chat.commandAutocomplete.command.exploreDescription': '快速熟悉這個程式碼庫:對架構和主要部分的概覽。',
'chat.commandAutocomplete.badge.skill': 'Skills',
'chat.commandAutocomplete.badge.command': '命令',
@@ -2093,6 +2094,18 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.titleNamed': '返回到:{title}',
'chat.container.returnToParent.title': '返回父會話',
'chat.container.returnToParent.label': '父級',
'chat.btw.destroyAria': '銷毀此 btw 工作階段',
'chat.btw.titleFallback': 'btw 工作階段',
'chat.btw.mainComposerPlaceholder': '在此 btw 工作階段中提問…',
'chat.btw.loading': '正在啟動 btw 工作階段…',
'chat.btw.toast.emptyArgument': '在 /btw 後輸入問題',
'chat.btw.toast.createFailed': '啟動 btw 工作階段失敗',
'chat.btw.toast.destroyFailed': '銷毀 btw 工作階段失敗。它將保留在側邊欄中。',
'chat.btw.working': '處理中…',
'chat.btw.collapseAria': '收合 btw 面板',
'chat.btw.expandAria': '展開 btw 面板',
'chat.btw.promoteAria': '保留為獨立工作階段',
'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗',
'chat.container.readOnlySubagentPromptBanner': '無法向子 Agent 會話傳送提示。',
'chat.container.sessionLoadError.title': '無法載入工作階段',
'chat.container.sessionLoadError.description': '請檢查連線,然後重新載入此工作階段。',
+3 -2
View File
@@ -602,10 +602,11 @@ class OpencodeService {
return unwrapSdkData(response, 'session.update');
}
async getSessionMessages(id: string, limit?: number): Promise<{ info: Message; parts: Part[] }[]> {
async getSessionMessages(id: string, limit?: number, directory?: string | null): Promise<{ info: Message; parts: Part[] }[]> {
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
const response = await this.client.session.messages({
sessionID: id,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
...(requestDirectory ? { directory: requestDirectory } : {}),
...(typeof limit === 'number' ? { limit } : {}),
});
return unwrapSdkData(response, 'session.messages');
@@ -0,0 +1,74 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import {
getBtwBoundaryMessageID,
getBtwOriginalSessionID,
getBtwSessionID,
isBtwSession,
withBtwSessionLink,
withBtwSessionMarker,
withoutBtwSessionLink,
withoutBtwSessionMarker,
} from './sessionBtwMetadata';
const sessionWith = (metadata: unknown): Session => ({ id: 's', metadata }) as unknown as Session;
describe('parent link', () => {
test('withBtwSessionLink preserves unrelated openchamber metadata', () => {
const next = withBtwSessionLink({ openchamber: { reviewSessionID: 'r-1' }, other: 1 }, 'fork-1');
expect(next).toEqual({ openchamber: { reviewSessionID: 'r-1', btwSessionID: 'fork-1' }, other: 1 });
});
test('getBtwSessionID reads the link and rejects blank values', () => {
expect(getBtwSessionID(sessionWith({ openchamber: { btwSessionID: 'fork-1' } }))).toBe('fork-1');
expect(getBtwSessionID(sessionWith({ openchamber: { btwSessionID: ' ' } }))).toBeNull();
expect(getBtwSessionID(sessionWith(undefined))).toBeNull();
expect(getBtwSessionID(null)).toBeNull();
});
test('withoutBtwSessionLink removes only a matching link', () => {
const linked = { openchamber: { btwSessionID: 'fork-1', reviewSessionID: 'r-1' } };
expect(withoutBtwSessionLink(linked, 'fork-2')).toBe(linked);
expect(withoutBtwSessionLink(linked, 'fork-1')).toEqual({ openchamber: { reviewSessionID: 'r-1' } });
});
test('withoutBtwSessionLink drops an emptied openchamber object', () => {
expect(withoutBtwSessionLink({ openchamber: { btwSessionID: 'fork-1' } }, 'fork-1')).toEqual({});
});
});
describe('fork marker', () => {
test('withBtwSessionMarker replaces inherited openchamber metadata', () => {
const inherited = { openchamber: { btwSessionID: 'stale', reviewSessionID: 'r-1' }, other: 1 };
expect(withBtwSessionMarker(inherited, 'parent-1', 'msg-9')).toEqual({
openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9' },
other: 1,
});
});
test('withBtwSessionMarker omits a null boundary (empty parent)', () => {
expect(withBtwSessionMarker({}, 'parent-1', null)).toEqual({
openchamber: { kind: 'btw', originalSessionID: 'parent-1' },
});
});
test('marker readers only apply to btw-kind sessions', () => {
const fork = sessionWith({ openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9' } });
expect(isBtwSession(fork)).toBe(true);
expect(getBtwOriginalSessionID(fork)).toBe('parent-1');
expect(getBtwBoundaryMessageID(fork)).toBe('msg-9');
const review = sessionWith({ openchamber: { kind: 'review', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9' } });
expect(isBtwSession(review)).toBe(false);
expect(getBtwOriginalSessionID(review)).toBeNull();
expect(getBtwBoundaryMessageID(review)).toBeNull();
});
test('withoutBtwSessionMarker strips the marker and keeps other keys', () => {
const marked = { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9', btwSessionID: 'nested' } };
expect(withoutBtwSessionMarker(marked)).toEqual({ openchamber: { btwSessionID: 'nested' } });
expect(withoutBtwSessionMarker({ openchamber: { kind: 'btw', originalSessionID: 'parent-1' } })).toEqual({});
const plain = { openchamber: { kind: 'review' } };
expect(withoutBtwSessionMarker(plain)).toBe(plain);
});
});
+120
View File
@@ -0,0 +1,120 @@
import type { Session } from '@opencode-ai/sdk/v2';
import { getSessionMetadata, type SessionMetadataRecord } from '@/lib/sessionReviewMetadata';
/**
* Session-metadata contract for the `/btw` flow, mirroring the review-session
* link in `sessionReviewMetadata`:
*
* - The parent (the session `/btw` was typed into) carries
* `openchamber.btwSessionID` pointing at its active btw fork. The panel is
* derived from this link, so it appears only in the parent session and
* survives reloads.
* - The fork itself is marked `openchamber.kind = 'btw'` with
* `originalSessionID` (its parent) and `btwBoundaryMessageID` the id of
* the last message cloned from the parent. Messages with a greater id are
* the fork's own tail and are what the panel renders. Message ids are
* server-generated ascending identifiers, so the boundary is a plain string
* comparison and immune to client clock skew.
*/
type BtwMetadata = {
kind?: string;
originalSessionID?: string;
btwSessionID?: string;
btwBoundaryMessageID?: string;
};
const getOpenChamberMetadata = (metadata: SessionMetadataRecord): BtwMetadata => {
const value = metadata.openchamber;
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
// SAFETY: session metadata is persisted, externally writable data; this is
// its parsing boundary. `BtwMetadata` only declares optional fields and
// every reader re-validates the field it consumes in `nonEmpty`.
return value as BtwMetadata;
};
const nonEmpty = (value: string | undefined): string | null =>
typeof value === 'string' && value.trim().length > 0 ? value : null;
/** The parent's link to its active btw fork, or null. */
export const getBtwSessionID = (session: Session | null | undefined): string | null =>
nonEmpty(getOpenChamberMetadata(getSessionMetadata(session)).btwSessionID);
export const isBtwSession = (session: Session | null | undefined): boolean =>
getOpenChamberMetadata(getSessionMetadata(session)).kind === 'btw'
&& Boolean(getBtwOriginalSessionID(session));
/** The fork's back-pointer to the session `/btw` was typed into. */
export const getBtwOriginalSessionID = (session: Session | null | undefined): string | null => {
const openchamber = getOpenChamberMetadata(getSessionMetadata(session));
return openchamber.kind === 'btw' ? nonEmpty(openchamber.originalSessionID) : null;
};
/**
* The id of the last message the fork inherited from the parent. `null` means
* the fork inherited nothing (empty parent) and every message is its own.
*/
export const getBtwBoundaryMessageID = (session: Session | null | undefined): string | null => {
const openchamber = getOpenChamberMetadata(getSessionMetadata(session));
return openchamber.kind === 'btw' ? nonEmpty(openchamber.btwBoundaryMessageID) : null;
};
export const withBtwSessionLink = (
metadata: SessionMetadataRecord,
btwSessionID: string,
): SessionMetadataRecord => ({
...metadata,
openchamber: {
...getOpenChamberMetadata(metadata),
btwSessionID,
},
});
/**
* Mark the fork as a btw session. The fork clones the parent's metadata
* wholesale (including review links or a stale `btwSessionID`), so the
* inherited `openchamber` object is replaced, not merged.
*/
export const withBtwSessionMarker = (
metadata: SessionMetadataRecord,
originalSessionID: string,
boundaryMessageID: string | null,
): SessionMetadataRecord => {
const openchamber: BtwMetadata = { kind: 'btw', originalSessionID };
if (boundaryMessageID) openchamber.btwBoundaryMessageID = boundaryMessageID;
return { ...metadata, openchamber };
};
/** Remove the btw marker so a promoted fork becomes a plain session. */
export const withoutBtwSessionMarker = (metadata: SessionMetadataRecord): SessionMetadataRecord => {
const openchamber = getOpenChamberMetadata(metadata);
if (openchamber.kind !== 'btw') return metadata;
const rest: BtwMetadata = { ...openchamber };
delete rest.kind;
delete rest.originalSessionID;
delete rest.btwBoundaryMessageID;
const next: SessionMetadataRecord = { ...metadata };
if (Object.keys(rest).length > 0) {
next.openchamber = rest;
} else {
delete next.openchamber;
}
return next;
};
/** Unlink the parent, but only if it still points at this fork. */
export const withoutBtwSessionLink = (
metadata: SessionMetadataRecord,
btwSessionID: string,
): SessionMetadataRecord => {
const openchamber = getOpenChamberMetadata(metadata);
if (openchamber.btwSessionID !== btwSessionID) return metadata;
const rest: BtwMetadata = { ...openchamber };
delete rest.btwSessionID;
const next: SessionMetadataRecord = { ...metadata };
if (Object.keys(rest).length > 0) {
next.openchamber = rest;
} else {
delete next.openchamber;
}
return next;
};
@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useBtwStore } from './useBtwStore';
describe('useBtwStore', () => {
beforeEach(() => {
useBtwStore.setState({ byParent: {} });
});
test('starts empty', () => {
expect(useBtwStore.getState().byParent).toEqual({});
});
test('setPanelState merges patches per parent', () => {
useBtwStore.getState().setPanelState('parent-1', { creating: true });
useBtwStore.getState().setPanelState('parent-1', { collapsed: true });
expect(useBtwStore.getState().byParent['parent-1']).toEqual({ creating: true, collapsed: true });
});
test('parents are independent', () => {
useBtwStore.getState().setPanelState('parent-1', { collapsed: true });
useBtwStore.getState().setPanelState('parent-2', { destroying: true });
expect(useBtwStore.getState().byParent['parent-1']).toEqual({ collapsed: true });
expect(useBtwStore.getState().byParent['parent-2']).toEqual({ destroying: true });
});
test('clearPanelState removes only its parent entry', () => {
useBtwStore.getState().setPanelState('parent-1', { collapsed: true });
useBtwStore.getState().setPanelState('parent-2', { collapsed: true });
useBtwStore.getState().clearPanelState('parent-1');
expect(useBtwStore.getState().byParent).toEqual({ 'parent-2': { collapsed: true } });
});
test('clearPanelState on an unknown parent is a no-op', () => {
const before = useBtwStore.getState().byParent;
useBtwStore.getState().clearPanelState('missing');
expect(useBtwStore.getState().byParent).toBe(before);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { create } from 'zustand';
/**
* UI-only state for the `/btw` peek panel.
*
* The panel's identity is NOT stored here: it is derived from session
* metadata (`openchamber.btwSessionID` on the parent see
* `sessionBtwMetadata`), so the panel appears only in the session `/btw` was
* typed into and survives reloads. This store keeps only transient
* per-parent presentation state that has no authoritative home:
*
* - `collapsed`: the panel is minimized to the composer chip; the composer
* talks to the main session again until it is expanded.
* - `creating`: `/btw` is between submit and the parent-metadata link
* landing, so the panel can show its starting state immediately.
* - `destroying`: close was clicked; hides the panel optimistically while the
* unlink/delete round-trip completes.
*/
type BtwPanelUIState = {
collapsed?: boolean;
creating?: boolean;
destroying?: boolean;
};
type BtwStore = {
byParent: Record<string, BtwPanelUIState>;
setPanelState: (parentSessionId: string, patch: BtwPanelUIState) => void;
clearPanelState: (parentSessionId: string) => void;
};
export const useBtwStore = create<BtwStore>()((set) => ({
byParent: {},
setPanelState: (parentSessionId, patch) =>
set((state) => ({
byParent: {
...state.byParent,
[parentSessionId]: { ...state.byParent[parentSessionId], ...patch },
},
})),
clearPanelState: (parentSessionId) =>
set((state) => {
if (!(parentSessionId in state.byParent)) return state;
const byParent = { ...state.byParent };
delete byParent[parentSessionId];
return { byParent };
}),
}));
+39 -20
View File
@@ -26,6 +26,7 @@ import {
type SessionMetadataRecord,
} from "@/lib/sessionReviewMetadata"
import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/lib/contextObligatoryMessages"
import { getBtwOriginalSessionID, getBtwSessionID, isBtwSession, withoutBtwSessionLink } from "@/lib/sessionBtwMetadata"
import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues"
import { getImperativeSessionMessageLoader } from "./session-message-loader"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
@@ -794,6 +795,7 @@ export async function patchSessionMetadata(
useGlobalSessionsStore.getState().upsertSession(updated)
const sessionDirectory = (updated as { directory?: string | null }).directory ?? targetDirectory
if (sessionDirectory) registerSessionDirectory(updated.id, sessionDirectory)
mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined)
return updated
}
@@ -803,11 +805,8 @@ export async function setLinkedIssue(
issue: LinkedIssue,
linked: boolean,
): Promise<Session> {
const updated = await patchSessionMetadata(sessionId, directory, (metadata) =>
return patchSessionMetadata(sessionId, directory, (metadata) =>
withLinkedIssue(metadata, issue, linked))
const sessionDirectory = (updated as Session & { directory?: string | null }).directory ?? directory ?? undefined
mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined)
return updated
}
export async function setContextObligatoryMessage(
@@ -816,11 +815,8 @@ export async function setContextObligatoryMessage(
message: ContextObligatoryMessage,
pinned: boolean,
): Promise<Session> {
const updated = await patchSessionMetadata(sessionId, directory, (metadata) =>
return patchSessionMetadata(sessionId, directory, (metadata) =>
withContextObligatoryMessage(metadata, message, pinned))
const sessionDirectory = (updated as Session & { directory?: string | null }).directory ?? directory ?? undefined
mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined)
return updated
}
async function cleanupReviewMetadataBeforeDelete(
@@ -836,18 +832,41 @@ async function cleanupReviewMetadataBeforeDelete(
return
}
if (isStaleRuntime(expectedRuntimeKey)) return
if (!isReviewSession(session)) return
const originalSessionID = getOriginalSessionID(session)
if (!originalSessionID) return
try {
await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), (metadata) =>
withoutReviewSessionLink(metadata, sessionId),
expectedRuntimeKey,
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (/not found/i.test(message)) return
console.warn("[session-actions] review metadata cleanup failed before delete", error)
const unlinkParent = async (originalSessionID: string, unlink: (metadata: SessionMetadataRecord) => SessionMetadataRecord) => {
try {
await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), unlink, expectedRuntimeKey)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (/not found/i.test(message)) return
console.warn("[session-actions] linked-session metadata cleanup failed before delete", error)
}
}
if (isReviewSession(session)) {
const originalSessionID = getOriginalSessionID(session)
if (originalSessionID) await unlinkParent(originalSessionID, (metadata) => withoutReviewSessionLink(metadata, sessionId))
return
}
if (isBtwSession(session)) {
const originalSessionID = getBtwOriginalSessionID(session)
if (originalSessionID) await unlinkParent(originalSessionID, (metadata) => withoutBtwSessionLink(metadata, sessionId))
return
}
// Deleting or archiving a session that has an active btw fork also removes
// the fork: it is a temporary session that only exists for its parent's
// panel. Best-effort — a failed fork delete must not block the parent's
// operation; the orphaned fork stays visible in the sidebar.
const btwSessionID = getBtwSessionID(session)
if (btwSessionID) {
try {
if (isStaleRuntime(expectedRuntimeKey)) return
await deleteSession(btwSessionID, { expectedRuntimeKey })
} catch (error) {
console.warn("[session-actions] failed to delete btw fork before parent delete", error)
}
}
}