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 ?