diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 1ff3bca0..2981248e 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ 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( + () => (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 = ({ const availableSkills = useSkillsStore((s) => s.skills); const knownSlashNames = React.useMemo(() => { const names = new Set([ - '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 = ({ 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 = ({ // 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 = ({ } } - const sendMessageOptions: { + let sendMessageOptions: { target?: NonNullable; + sessionId?: string; + directory?: string; draftSnapshot?: NonNullable; 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(); const reservedFilenames = new Set([ @@ -1208,6 +1240,40 @@ const ChatInputComponent: React.FC = ({ } 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 = ({ } 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 = ({ // 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 = ({ } 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 = ({ // 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 = ({ 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 = ({ }} 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 = ({ className={cn('chat-input-column mt-4', draftPresentationClassName)} /> ) : null} + {currentSessionId ? : null} {/* Issue Picker Dialog */} diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 5ec9a8ac..3fe0ef62 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -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 = ({ 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 = ({ respectReducedMotion >
-
+ {/* peek: the action row under the bubble is suppressed, so + reserve its gap to the next message here, OUTSIDE the + bubble background. */} +
{}; + +/** + * 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 ( + + ); + } + + if (panel.creating) { + return ( + +
+ + {t('chat.btw.loading')} +
+
+ ); + } + + 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; + sessionQuestions: ReturnType; + 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, + contentRef: React.RefObject, + contentReady: boolean, +): ((event: React.UIEvent) => 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) => { + 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 }) => ( +
+
+
+ {onTitleClick ? ( + + ) : ( + + +

+ {title} +

+
+ )} +
+ {actions} +
+ {children ? ( + <> + {children} +
+ + ) : null} +
+
+); + +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 = ( +
+ + +
+ ); + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +}; + +/** + * 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 ( + + ); +}; + +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(null); + const contentRef = React.useRef(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 ( + + + + + + ); +}; + +const BtwMessages: React.FC<{ + data: BtwSessionData; + bodyRef: React.RefObject; + contentRef: React.RefObject; + onBodyScroll: (event: React.UIEvent) => void; + maxHeight?: number; +}> = ({ data, bodyRef, contentRef, onBodyScroll, maxHeight }) => { + const { t } = useI18n(); + + if (data.isEmpty) { + return ( +
+ + {t('chat.btw.loading')} +
+ ); + } + + return ( + +
+ {data.messageRecords.map((record, index) => ( + + ))} + {data.sessionQuestions.length > 0 || data.sessionPermissions.length > 0 ? ( +
+ {data.sessionQuestions.map((question) => ( + + ))} + {data.sessionPermissions.map((permission) => ( + + ))} +
+ ) : null} + {/* Always reserve this row so the content does not shift down + by a line when the indicator disappears. */} +
+ + {t('chat.btw.working')} +
+
+
+ ); +}; diff --git a/packages/ui/src/components/chat/btw/useBtwPanelState.ts b/packages/ui/src/components/chat/btw/useBtwPanelState.ts new file mode 100644 index 00000000..6d36f060 --- /dev/null +++ b/packages/ui/src/components/chat/btw/useBtwPanelState.ts @@ -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), + }; +} diff --git a/packages/ui/src/components/chat/chatSurfaceContextValue.ts b/packages/ui/src/components/chat/chatSurfaceContextValue.ts index 30065ad0..74470c17 100644 --- a/packages/ui/src/components/chat/chatSurfaceContextValue.ts +++ b/packages/ui/src/components/chat/chatSurfaceContextValue.ts @@ -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('default'); diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index c6c09f46..dc870847 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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 ? (
= ({ 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]); diff --git a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts index 42e3e05b..8e0f5e77 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts @@ -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) => { diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index d54861be..2fe8a942 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -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(); diff --git a/packages/ui/src/hooks/useSessionActivity.ts b/packages/ui/src/hooks/useSessionActivity.ts index a19eda62..0d7163fe 100644 --- a/packages/ui/src/hooks/useSessionActivity.ts +++ b/packages/ui/src/hooks/useSessionActivity.ts @@ -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); diff --git a/packages/ui/src/lib/btw.test.ts b/packages/ui/src/lib/btw.test.ts new file mode 100644 index 00000000..ce541989 --- /dev/null +++ b/packages/ui/src/lib/btw.test.ts @@ -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; +let getSessionMessagesImpl: (id: string, limit?: number, directory?: string | null) => Promise>; +let sendMessageImpl: (...args: unknown[]) => Promise; +let deleteSessionImpl: (sessionId: string) => Promise; +let updateSessionTitleImpl: (sessionId: string, title: string) => Promise; +let patchSessionMetadataImpl: ( + sessionId: string, + directory: string | null | undefined, + updater: (metadata: Record) => Record, +) => Promise; +const registeredDirectories: string[] = []; +const upsertedSessions: unknown[] = []; +const childStoreSessions: Session[] = []; +const currentSessionSwitches: string[] = []; +const metadataPatches: Array<{ sessionId: string; result: Record }> = []; + +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) => Record, + ) => 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([]); + }); +}); diff --git a/packages/ui/src/lib/btw.ts b/packages/ui/src/lib/btw.ts new file mode 100644 index 00000000..9ba9068e --- /dev/null +++ b/packages/ui/src/lib/btw.ts @@ -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 `: 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 { + 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 { + 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 { + 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); +} diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 673ec2fe..6874fc8c 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index d10d992a..e48145b3 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 344c4eed..79625f08 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2081,6 +2081,7 @@ export const dict: Record = { "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 = { "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.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 14973f40..fd989b8f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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 d’un 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 d’ensemble de l’architecture et des parties principales.', 'chat.questionCard.submitFailed': 'Impossible d’envoyer la réponse', 'chat.questionCard.dismissFailed': 'Impossible d’ignorer la question', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index bef4e313..f233371b 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2099,6 +2099,7 @@ export const dict: Record = { '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 = { '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': '接続を確認して、このセッションをもう一度読み込んでください。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 5e2ca75a..bfe88f96 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2105,6 +2105,7 @@ export const dict: Record = { '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 = { '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': '연결을 확인한 후 이 세션을 다시 불러오세요.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 73d8a952..c8ded4ba 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -801,6 +801,7 @@ export const dict: Record = { '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 = { '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ę.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 2fe14a2c..48235bf6 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2081,6 +2081,7 @@ export const dict: Record = { "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 = { "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.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index d7675954..618fafa4 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2081,6 +2081,7 @@ export const dict: Record = { "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 = { "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": "Перевірте з’єднання та спробуйте завантажити цю сесію ще раз.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 50b5164d..df7f48c5 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2069,6 +2069,7 @@ export const dict: Record = { '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 = { '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': '请检查连接,然后重新加载此会话。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 644b95c1..673ae58e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2073,6 +2073,7 @@ export const dict: Record = { '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 = { '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': '請檢查連線,然後重新載入此工作階段。', diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 37e12620..42f155c1 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -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'); diff --git a/packages/ui/src/lib/sessionBtwMetadata.test.ts b/packages/ui/src/lib/sessionBtwMetadata.test.ts new file mode 100644 index 00000000..56ca52b9 --- /dev/null +++ b/packages/ui/src/lib/sessionBtwMetadata.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/lib/sessionBtwMetadata.ts b/packages/ui/src/lib/sessionBtwMetadata.ts new file mode 100644 index 00000000..1aace74f --- /dev/null +++ b/packages/ui/src/lib/sessionBtwMetadata.ts @@ -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; +}; diff --git a/packages/ui/src/stores/useBtwStore.test.ts b/packages/ui/src/stores/useBtwStore.test.ts new file mode 100644 index 00000000..9711d182 --- /dev/null +++ b/packages/ui/src/stores/useBtwStore.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/stores/useBtwStore.ts b/packages/ui/src/stores/useBtwStore.ts new file mode 100644 index 00000000..c885f3bc --- /dev/null +++ b/packages/ui/src/stores/useBtwStore.ts @@ -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; + setPanelState: (parentSessionId: string, patch: BtwPanelUIState) => void; + clearPanelState: (parentSessionId: string) => void; +}; + +export const useBtwStore = create()((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 }; + }), +})); diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 027e8f27..fa56cc25 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -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 { - 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 { - 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) + } } }