feat: split session recap and suggestion settings

This commit is contained in:
Bohdan Triapitsyn
2026-07-06 22:54:31 +03:00
parent 1824b51155
commit 182fc89b8d
22 changed files with 214 additions and 121 deletions
@@ -10,7 +10,7 @@ interface SessionRecapNoteProps {
// Quiet one-paragraph recap of the agent's last reply, rendered right under
// the last message (above the reserved bottom gap). Appears only after the
// 5-minute quiet window, so the layout shift happens off-screen in practice.
// 1-minute quiet window, so the layout shift happens off-screen in practice.
export const SessionRecapNote: React.FC<SessionRecapNoteProps> = React.memo(({ sessionId, directory, isMobile }) => {
const { visibleRecap } = useSessionAssistState(sessionId, directory);
const { t } = useI18n();
@@ -259,8 +259,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const { browserTab } = usePwaDetection();
const directoryShowHidden = useDirectoryShowHidden();
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
const sessionAssistEnabled = useUIStore(state => state.sessionAssistEnabled);
const setSessionAssistEnabled = useUIStore(state => state.setSessionAssistEnabled);
const sessionRecapEnabled = useUIStore(state => state.sessionRecapEnabled);
const sessionSuggestionEnabled = useUIStore(state => state.sessionSuggestionEnabled);
const setSessionRecapEnabled = useUIStore(state => state.setSessionRecapEnabled);
const setSessionSuggestionEnabled = useUIStore(state => state.setSessionSuggestionEnabled);
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks);
const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks);
@@ -1780,27 +1782,50 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{(shouldShow('sessionAssist') || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
<section className="p-2 space-y-0.5">
{shouldShow('sessionAssist') && (
<>
<div
data-settings-item="chat.session-assist"
data-settings-item="chat.session-recap"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={sessionAssistEnabled}
onClick={() => setSessionAssistEnabled(!sessionAssistEnabled)}
aria-pressed={sessionRecapEnabled}
onClick={() => setSessionRecapEnabled(!sessionRecapEnabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setSessionAssistEnabled(!sessionAssistEnabled);
setSessionRecapEnabled(!sessionRecapEnabled);
}
}}
>
<Checkbox
checked={sessionAssistEnabled}
onChange={setSessionAssistEnabled}
ariaLabel={t('settings.openchamber.visual.field.sessionAssistAria')}
checked={sessionRecapEnabled}
onChange={setSessionRecapEnabled}
ariaLabel={t('settings.openchamber.visual.field.sessionRecapAria')}
/>
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.sessionAssist')}</span>
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.sessionRecap')}</span>
</div>
<div
data-settings-item="chat.session-suggestion"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={sessionSuggestionEnabled}
onClick={() => setSessionSuggestionEnabled(!sessionSuggestionEnabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setSessionSuggestionEnabled(!sessionSuggestionEnabled);
}
}}
>
<Checkbox
checked={sessionSuggestionEnabled}
onChange={setSessionSuggestionEnabled}
ariaLabel={t('settings.openchamber.visual.field.sessionSuggestionAria')}
/>
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.sessionSuggestion')}</span>
</div>
</>
)}
{shouldShow('reasoning') && (
<div
+7 -4
View File
@@ -1,10 +1,11 @@
import React from 'react';
import { useDirectoryStore, useSession, useSessionStatus } from '@/sync/sync-context';
import { getSessionAssist, type SessionAssistPayload } from '@/lib/sessionAssistMetadata';
import { useUIStore } from '@/stores/useUIStore';
// How long the chat must sit untouched before the recap becomes visible.
// The suggestion has no such delay — it shows as soon as it arrives.
export const RECAP_VISIBILITY_DELAY_MS = 5 * 60 * 1000;
export const RECAP_VISIBILITY_DELAY_MS = 60 * 1000;
interface LastMessageSnapshot {
id: string;
@@ -50,7 +51,7 @@ function useLastMessageSnapshot(sessionId: string, directory?: string): LastMess
export interface SessionAssistState {
/** Valid (fresh) assist payload, or null. */
assist: SessionAssistPayload | null;
/** Recap text, only when the 5-minute quiet window has elapsed. */
/** Recap text, only when the 1-minute quiet window has elapsed. */
visibleRecap: string | null;
/** Suggestion text — fresh payload, session idle; caller still gates on input emptiness. */
suggestion: string | null;
@@ -60,6 +61,8 @@ export function useSessionAssistState(sessionId: string, directory?: string): Se
const session = useSession(sessionId, directory);
const status = useSessionStatus(sessionId, directory);
const lastMessage = useLastMessageSnapshot(sessionId, directory);
const sessionRecapEnabled = useUIStore((state) => state.sessionRecapEnabled);
const sessionSuggestionEnabled = useUIStore((state) => state.sessionSuggestionEnabled);
const isIdle = !status || status.type === 'idle';
const payload = getSessionAssist(session);
@@ -88,7 +91,7 @@ export function useSessionAssistState(sessionId: string, directory?: string): Se
return {
assist,
visibleRecap: assist && assist.recap && quietElapsed ? assist.recap : null,
suggestion: assist && assist.suggestion ? assist.suggestion : null,
visibleRecap: sessionRecapEnabled && assist && assist.recap && quietElapsed ? assist.recap : null,
suggestion: sessionSuggestionEnabled && assist && assist.suggestion ? assist.suggestion : null,
};
}
+11 -5
View File
@@ -6,7 +6,8 @@ import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
type AppearanceSlice = {
showReasoningTraces: boolean;
sessionAssistEnabled: boolean;
sessionRecapEnabled: boolean;
sessionSuggestionEnabled: boolean;
collapsibleThinkingBlocks: boolean;
showDeletionDialog: boolean;
nativeNotificationsEnabled: boolean;
@@ -51,7 +52,8 @@ export const startAppearanceAutoSave = (): void => {
let previous: AppearanceSlice = {
showReasoningTraces: useUIStore.getState().showReasoningTraces,
sessionAssistEnabled: useUIStore.getState().sessionAssistEnabled,
sessionRecapEnabled: useUIStore.getState().sessionRecapEnabled,
sessionSuggestionEnabled: useUIStore.getState().sessionSuggestionEnabled,
collapsibleThinkingBlocks: useUIStore.getState().collapsibleThinkingBlocks,
showDeletionDialog: useUIStore.getState().showDeletionDialog,
nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled,
@@ -103,7 +105,8 @@ export const startAppearanceAutoSave = (): void => {
useUIStore.subscribe((state) => {
const current: AppearanceSlice = {
showReasoningTraces: state.showReasoningTraces,
sessionAssistEnabled: state.sessionAssistEnabled,
sessionRecapEnabled: state.sessionRecapEnabled,
sessionSuggestionEnabled: state.sessionSuggestionEnabled,
collapsibleThinkingBlocks: state.collapsibleThinkingBlocks,
showDeletionDialog: state.showDeletionDialog,
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
@@ -137,8 +140,11 @@ export const startAppearanceAutoSave = (): void => {
if (current.showReasoningTraces !== previous.showReasoningTraces) {
diff.showReasoningTraces = current.showReasoningTraces;
}
if (current.sessionAssistEnabled !== previous.sessionAssistEnabled) {
diff.sessionAssistEnabled = current.sessionAssistEnabled;
if (current.sessionRecapEnabled !== previous.sessionRecapEnabled) {
diff.sessionRecapEnabled = current.sessionRecapEnabled;
}
if (current.sessionSuggestionEnabled !== previous.sessionSuggestionEnabled) {
diff.sessionSuggestionEnabled = current.sessionSuggestionEnabled;
}
if (current.collapsibleThinkingBlocks !== previous.collapsibleThinkingBlocks) {
diff.collapsibleThinkingBlocks = current.collapsibleThinkingBlocks;
+2 -1
View File
@@ -114,7 +114,8 @@ export type DesktopSettings = {
defaultVariant?: string;
defaultAgent?: string;
smallModelUseDefault?: boolean;
sessionAssistEnabled?: boolean;
sessionRecapEnabled?: boolean;
sessionSuggestionEnabled?: boolean;
smallModelOverride?: string; // format: "provider/model"
defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id
openInAppId?: string;
@@ -1690,8 +1690,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.userMessageRenderingAria': 'User message rendering: {option}',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid rendering: {option}',
'settings.openchamber.visual.field.diffLayoutAria': 'Diff layout: {option}',
'settings.openchamber.visual.field.sessionAssist': 'Generate Session Recap & Suggestion',
'settings.openchamber.visual.field.sessionAssistAria': 'Generate a recap and a suggested reply after the agent finishes',
'settings.openchamber.visual.field.sessionRecap': 'Generate Session Recap',
'settings.openchamber.visual.field.sessionRecapAria': 'Generate a recap after the agent finishes',
'settings.openchamber.visual.field.sessionSuggestion': 'Generate Next User Message Suggestion',
'settings.openchamber.visual.field.sessionSuggestionAria': 'Generate a suggested next user message after the agent finishes',
'settings.openchamber.visual.field.showReasoningTracesAria': 'Show reasoning traces',
'settings.openchamber.visual.field.showReasoningTraces': 'Show Reasoning Traces',
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Enable collapsible reasoning blocks',
@@ -1657,8 +1657,10 @@ export const settingsDict = {
"settings.openchamber.visual.field.userMessageRenderingAria": "Renderizado de mensajes del usuario: {option}",
"settings.openchamber.visual.field.mermaidRenderingAria": "Renderizado de Mermaid: {option}",
"settings.openchamber.visual.field.diffLayoutAria": "Diseño de comparación: {option}",
"settings.openchamber.visual.field.sessionAssist": "Generar resumen y sugerencia de sesión",
"settings.openchamber.visual.field.sessionAssistAria": "Generar un resumen y una respuesta sugerida cuando el agente termina",
"settings.openchamber.visual.field.sessionRecap": "Generar resumen de sesión",
"settings.openchamber.visual.field.sessionRecapAria": "Generar un resumen cuando el agente termina",
"settings.openchamber.visual.field.sessionSuggestion": "Generar sugerencia del próximo mensaje del usuario",
"settings.openchamber.visual.field.sessionSuggestionAria": "Generar un próximo mensaje sugerido del usuario cuando el agente termina",
"settings.openchamber.visual.field.showReasoningTracesAria": "Mostrar rastros de razonamiento",
"settings.openchamber.visual.field.showReasoningTraces": "Mostrar trazas de razonamiento",
"settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Habilitar bloques de razonamiento colapsables",
@@ -1624,8 +1624,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.userMessageRenderingAria': 'Rendu du message utilisateur : {option}',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Rendu Mermaid : {option}',
'settings.openchamber.visual.field.diffLayoutAria': 'Disposition des différences : {option}',
'settings.openchamber.visual.field.sessionAssist': 'Générer le récapitulatif et la suggestion de session',
'settings.openchamber.visual.field.sessionAssistAria': "Générer un récapitulatif et une réponse suggérée quand l'agent termine",
'settings.openchamber.visual.field.sessionRecap': 'Générer le récapitulatif de session',
'settings.openchamber.visual.field.sessionRecapAria': "Générer un récapitulatif quand l'agent termine",
'settings.openchamber.visual.field.sessionSuggestion': 'Générer une suggestion de prochain message utilisateur',
'settings.openchamber.visual.field.sessionSuggestionAria': "Générer un prochain message utilisateur suggéré quand l'agent termine",
'settings.openchamber.visual.field.showReasoningTracesAria': 'Afficher les traces de raisonnement',
'settings.openchamber.visual.field.showReasoningTraces': 'Afficher les traces de raisonnement',
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Activer les blocs de raisonnement pliables',
@@ -1690,8 +1690,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.userMessageRenderingAria': 'ユーザーメッセージ表示: {option}',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 表示: {option}',
'settings.openchamber.visual.field.diffLayoutAria': 'Diff レイアウト: {option}',
'settings.openchamber.visual.field.sessionAssist': 'セッションの要約と提案を生成',
'settings.openchamber.visual.field.sessionAssistAria': 'エージェントの完了後に要約と返信の提案を生成します',
'settings.openchamber.visual.field.sessionRecap': 'セッションの要約を生成',
'settings.openchamber.visual.field.sessionRecapAria': 'エージェントの完了後に要約を生成します',
'settings.openchamber.visual.field.sessionSuggestion': '次のユーザーメッセージの提案を生成',
'settings.openchamber.visual.field.sessionSuggestionAria': 'エージェントの完了後に次のユーザーメッセージの提案を生成します',
'settings.openchamber.visual.field.showReasoningTracesAria': '推論トレースを表示',
'settings.openchamber.visual.field.showReasoningTraces': '推論トレースを表示',
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '折りたたみ可能な推論ブロックを有効化',
@@ -1657,8 +1657,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.userMessageRenderingAria': '사용자 메시지 렌더링: {option}',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 렌더링: {option}',
'settings.openchamber.visual.field.diffLayoutAria': 'Diff 레이아웃: {option}',
'settings.openchamber.visual.field.sessionAssist': '세션 요약 및 제안 생성',
'settings.openchamber.visual.field.sessionAssistAria': '에이전트가 완료되면 요약과 제안 답장을 생성합니다',
'settings.openchamber.visual.field.sessionRecap': '세션 요약 생성',
'settings.openchamber.visual.field.sessionRecapAria': '에이전트가 완료되면 요약을 생성합니다',
'settings.openchamber.visual.field.sessionSuggestion': '다음 사용자 메시지 제안 생성',
'settings.openchamber.visual.field.sessionSuggestionAria': '에이전트가 완료되면 다음 사용자 메시지 제안을 생성합니다',
'settings.openchamber.visual.field.showReasoningTracesAria': 'Reasoning trace 표시',
'settings.openchamber.visual.field.showReasoningTraces': 'Reasoning Trace 표시',
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '접을 수 있는 추론 블록 활성화',
@@ -974,8 +974,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.showDotfilesAria': 'Pokaż pliki ukryte',
'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Pokaż rozwinięte narzędzia bash',
'settings.openchamber.visual.field.showExpandedEditToolsAria': 'Pokaż rozwinięte narzędzia edycji',
'settings.openchamber.visual.field.sessionAssist': 'Generuj podsumowanie i sugestię sesji',
'settings.openchamber.visual.field.sessionAssistAria': 'Generuj podsumowanie i sugerowaną odpowiedź po zakończeniu pracy agenta',
'settings.openchamber.visual.field.sessionRecap': 'Generuj podsumowanie sesji',
'settings.openchamber.visual.field.sessionRecapAria': 'Generuj podsumowanie po zakończeniu pracy agenta',
'settings.openchamber.visual.field.sessionSuggestion': 'Generuj sugestię następnej wiadomości użytkownika',
'settings.openchamber.visual.field.sessionSuggestionAria': 'Generuj sugerowaną następną wiadomość użytkownika po zakończeniu pracy agenta',
'settings.openchamber.visual.field.showReasoningTraces': 'Pokaż ślady rozumowania',
'settings.openchamber.visual.field.showReasoningTracesAria': 'Pokaż ślady rozumowania',
'settings.openchamber.visual.field.collapsibleThinkingBlocks': 'Włącz zwijalne bloki rozumowania',
@@ -1657,8 +1657,10 @@ export const settingsDict = {
"settings.openchamber.visual.field.userMessageRenderingAria": "Renderizado de mensagens do usuário: {option}",
"settings.openchamber.visual.field.mermaidRenderingAria": "Renderizado de Mermaid: {option}",
"settings.openchamber.visual.field.diffLayoutAria": "Layout de comparação: {option}",
"settings.openchamber.visual.field.sessionAssist": "Gerar resumo e sugestão da sessão",
"settings.openchamber.visual.field.sessionAssistAria": "Gerar um resumo e uma resposta sugerida quando o agente termina",
"settings.openchamber.visual.field.sessionRecap": "Gerar resumo da sessão",
"settings.openchamber.visual.field.sessionRecapAria": "Gerar um resumo quando o agente termina",
"settings.openchamber.visual.field.sessionSuggestion": "Gerar sugestão da próxima mensagem do usuário",
"settings.openchamber.visual.field.sessionSuggestionAria": "Gerar uma próxima mensagem sugerida do usuário quando o agente termina",
"settings.openchamber.visual.field.showReasoningTracesAria": "Mostrar rastros de raciocínio",
"settings.openchamber.visual.field.showReasoningTraces": "Mostrar rastros de raciocínio",
"settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Habilitar blocos de raciocínio recolhíveis",
@@ -1657,8 +1657,10 @@ export const settingsDict = {
"settings.openchamber.visual.field.userMessageRenderingAria": "Відображення повідомлень користувача: {option}",
"settings.openchamber.visual.field.mermaidRenderingAria": "Візуалізація Mermaid: {option}",
"settings.openchamber.visual.field.diffLayoutAria": "Компонування diff: {option}",
"settings.openchamber.visual.field.sessionAssist": "Генерувати підсумок і пропозицію для сесії",
"settings.openchamber.visual.field.sessionAssistAria": "Генерувати підсумок і запропоновану відповідь після завершення роботи агента",
"settings.openchamber.visual.field.sessionRecap": "Генерувати підсумок сесії",
"settings.openchamber.visual.field.sessionRecapAria": "Генерувати підсумок після завершення роботи агента",
"settings.openchamber.visual.field.sessionSuggestion": "Генерувати пропозицію наступного повідомлення користувача",
"settings.openchamber.visual.field.sessionSuggestionAria": "Генерувати запропоноване наступне повідомлення користувача після завершення роботи агента",
"settings.openchamber.visual.field.showReasoningTracesAria": "Показати сліди міркувань",
"settings.openchamber.visual.field.showReasoningTraces": "Показати сліди міркувань",
"settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Увімкнути згортальні блоки міркувань",
@@ -1657,8 +1657,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.userMessageRenderingAria': '用户消息渲染:{option}',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 渲染:{option}',
'settings.openchamber.visual.field.diffLayoutAria': '差异布局:{option}',
'settings.openchamber.visual.field.sessionAssist': '生成会话回顾与建议',
'settings.openchamber.visual.field.sessionAssistAria': '代理完成后生成回顾和建议回复',
'settings.openchamber.visual.field.sessionRecap': '生成会话回顾',
'settings.openchamber.visual.field.sessionRecapAria': '代理完成后生成回顾',
'settings.openchamber.visual.field.sessionSuggestion': '生成下一条用户消息建议',
'settings.openchamber.visual.field.sessionSuggestionAria': '代理完成后生成下一条用户消息建议',
'settings.openchamber.visual.field.showReasoningTracesAria': '显示推理轨迹',
'settings.openchamber.visual.field.showReasoningTraces': '显示推理轨迹',
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '启用可折叠推理块',
@@ -1573,8 +1573,10 @@
'settings.openchamber.visual.field.userMessageRenderingAria': '使用者訊息渲染:{option}',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 渲染:{option}',
'settings.openchamber.visual.field.diffLayoutAria': '差異佈局:{option}',
'settings.openchamber.visual.field.sessionAssist': '產生工作階段回顧與建議',
'settings.openchamber.visual.field.sessionAssistAria': '代理完成後產生回顧與建議回覆',
'settings.openchamber.visual.field.sessionRecap': '產生工作階段回顧',
'settings.openchamber.visual.field.sessionRecapAria': '代理完成後產生回顧',
'settings.openchamber.visual.field.sessionSuggestion': '產生下一則使用者訊息建議',
'settings.openchamber.visual.field.sessionSuggestionAria': '代理完成後產生下一則使用者訊息建議',
'settings.openchamber.visual.field.showReasoningTracesAria': '顯示推理軌跡',
'settings.openchamber.visual.field.showReasoningTraces': '顯示推理軌跡',
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '啟用可摺疊推理區塊',
+10 -4
View File
@@ -423,8 +423,11 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
store.setShowReasoningTraces(settings.showReasoningTraces);
}
if (typeof settings.sessionAssistEnabled === 'boolean' && settings.sessionAssistEnabled !== store.sessionAssistEnabled) {
store.setSessionAssistEnabled(settings.sessionAssistEnabled);
if (typeof settings.sessionRecapEnabled === 'boolean' && settings.sessionRecapEnabled !== store.sessionRecapEnabled) {
store.setSessionRecapEnabled(settings.sessionRecapEnabled);
}
if (typeof settings.sessionSuggestionEnabled === 'boolean' && settings.sessionSuggestionEnabled !== store.sessionSuggestionEnabled) {
store.setSessionSuggestionEnabled(settings.sessionSuggestionEnabled);
}
if (typeof settings.collapsibleThinkingBlocks === 'boolean' && settings.collapsibleThinkingBlocks !== store.collapsibleThinkingBlocks) {
store.setCollapsibleThinkingBlocks(settings.collapsibleThinkingBlocks);
@@ -768,8 +771,11 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
if (typeof candidate.sessionAssistEnabled === 'boolean') {
result.sessionAssistEnabled = candidate.sessionAssistEnabled;
if (typeof candidate.sessionRecapEnabled === 'boolean') {
result.sessionRecapEnabled = candidate.sessionRecapEnabled;
}
if (typeof candidate.sessionSuggestionEnabled === 'boolean') {
result.sessionSuggestionEnabled = candidate.sessionSuggestionEnabled;
}
if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
+9 -3
View File
@@ -167,10 +167,16 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
keywords: ['streaming', 'sse', 'websocket'],
},
{
id: 'chat.session-assist',
id: 'chat.session-recap',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.sessionAssist',
keywords: ['recap', 'suggestion', 'assist', 'small model', 'summary'],
titleKey: 'settings.openchamber.visual.field.sessionRecap',
keywords: ['recap', 'assist', 'small model', 'summary'],
},
{
id: 'chat.session-suggestion',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.sessionSuggestion',
keywords: ['suggestion', 'assist', 'small model', 'follow up'],
},
{
id: 'chat.reasoning-traces',
+14 -6
View File
@@ -560,7 +560,8 @@ interface UIStore {
eventStreamStatus: EventStreamStatus;
eventStreamHint: string | null;
showReasoningTraces: boolean;
sessionAssistEnabled: boolean;
sessionRecapEnabled: boolean;
sessionSuggestionEnabled: boolean;
collapsibleThinkingBlocks: boolean;
groupReasoningBlocks: boolean;
chatRenderMode: ChatRenderMode;
@@ -709,7 +710,8 @@ interface UIStore {
setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void;
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
setShowReasoningTraces: (value: boolean) => void;
setSessionAssistEnabled: (value: boolean) => void;
setSessionRecapEnabled: (value: boolean) => void;
setSessionSuggestionEnabled: (value: boolean) => void;
setCollapsibleThinkingBlocks: (value: boolean) => void;
setChatRenderMode: (value: ChatRenderMode) => void;
setActivityRenderMode: (value: ActivityRenderMode) => void;
@@ -853,7 +855,8 @@ export const useUIStore = create<UIStore>()(
eventStreamStatus: 'idle',
eventStreamHint: null,
showReasoningTraces: true,
sessionAssistEnabled: true,
sessionRecapEnabled: true,
sessionSuggestionEnabled: true,
collapsibleThinkingBlocks: true,
groupReasoningBlocks: true,
chatRenderMode: 'live',
@@ -1546,8 +1549,12 @@ export const useUIStore = create<UIStore>()(
set({ showReasoningTraces: value });
},
setSessionAssistEnabled: (value) => {
set({ sessionAssistEnabled: value });
setSessionRecapEnabled: (value) => {
set({ sessionRecapEnabled: value });
},
setSessionSuggestionEnabled: (value) => {
set({ sessionSuggestionEnabled: value });
},
setCollapsibleThinkingBlocks: (value) => {
@@ -2234,7 +2241,8 @@ export const useUIStore = create<UIStore>()(
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
// Note: isSettingsDialogOpen intentionally NOT persisted
showReasoningTraces: state.showReasoningTraces,
sessionAssistEnabled: state.sessionAssistEnabled,
sessionRecapEnabled: state.sessionRecapEnabled,
sessionSuggestionEnabled: state.sessionSuggestionEnabled,
collapsibleThinkingBlocks: state.collapsibleThinkingBlocks,
chatRenderMode: state.chatRenderMode,
activityRenderMode: state.activityRenderMode,
@@ -303,8 +303,12 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
delete restChanges.smallModelUseDefault;
}
if ('sessionAssistEnabled' in restChanges && typeof restChanges.sessionAssistEnabled !== 'boolean') {
delete restChanges.sessionAssistEnabled;
if ('sessionRecapEnabled' in restChanges && typeof restChanges.sessionRecapEnabled !== 'boolean') {
delete restChanges.sessionRecapEnabled;
}
if ('sessionSuggestionEnabled' in restChanges && typeof restChanges.sessionSuggestionEnabled !== 'boolean') {
delete restChanges.sessionSuggestionEnabled;
}
if (typeof restChanges.usageAutoRefresh !== 'boolean') {
@@ -245,8 +245,11 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
if (typeof candidate.sessionAssistEnabled === 'boolean') {
result.sessionAssistEnabled = candidate.sessionAssistEnabled;
if (typeof candidate.sessionRecapEnabled === 'boolean') {
result.sessionRecapEnabled = candidate.sessionRecapEnabled;
}
if (typeof candidate.sessionSuggestionEnabled === 'boolean') {
result.sessionSuggestionEnabled = candidate.sessionSuggestionEnabled;
}
if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
@@ -24,18 +24,20 @@ and one suggested user follow-up with the small model
conversation content never goes to a provider the user didn't pick for
the session, unless the small model was chosen explicitly (settings
override or opencode config). A resolver 404 is silently skipped.
4. The `{recap, suggestion}` JSON is clamped and PATCHed onto the session
metadata together with `forMessageID` (the last assistant message id) and
`generatedAt`. Before writing, the session tail is re-checked (a stale
result is dropped) and the metadata is merged from a fresh session read so
concurrent metadata writes made during generation are preserved.
4. The requested JSON fields (`recap`, `suggestion`, or both) are clamped and
PATCHed onto the session metadata together with `forMessageID` (the last
assistant message id) and `generatedAt`. Before writing, the session tail is
re-checked (a stale result is dropped) and the metadata is merged from a
fresh session read so concurrent metadata writes made during generation are
preserved.
## Settings gate
`sessionAssistEnabled` in OpenChamber settings (Settings → Chat, default on)
is a hard generation switch checked at fire time: when off, no small-model
calls run and nothing is written. Existing payloads keep rendering and can
still be dismissed — the switch is about generation, not visibility.
`sessionRecapEnabled` and `sessionSuggestionEnabled` in OpenChamber settings
(Settings → Chat, default on) are hard generation switches checked at fire
time. When both are off, no small-model calls run and nothing is written. When
one is on, the runtime still makes at most one small-model call and asks only
for that field. The UI also hides disabled payload types immediately.
## Freshness contract (no clearing writes)
@@ -47,7 +49,7 @@ everywhere instantly and offline; the next idle cycle overwrites it.
## UI consumers (packages/ui)
- `lib/sessionAssistMetadata.ts` — payload parsing.
- `hooks/useSessionAssist.ts` — freshness gating + the 5-minute quiet window
- `hooks/useSessionAssist.ts` — freshness gating + the 1-minute quiet window
for the recap (single timeout to the boundary, no polling).
- `components/chat/SessionRecapSpacer.tsx` — renders the recap inside the
fixed-height reserved gap under the last message (height never changes).
@@ -19,16 +19,19 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
'settings.json',
);
// The Chat setting is a hard generation switch (default on): when off, no
// small-model calls and no metadata writes happen at all. Existing payloads
// stay untouched — clients keep showing them and dismissal still works.
const isSessionAssistEnabled = () => {
// The Chat settings are hard generation switches (default on): when both are
// off, no small-model calls and no metadata writes happen at all. Existing
// payloads stay untouched — clients keep showing them and dismissal still works.
const getSessionAssistTargets = () => {
try {
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
const settings = JSON.parse(raw);
return settings?.sessionAssistEnabled !== false;
return {
recap: settings?.sessionRecapEnabled !== false,
suggestion: settings?.sessionSuggestionEnabled !== false,
};
} catch {
return true;
return { recap: true, suggestion: true };
}
};
@@ -39,50 +42,52 @@ const RECAP_CHAR_LIMIT = 320;
const SUGGESTION_CHAR_LIMIT = 500;
const FETCH_TIMEOUT_MS = 5_000;
const ASSIST_SYSTEM_PROMPT = [
const buildAssistSystemPrompt = ({ recap, suggestion }) => [
'You assist a user who chats with a coding agent. Based on the conversation transcript, return exactly one JSON object and nothing else — no prose, no markdown, no code fences.',
'Shape: {"recap": string, "suggestion": string}',
'recap: at most 20 words. State the substance directly — the facts, result, or conclusion, plus the next move if there is one. NEVER narrate ("The assistant explained…", "The agent did…") — write the content itself, like a note the user jotted down.',
'suggestion: write ONE immediately sendable next user message addressed TO the coding agent.',
'The suggestion should be the most useful next step after the assistant\'s latest reply. It should help the user continue productively, not inspect already-known details.',
'Prefer suggestions that ask the agent to make a concrete improvement, implement something specific, validate the latest change, explain tradeoffs, improve the current approach, or continue from the current result.',
'Rules for suggestion:',
'- Output exactly one message the user could click and send without editing.',
'- Pick one best next action yourself.',
'- Do not include alternatives, choices, slash-separated options, or "or".',
'- Do not write "Do X or Y", "Ask whether...", "Maybe...", or "You could...".',
'- Do not ask for information the assistant already provided.',
'- Do not ask to see exact code, file paths, prompt locations, or implementation internals unless the assistant did not provide them and they are necessary for the next step.',
'- Do not produce generic workflow commands like "Run tests" unless testing is clearly the next unresolved step.',
'- Do not produce meta/debug requests that merely inspect the implementation.',
'- Use imperative or question form.',
'- Keep it concise.',
'Use these examples to understand how to choose the suggestion. Do not copy their topic or wording unless the current conversation is about the same thing.',
'Example 1:',
'Assistant reply summary:',
'The assistant already identified the file where the feature is implemented, explained what context is sent to the small model, and summarized the current prompt.',
'Bad suggestion:',
'"Show me the exact runtime.js code and where the prompt is built."',
'Why bad:',
'It asks for information the assistant already provided. It repeats inspection instead of moving to an improvement or decision.',
'Good suggestion:',
'"Suggest how to improve the prompt and context so the generated suggestion is more useful."',
'Why good:',
'It naturally continues from the analysis and asks for a concrete improvement.',
'Example 2:',
'Assistant reply summary:',
'The assistant implemented a timeline dialog redesign, listed concrete UI changes, and reported that type-check and lint passed.',
'Bad suggestion:',
'"Check whether scrolling or loading older messages works without jumps."',
'Why bad:',
'It contains an alternative. A suggestion chip must be one sendable message, not a choice the user has to edit.',
'Good suggestion:',
'"Check whether scrolling and loading older messages work without jumps."',
'Why good:',
'It picks a single validation request that the user can send immediately.',
'Both values MUST be written in the same language as the conversation text itself. Ignore any other language preferences or personalization you may have — only the conversation text decides the language.',
`Shape: {${[recap ? '"recap": string' : '', suggestion ? '"suggestion": string' : ''].filter(Boolean).join(', ')}}`,
recap
? 'recap: at most 20 words. State the substance directly — the facts, result, or conclusion, plus the next move if there is one. NEVER narrate ("The assistant explained…", "The agent did…") — write the content itself, like a note the user jotted down.'
: '',
suggestion ? 'suggestion: write ONE immediately sendable next user message addressed TO the coding agent.' : '',
suggestion ? 'The suggestion should be the most useful next step after the assistant\'s latest reply. It should help the user continue productively, not inspect already-known details.' : '',
suggestion ? 'Prefer suggestions that ask the agent to make a concrete improvement, implement something specific, validate the latest change, explain tradeoffs, improve the current approach, or continue from the current result.' : '',
suggestion ? 'Rules for suggestion:' : '',
suggestion ? '- Output exactly one message the user could click and send without editing.' : '',
suggestion ? '- Pick one best next action yourself.' : '',
suggestion ? '- Do not include alternatives, choices, slash-separated options, or "or".' : '',
suggestion ? '- Do not write "Do X or Y", "Ask whether...", "Maybe...", or "You could...".' : '',
suggestion ? '- Do not ask for information the assistant already provided.' : '',
suggestion ? '- Do not ask to see exact code, file paths, prompt locations, or implementation internals unless the assistant did not provide them and they are necessary for the next step.' : '',
suggestion ? '- Do not produce generic workflow commands like "Run tests" unless testing is clearly the next unresolved step.' : '',
suggestion ? '- Do not produce meta/debug requests that merely inspect the implementation.' : '',
suggestion ? '- Use imperative or question form.' : '',
suggestion ? '- Keep it concise.' : '',
suggestion ? 'Use these examples to understand how to choose the suggestion. Do not copy their topic or wording unless the current conversation is about the same thing.' : '',
suggestion ? 'Example 1:' : '',
suggestion ? 'Assistant reply summary:' : '',
suggestion ? 'The assistant already identified the file where the feature is implemented, explained what context is sent to the small model, and summarized the current prompt.' : '',
suggestion ? 'Bad suggestion:' : '',
suggestion ? '"Show me the exact runtime.js code and where the prompt is built."' : '',
suggestion ? 'Why bad:' : '',
suggestion ? 'It asks for information the assistant already provided. It repeats inspection instead of moving to an improvement or decision.' : '',
suggestion ? 'Good suggestion:' : '',
suggestion ? '"Suggest how to improve the prompt and context so the generated suggestion is more useful."' : '',
suggestion ? 'Why good:' : '',
suggestion ? 'It naturally continues from the analysis and asks for a concrete improvement.' : '',
suggestion ? 'Example 2:' : '',
suggestion ? 'Assistant reply summary:' : '',
suggestion ? 'The assistant implemented a timeline dialog redesign, listed concrete UI changes, and reported that type-check and lint passed.' : '',
suggestion ? 'Bad suggestion:' : '',
suggestion ? '"Check whether scrolling or loading older messages works without jumps."' : '',
suggestion ? 'Why bad:' : '',
suggestion ? 'It contains an alternative. A suggestion chip must be one sendable message, not a choice the user has to edit.' : '',
suggestion ? 'Good suggestion:' : '',
suggestion ? '"Check whether scrolling and loading older messages work without jumps."' : '',
suggestion ? 'Why good:' : '',
suggestion ? 'It picks a single validation request that the user can send immediately.' : '',
'All requested values MUST be written in the same language as the conversation text itself. Ignore any other language preferences or personalization you may have — only the conversation text decides the language.',
'Use double quotes for JSON strings, no trailing commas.',
].join('\n');
].filter(Boolean).join('\n');
const extractJsonObject = (value) => {
const text = String(value ?? '').trim();
@@ -192,7 +197,8 @@ export const createSessionAssistRuntime = ({
};
const generateAssist = async (sessionId, directory) => {
if (!isSessionAssistEnabled()) return;
const targets = getSessionAssistTargets();
if (!targets.recap && !targets.suggestion) return;
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
.catch((error) => {
console.warn(`[session-assist] session fetch failed: ${error?.message || error}`);
@@ -234,6 +240,9 @@ export const createSessionAssistRuntime = ({
if (!transcript) return;
const { generateSmallModelText } = await getSmallModelService();
const requestedFields = [targets.recap ? 'recap' : '', targets.suggestion ? 'suggestion' : '']
.filter(Boolean)
.join(' and ');
// Instruct the language by example, not by description — account-side
// personalization (e.g. the ChatGPT backend knowing the user's locale)
// otherwise leaks a different language into the output.
@@ -245,8 +254,8 @@ export const createSessionAssistRuntime = ({
// session's own provider unless the user explicitly picked a small
// model (settings override / opencode config).
restrictToPreferredProvider: true,
prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite recap and suggestion in the SAME language as this sample from the conversation: "${languageSample}"`,
system: ASSIST_SYSTEM_PROMPT,
prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite ${requestedFields} in the SAME language as this sample from the conversation: "${languageSample}"`,
system: buildAssistSystemPrompt(targets),
directory,
preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
@@ -261,8 +270,8 @@ export const createSessionAssistRuntime = ({
}
const structured = extractJsonObject(generated?.text);
let recap = typeof structured?.recap === 'string' ? structured.recap.trim().slice(0, RECAP_CHAR_LIMIT) : '';
let suggestion = typeof structured?.suggestion === 'string' ? structured.suggestion.trim().slice(0, SUGGESTION_CHAR_LIMIT) : '';
let recap = targets.recap && typeof structured?.recap === 'string' ? structured.recap.trim().slice(0, RECAP_CHAR_LIMIT) : '';
let suggestion = targets.suggestion && typeof structured?.suggestion === 'string' ? structured.suggestion.trim().slice(0, SUGGESTION_CHAR_LIMIT) : '';
// Hard guard against language hallucination: if the conversation contains
// no Cyrillic/CJK at all, the output must not either (and drop per-field,