From bd68e303d427c31a2362095e7d3e9bbc1834841f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 17 Jul 2026 10:30:45 +0300 Subject: [PATCH] feat(chat): preserve pinned messages across compaction Add pin and unpin actions for user and assistant text messages, with clear compaction-survival labels, localized tooltips, status-info active styling, and VS Code gating where the server runtime is unavailable. Persist pinned message IDs, creation timestamps, and roles under the OpenChamber session metadata namespace using fresh-read merge updates so goal, review, and other metadata remain intact. Introduce a server runtime that reacts to OpenCode's dedicated session.compacted event, fetches pinned messages by ID, extracts and chronologically orders their text parts, and injects them as hidden synthetic context through prompt_async. The restoration prompt tells the agent to use the context silently while work remains and limits idle summaries to one short paragraph. Track the last handled compaction summary to avoid replay duplication, tolerate individually missing pinned messages, integrate runtime shutdown, document ownership and limitations, and cover metadata round trips plus compaction injection behavior with focused tests. --- .../ui/src/components/chat/ChatMessage.tsx | 40 +++++ .../components/chat/message/MessageBody.tsx | 62 +++++++- .../src/lib/contextObligatoryMessages.test.ts | 20 +++ .../ui/src/lib/contextObligatoryMessages.ts | 48 ++++++ packages/ui/src/lib/i18n/messages/en.ts | 3 + packages/ui/src/lib/i18n/messages/es.ts | 3 + packages/ui/src/lib/i18n/messages/fr.ts | 3 + packages/ui/src/lib/i18n/messages/ja.ts | 3 + packages/ui/src/lib/i18n/messages/ko.ts | 3 + packages/ui/src/lib/i18n/messages/pl.ts | 3 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 3 + packages/ui/src/lib/i18n/messages/uk.ts | 3 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 3 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 3 + packages/ui/src/sync/session-actions.ts | 14 ++ packages/web/server/index.js | 7 + .../lib/context-obligatory/DOCUMENTATION.md | 19 +++ .../server/lib/context-obligatory/runtime.js | 140 ++++++++++++++++++ .../lib/context-obligatory/runtime.test.js | 77 ++++++++++ .../server/lib/opencode/shutdown-runtime.js | 2 + 20 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/lib/contextObligatoryMessages.test.ts create mode 100644 packages/ui/src/lib/contextObligatoryMessages.ts create mode 100644 packages/web/server/lib/context-obligatory/DOCUMENTATION.md create mode 100644 packages/web/server/lib/context-obligatory/runtime.js create mode 100644 packages/web/server/lib/context-obligatory/runtime.test.js diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 320031eb..b3a577a2 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -31,6 +31,12 @@ import { FadeInOnReveal } from './message/FadeInOnReveal'; import { streamPerfCount } from '@/stores/utils/streamDebug'; import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare'; import type { ReviewTransferDirection } from '@/lib/reviewFlow'; +import { toast } from 'sonner'; +import { useI18n } from '@/lib/i18n'; +import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages'; +import { setContextObligatoryMessage } from '@/sync/session-actions'; +import { isVSCodeRuntime } from '@/lib/desktop'; const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog')); @@ -150,8 +156,10 @@ const ChatMessage: React.FC = ({ onUserAnimationConsumed, reviewTransferDirection = null, }) => { + const { t } = useI18n(); const { isMobile, isTablet, hasTouchInput } = useDeviceInfo(); const alwaysShowMessageActions = isMobile || isTablet; + const canPinIntoContext = !isVSCodeRuntime(); const { currentTheme } = useThemeSystem(); const messageContainerRef = React.useRef(null); @@ -402,6 +410,29 @@ const ChatMessage: React.FC = ({ const timeInfo = message.info.time as { created?: number } | undefined; return typeof timeInfo?.created === 'number' ? timeInfo.created : null; }, [message.info.time]); + const isPinnedIntoContext = useGlobalSessionsStore((state) => { + const session = state.activeSessions.find((candidate) => candidate.id === sessionId) + ?? state.archivedSessions.find((candidate) => candidate.id === sessionId); + return getContextObligatoryMessages(session).some((entry) => entry.id === message.info.id); + }); + const [pinPending, setPinPending] = React.useState(false); + const handleToggleContextPin = React.useCallback(async () => { + if (!sessionId || !messageCreatedAt || pinPending) return; + setPinPending(true); + try { + const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId); + await setContextObligatoryMessage(sessionId, directory, { + id: message.info.id, + createdAt: messageCreatedAt, + role: isUser ? 'user' : 'assistant', + }, !isPinnedIntoContext); + } catch (error) { + console.error('[chat-message] failed to update context pin', error); + toast.error(t('chat.messageBody.actions.contextPinFailed')); + } finally { + setPinPending(false); + } + }, [isPinnedIntoContext, isUser, message.info.id, messageCreatedAt, pinPending, sessionId, t]); const isMessageCompleted = React.useMemo(() => { if (isUser) return true; @@ -1038,6 +1069,9 @@ const ChatMessage: React.FC = ({ agentMention={agentMention} onRevert={handleRevert} onFork={isUser ? handleFork : undefined} + contextPinned={isPinnedIntoContext} + contextPinPending={pinPending} + onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined} errorMessage={assistantErrorText} errorVariant={assistantErrorVariant} userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'} @@ -1072,6 +1106,9 @@ const ChatMessage: React.FC = ({ agentMention={agentMention} onRevert={handleRevert} onFork={isUser ? handleFork : undefined} + contextPinned={isPinnedIntoContext} + contextPinPending={pinPending} + onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined} errorMessage={assistantErrorText} errorVariant={assistantErrorVariant} userActionsMode="external-actions" @@ -1104,6 +1141,9 @@ const ChatMessage: React.FC = ({ messageFinish={messageFinish} messageCompletedAt={messageCompletedAt ?? undefined} messageCreatedAt={messageCreatedAt ?? undefined} + contextPinned={isPinnedIntoContext} + contextPinPending={pinPending} + onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined} isMobile={isMobile} alwaysShowActions={alwaysShowMessageActions} hasTouchInput={hasTouchInput} diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 6563375f..e985e64f 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -434,6 +434,9 @@ interface MessageBodyProps { userActionsMode?: 'inline' | 'external-content' | 'external-actions'; stickyUserHeaderEnabled?: boolean; reviewTransferDirection?: ReviewTransferDirection | null; + contextPinned?: boolean; + contextPinPending?: boolean; + onToggleContextPin?: () => void; } const TOOL_REVEAL_CACHE_MAX = 200; @@ -454,7 +457,7 @@ const writeRevealedToolIds = (messageId: string, value: Set): void => { revealedToolIdsByMessage.set(messageId, new Set(value)); }; -const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobile, alwaysShowActions = isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: { +const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobile, alwaysShowActions = isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, contextPinned, contextPinPending, onToggleContextPin, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: { messageId: string; parts: Part[]; messageCreatedAt?: number | null; @@ -468,6 +471,9 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi agentMention?: AgentMentionInfo; onRevert?: () => void; onFork?: () => void; + contextPinned?: boolean; + contextPinPending?: boolean; + onToggleContextPin?: () => void; userActionsMode?: 'inline' | 'external-content' | 'external-actions'; stickyUserHeaderEnabled?: boolean; }) => { @@ -554,7 +560,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) && showUserActions ? ( + const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
{t('chat.messageBody.actions.fork')} )} + {onToggleContextPin && hasCopyableText && ( + + + + + {t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')} + + )} {canCopyMessage && hasCopyableText && ( @@ -1057,6 +1086,9 @@ const AssistantMessageBody = React.memo(({ errorMessage, errorVariant = 'error', reviewTransferDirection = null, + contextPinned, + contextPinPending, + onToggleContextPin, }: Omit) => { const { t, locale } = useI18n(); const chatSurfaceMode = useChatSurfaceMode(); @@ -2003,6 +2035,29 @@ const AssistantMessageBody = React.memo(({ {t('chat.messageBody.actions.saveAsPlan')} ) : null} + {onToggleContextPin && hasCopyableText ? ( + + + + + {t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')} + + ) : null} {!isMiniChatSurface && !isReviewSessionView ?