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.
This commit is contained in:
@@ -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<ChatMessageProps> = ({
|
||||
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<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -402,6 +410,29 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
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<ChatMessageProps> = ({
|
||||
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<ChatMessageProps> = ({
|
||||
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<ChatMessageProps> = ({
|
||||
messageFinish={messageFinish}
|
||||
messageCompletedAt={messageCompletedAt ?? undefined}
|
||||
messageCreatedAt={messageCreatedAt ?? undefined}
|
||||
contextPinned={isPinnedIntoContext}
|
||||
contextPinPending={pinPending}
|
||||
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
||||
isMobile={isMobile}
|
||||
alwaysShowActions={alwaysShowMessageActions}
|
||||
hasTouchInput={hasTouchInput}
|
||||
|
||||
@@ -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<string>): 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 ? (
|
||||
<div className={cn(
|
||||
'group/user-actions',
|
||||
isMobile
|
||||
@@ -638,6 +644,29 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.fork')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onToggleContextPin && hasCopyableText && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-6 w-6 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
|
||||
)}
|
||||
disabled={contextPinPending}
|
||||
aria-pressed={contextPinned}
|
||||
aria-label={t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
|
||||
>
|
||||
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canCopyMessage && hasCopyableText && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1057,6 +1086,9 @@ const AssistantMessageBody = React.memo(({
|
||||
errorMessage,
|
||||
errorVariant = 'error',
|
||||
reviewTransferDirection = null,
|
||||
contextPinned,
|
||||
contextPinPending,
|
||||
onToggleContextPin,
|
||||
}: Omit<MessageBodyProps, 'isUser'>) => {
|
||||
const { t, locale } = useI18n();
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
@@ -2003,6 +2035,29 @@ const AssistantMessageBody = React.memo(({
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.saveAsPlan')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onToggleContextPin && hasCopyableText ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-8 w-8 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
|
||||
)}
|
||||
disabled={contextPinPending}
|
||||
aria-pressed={contextPinned}
|
||||
aria-label={t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
|
||||
>
|
||||
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isMiniChatSurface && !isReviewSessionView ? <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -2176,6 +2231,9 @@ const MessageBody = React.memo(({ isUser, ...props }: MessageBodyProps) => {
|
||||
agentMention={props.agentMention}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
contextPinned={props.contextPinned}
|
||||
contextPinPending={props.contextPinPending}
|
||||
onToggleContextPin={props.onToggleContextPin}
|
||||
userActionsMode={props.userActionsMode}
|
||||
stickyUserHeaderEnabled={props.stickyUserHeaderEnabled}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getContextObligatoryMessages, withContextObligatoryMessage } from './contextObligatoryMessages';
|
||||
|
||||
describe('context obligatory message metadata', () => {
|
||||
test('preserves sibling metadata while pinning and unpinning without duplicates', () => {
|
||||
const message = { id: 'msg_1', createdAt: 10, role: 'user' as const };
|
||||
const initial = { openchamber: { goal: { id: 'goal_1' } }, external: true };
|
||||
const pinned = withContextObligatoryMessage(initial, message, true);
|
||||
const repinned = withContextObligatoryMessage(pinned, message, true);
|
||||
const session = { metadata: repinned } as never;
|
||||
|
||||
expect(getContextObligatoryMessages(session)).toEqual([message]);
|
||||
expect((repinned.openchamber as Record<string, unknown>).goal).toEqual({ id: 'goal_1' });
|
||||
expect(withContextObligatoryMessage(repinned, message, false)).toEqual({
|
||||
external: true,
|
||||
openchamber: { goal: { id: 'goal_1' }, context_obligatory_messages: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { getSessionMetadata, type SessionMetadataRecord } from './sessionReviewMetadata';
|
||||
|
||||
export type ContextObligatoryMessage = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
role: 'user' | 'assistant';
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
|
||||
export const getContextObligatoryMessages = (
|
||||
session: Session | null | undefined,
|
||||
): ContextObligatoryMessage[] => {
|
||||
const openchamber = getSessionMetadata(session).openchamber;
|
||||
if (!isRecord(openchamber) || !Array.isArray(openchamber.context_obligatory_messages)) return [];
|
||||
|
||||
return openchamber.context_obligatory_messages.filter((value): value is ContextObligatoryMessage =>
|
||||
isRecord(value)
|
||||
&& typeof value.id === 'string'
|
||||
&& typeof value.createdAt === 'number'
|
||||
&& Number.isFinite(value.createdAt)
|
||||
&& (value.role === 'user' || value.role === 'assistant'));
|
||||
};
|
||||
|
||||
export const withContextObligatoryMessage = (
|
||||
metadata: SessionMetadataRecord,
|
||||
message: ContextObligatoryMessage,
|
||||
pinned: boolean,
|
||||
): SessionMetadataRecord => {
|
||||
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
|
||||
const current = Array.isArray(openchamber.context_obligatory_messages)
|
||||
? openchamber.context_obligatory_messages.filter((value): value is ContextObligatoryMessage =>
|
||||
isRecord(value) && typeof value.id === 'string')
|
||||
: [];
|
||||
const withoutMessage = current.filter((value) => value.id !== message.id);
|
||||
const nextMessages = pinned ? [...withoutMessage, message] : withoutMessage;
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
openchamber: {
|
||||
...openchamber,
|
||||
context_obligatory_messages: nextMessages,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1886,6 +1886,9 @@ export const dict = {
|
||||
'chat.messageBody.actions.fork': 'Fork from here',
|
||||
'chat.messageBody.actions.copyMessageAria': 'Copy message text',
|
||||
'chat.messageBody.actions.copyMessage': 'Copy message',
|
||||
'chat.messageBody.actions.pinContext': 'Pin into context (survives compaction)',
|
||||
'chat.messageBody.actions.unpinContext': 'Unpin from context (will not survive compaction)',
|
||||
'chat.messageBody.actions.contextPinFailed': 'Could not update the context pin',
|
||||
'chat.messageBody.actions.openPreviewAria': 'Open preview',
|
||||
'chat.messageBody.actions.openPreview': 'Open preview',
|
||||
'chat.messageBody.actions.copyAnswer': 'Copy answer',
|
||||
|
||||
@@ -1864,6 +1864,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.fork": "Bifurcar desde aquí",
|
||||
"chat.messageBody.actions.copyMessageAria": "Copiar texto del mensaje",
|
||||
"chat.messageBody.actions.copyMessage": "Copiar mensaje",
|
||||
"chat.messageBody.actions.pinContext": "Fijar en el contexto (se conserva tras la compactación)",
|
||||
"chat.messageBody.actions.unpinContext": "Quitar del contexto (no se conservará tras la compactación)",
|
||||
"chat.messageBody.actions.contextPinFailed": "No se pudo actualizar el mensaje fijado en el contexto",
|
||||
"chat.messageBody.actions.openPreviewAria": "Abrir vista previa",
|
||||
"chat.messageBody.actions.openPreview": "Abrir vista previa",
|
||||
"chat.messageBody.actions.copyAnswer": "Copiar respuesta",
|
||||
|
||||
@@ -1679,6 +1679,9 @@ export const dict = {
|
||||
'chat.messageBody.actions.fork': 'Fourche d\'ici',
|
||||
'chat.messageBody.actions.copyMessageAria': 'Copier le texte du message',
|
||||
'chat.messageBody.actions.copyMessage': 'Copier le message',
|
||||
'chat.messageBody.actions.pinContext': 'Épingler dans le contexte (conservé après compactage)',
|
||||
'chat.messageBody.actions.unpinContext': 'Désépingler du contexte (non conservé après compactage)',
|
||||
'chat.messageBody.actions.contextPinFailed': 'Impossible de modifier l’épinglage dans le contexte',
|
||||
'chat.messageBody.actions.openPreviewAria': 'Ouvrir l\'aperçu',
|
||||
'chat.messageBody.actions.openPreview': 'Ouvrir l\'aperçu',
|
||||
'chat.messageBody.actions.copyAnswer': 'Copier la réponse',
|
||||
|
||||
@@ -1882,6 +1882,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.fork': 'ここからフォーク',
|
||||
'chat.messageBody.actions.copyMessageAria': 'メッセージテキストをコピー',
|
||||
'chat.messageBody.actions.copyMessage': 'メッセージをコピー',
|
||||
'chat.messageBody.actions.pinContext': 'コンテキストに固定(圧縮後も保持)',
|
||||
'chat.messageBody.actions.unpinContext': 'コンテキストから固定解除(圧縮後は保持されません)',
|
||||
'chat.messageBody.actions.contextPinFailed': 'コンテキストの固定を更新できませんでした',
|
||||
'chat.messageBody.actions.openPreviewAria': 'プレビューを開く',
|
||||
'chat.messageBody.actions.openPreview': 'プレビューを開く',
|
||||
'chat.messageBody.actions.copyAnswer': '回答をコピー',
|
||||
|
||||
@@ -1888,6 +1888,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.fork': '여기서 분기 시작',
|
||||
'chat.messageBody.actions.copyMessageAria': '메시지 텍스트 복사',
|
||||
'chat.messageBody.actions.copyMessage': '메시지 복사',
|
||||
'chat.messageBody.actions.pinContext': '컨텍스트에 고정(압축 후에도 유지)',
|
||||
'chat.messageBody.actions.unpinContext': '컨텍스트에서 고정 해제(압축 후 유지되지 않음)',
|
||||
'chat.messageBody.actions.contextPinFailed': '컨텍스트 고정을 업데이트하지 못했습니다',
|
||||
'chat.messageBody.actions.copyAnswer': '답변 복사',
|
||||
'chat.messageBody.actions.savingImage': '이미지 저장 중…',
|
||||
'chat.messageBody.actions.saveAsImage': '이미지로 저장',
|
||||
|
||||
@@ -777,6 +777,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.fork': 'Rozwidl od tego miejsca',
|
||||
'chat.messageBody.actions.copyMessageAria': 'Kopiuj tekst wiadomości',
|
||||
'chat.messageBody.actions.copyMessage': 'Kopiuj wiadomość',
|
||||
'chat.messageBody.actions.pinContext': 'Przypnij do kontekstu (przetrwa kompakcję)',
|
||||
'chat.messageBody.actions.unpinContext': 'Odepnij od kontekstu (nie przetrwa kompakcji)',
|
||||
'chat.messageBody.actions.contextPinFailed': 'Nie udało się zaktualizować przypięcia w kontekście',
|
||||
'chat.messageBody.actions.openPreviewAria': 'Otwórz podgląd',
|
||||
'chat.messageBody.actions.openPreview': 'Otwórz podgląd',
|
||||
'chat.messageBody.actions.copyAnswer': 'Kopiuj odpowiedź',
|
||||
|
||||
@@ -1864,6 +1864,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.fork": "Bifurcar daqui",
|
||||
"chat.messageBody.actions.copyMessageAria": "Copiar texto da mensagem",
|
||||
"chat.messageBody.actions.copyMessage": "Copiar mensagem",
|
||||
"chat.messageBody.actions.pinContext": "Fixar no contexto (permanece após a compactação)",
|
||||
"chat.messageBody.actions.unpinContext": "Desafixar do contexto (não permanece após a compactação)",
|
||||
"chat.messageBody.actions.contextPinFailed": "Não foi possível atualizar a fixação no contexto",
|
||||
"chat.messageBody.actions.openPreviewAria": "Abrir visualização",
|
||||
"chat.messageBody.actions.openPreview": "Abrir visualização",
|
||||
"chat.messageBody.actions.copyAnswer": "Copiar resposta",
|
||||
|
||||
@@ -1864,6 +1864,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.fork": "Відгалузити звідси",
|
||||
"chat.messageBody.actions.copyMessageAria": "Копіювати текст повідомлення",
|
||||
"chat.messageBody.actions.copyMessage": "Копіювати повідомлення",
|
||||
"chat.messageBody.actions.pinContext": "Закріпити в контексті (збережеться після стиснення)",
|
||||
"chat.messageBody.actions.unpinContext": "Відкріпити від контексту (не збережеться після стиснення)",
|
||||
"chat.messageBody.actions.contextPinFailed": "Не вдалося оновити закріплення в контексті",
|
||||
"chat.messageBody.actions.openPreviewAria": "Відкрити попередній перегляд",
|
||||
"chat.messageBody.actions.openPreview": "Відкрити попередній перегляд",
|
||||
"chat.messageBody.actions.copyAnswer": "Скопіювати відповідь",
|
||||
|
||||
@@ -1852,6 +1852,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.fork': '从此处分叉',
|
||||
'chat.messageBody.actions.copyMessageAria': '复制消息文本',
|
||||
'chat.messageBody.actions.copyMessage': '复制消息',
|
||||
'chat.messageBody.actions.pinContext': '固定到上下文(压缩后仍保留)',
|
||||
'chat.messageBody.actions.unpinContext': '从上下文取消固定(压缩后不再保留)',
|
||||
'chat.messageBody.actions.contextPinFailed': '无法更新上下文固定状态',
|
||||
'chat.messageBody.actions.openPreviewAria': '打开预览',
|
||||
'chat.messageBody.actions.openPreview': '打开预览',
|
||||
'chat.messageBody.actions.copyAnswer': '复制回答',
|
||||
|
||||
@@ -1856,6 +1856,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.fork': '從此處分支',
|
||||
'chat.messageBody.actions.copyMessageAria': '複製訊息文字',
|
||||
'chat.messageBody.actions.copyMessage': '複製訊息',
|
||||
'chat.messageBody.actions.pinContext': '釘選到內容脈絡(壓縮後仍保留)',
|
||||
'chat.messageBody.actions.unpinContext': '從內容脈絡取消釘選(壓縮後不再保留)',
|
||||
'chat.messageBody.actions.contextPinFailed': '無法更新內容脈絡釘選狀態',
|
||||
'chat.messageBody.actions.openPreviewAria': '開啟預覽',
|
||||
'chat.messageBody.actions.openPreview': '開啟預覽',
|
||||
'chat.messageBody.actions.copyAnswer': '複製回答',
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
withoutReviewSessionLink,
|
||||
type SessionMetadataRecord,
|
||||
} from "@/lib/sessionReviewMetadata"
|
||||
import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/lib/contextObligatoryMessages"
|
||||
|
||||
const MESSAGE_REFETCH_LIMIT = 100
|
||||
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
|
||||
@@ -472,6 +473,19 @@ export async function patchSessionMetadata(
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function setContextObligatoryMessage(
|
||||
sessionId: string,
|
||||
directory: string | null | undefined,
|
||||
message: ContextObligatoryMessage,
|
||||
pinned: boolean,
|
||||
): Promise<Session> {
|
||||
const updated = await 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(sessionId: string, directory?: string | null): Promise<void> {
|
||||
let session: Session
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user