From ca4234ad8fb545dfaaf48bdca641b8769e78fd21 Mon Sep 17 00:00:00 2001 From: YoonwooHa Date: Mon, 10 Aug 2026 06:20:13 +0900 Subject: [PATCH 1/5] fix: repair notification links and editor font sizing --- public/sw.js | 26 +++++++++++++++++-- .../services/external-turn-monitor.service.ts | 9 ++++--- .../tests/external-turn-monitor.test.ts | 18 ++++++++++++- .../service-worker-completion-payload.test.ts | 26 ++++++++++++++++++- .../code-editor/constants/settings.ts | 2 +- .../code-editor/utils/editorStyles.test.ts | 12 +++++++++ .../code-editor/utils/editorStyles.ts | 6 +++++ .../code-editor/view/CodeEditor.tsx | 9 ++++++- 8 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 src/components/code-editor/utils/editorStyles.test.ts diff --git a/public/sw.js b/public/sw.js index 2b4316e..b978455 100755 --- a/public/sw.js +++ b/public/sw.js @@ -117,7 +117,16 @@ function navigationHref(navigation) { } function completionNavigation(navigation) { - return { href: navigationHref(navigation) || '/' }; + const href = navigationHref(navigation) || '/'; + let sessionId = null; + try { + const url = new URL(href, self.location.origin); + const match = url.pathname.match(/^\/session\/([^/]+)$/); + sessionId = match ? decodeURIComponent(match[1]) : null; + } catch { + sessionId = null; + } + return { href, sessionId }; } function isSameOriginClient(client) { try { @@ -179,8 +188,21 @@ self.addEventListener('notificationclick', event => { const navigation = navigationHref(event.notification.data?.navigation); if (navigation) { + const sessionId = event.notification.data?.navigation?.sessionId || null; event.waitUntil( - focusClientOrOpen(navigation, client => client.navigate(navigation)) + focusClientOrOpen(navigation, client => { + client.postMessage({ + type: 'notification:navigate', + sessionId, + provider: null, + urlPath: navigation + }); + try { + return Promise.resolve(client.navigate(navigation)).catch(() => undefined); + } catch { + return Promise.resolve(); + } + }) ); return; } diff --git a/server/modules/notifications/services/external-turn-monitor.service.ts b/server/modules/notifications/services/external-turn-monitor.service.ts index 409207b..485e1e0 100644 --- a/server/modules/notifications/services/external-turn-monitor.service.ts +++ b/server/modules/notifications/services/external-turn-monitor.service.ts @@ -167,7 +167,10 @@ function asResolvedActivity(value: unknown): MonitorResolvedActivity | null { return result as MonitorResolvedActivity; } -function completionPayload(session: ExternalCliSession, target: CompletionTargetResolution['target']): TerminalCompletionDecision['payload'] { +function completionPayload( + session: ExternalCliSession, + appSessionId: string | null, +): TerminalCompletionDecision['payload'] { const title = typeof session.tmuxName === 'string' && session.tmuxName.trim() ? session.tmuxName.trim() : 'ChatMux'; @@ -180,7 +183,7 @@ function completionPayload(session: ExternalCliSession, target: CompletionTarget title, body: `${label}: Reply ready`, navigation: { - href: `/session/${encodeURIComponent(target.alias)}`, + href: appSessionId ? `/session/${encodeURIComponent(appSessionId)}` : '/', title, }, }; @@ -400,7 +403,7 @@ export function createExternalTurnMonitor(deps: MonitorDeps) { evidenceCursor: cursor, eventCode: 'reply_ready', targetAliasSnapshot: resolution.target.alias, - payload: completionPayload(session, resolution.target), + payload: completionPayload(session, resolution.appSessionId), now: now(), }); if (decision.status === 'baselined') emitDiagnostic({ code: 'baselined', ...diagnosticContext(session) }); diff --git a/server/modules/notifications/tests/external-turn-monitor.test.ts b/server/modules/notifications/tests/external-turn-monitor.test.ts index 773ebb9..7ac16a5 100644 --- a/server/modules/notifications/tests/external-turn-monitor.test.ts +++ b/server/modules/notifications/tests/external-turn-monitor.test.ts @@ -49,7 +49,7 @@ function harness() { resolveTargets: ((detailed: any) => detailed.sessions.filter((item: any) => item.kind !== 'cursor').map((item: any) => ({ generationIdentityKey: completionExternalGenerationIdentityKey(completionExternalGenerationIdentityFromSession(item)!), generationTargetId: item.generationTargetId ?? 17, - appSessionId: null, target: { alias: 'target', watched: item.watched ?? true }, mappingState: item.mappingState ?? 'inactive_match', + appSessionId: item.appSessionId ?? null, target: { alias: 'target', watched: item.watched ?? true }, mappingState: item.mappingState ?? 'inactive_match', }))) as any, observeGeneration: (_id, cursor, observation) => { if (throwObserve) throw new Error('db'); @@ -158,6 +158,22 @@ test('external monitor silently persists a startup reply-ready baseline, then cr assert.equal(h.wakes.length, 1); }); +test('external completion deep-links only with the mapped app session id', async () => { + const mapped = harness(); + mapped.setSessions([session({ appSessionId: 'app-session-1' })]); + mapped.setAnswer({ + ...resolved('waiting_user', 'reply_ready'), + appSession: { session_id: 'app-session-1' }, + }); + await mapped.monitor.tick(); + assert.equal(mapped.decisions[0]?.payload.navigation.href, '/session/app-session-1'); + + const unmapped = harness(); + await unmapped.monitor.tick(); + assert.equal(unmapped.decisions[0]?.payload.navigation.href, '/'); + assert.notEqual(unmapped.decisions[0]?.payload.navigation.href, '/session/target'); +}); + test('terminal replay is delegated to the durable decision repository after an armed generation', async () => { const h = harness(); h.setAnswer(resolved('running', 'none', 'run')); await h.monitor.tick(); diff --git a/server/service-worker-completion-payload.test.ts b/server/service-worker-completion-payload.test.ts index 181217c..fa3e4fc 100644 --- a/server/service-worker-completion-payload.test.ts +++ b/server/service-worker-completion-payload.test.ts @@ -122,17 +122,41 @@ test('service worker falls back from invalid completion titles and navigation ta test('completion notification clicks navigate a focused client or open the target', async () => { const focusedRuntime = await serviceWorkerRuntime(); const calls: string[] = []; + const messages: unknown[] = []; focusedRuntime.clients.push({ url: 'https://chatmux.test/', focus: async () => { calls.push('focus'); }, navigate: async (target: string) => { calls.push(`navigate:${target}`); }, + postMessage: (message: unknown) => { messages.push(message); }, }); - await focusedRuntime.click({ navigation: { href: '/session/focused' } }); + await focusedRuntime.push({ navigation: { href: '/session/focused' } }); + await focusedRuntime.click(focusedRuntime.notifications[0].options.data); assert.deepEqual(calls, ['focus', 'navigate:/session/focused']); + assert.deepEqual(messages.map((message) => JSON.parse(JSON.stringify(message))), [{ + type: 'notification:navigate', + sessionId: 'focused', + provider: null, + urlPath: '/session/focused', + }]); assert.deepEqual(focusedRuntime.openWindows, []); + const rejectedRuntime = await serviceWorkerRuntime(); + const rejectedMessages: unknown[] = []; + rejectedRuntime.clients.push({ + url: 'https://chatmux.test/', + focus: async () => {}, + navigate: async () => { throw new Error('navigation rejected'); }, + postMessage: (message: unknown) => { rejectedMessages.push(message); }, + }); + + await rejectedRuntime.push({ navigation: { href: '/session/rejected' } }); + await rejectedRuntime.click(rejectedRuntime.notifications[0].options.data); + + assert.equal((rejectedMessages[0] as { sessionId?: unknown })?.sessionId, 'rejected'); + assert.deepEqual(rejectedRuntime.openWindows, []); + const openRuntime = await serviceWorkerRuntime(); openRuntime.clients.push({ url: 'https://chatmux.test.attacker.test/', focus: async () => {}, navigate: async () => {} }); diff --git a/src/components/code-editor/constants/settings.ts b/src/components/code-editor/constants/settings.ts index 4d8d106..b053fff 100644 --- a/src/components/code-editor/constants/settings.ts +++ b/src/components/code-editor/constants/settings.ts @@ -9,7 +9,7 @@ export const CODE_EDITOR_DEFAULTS = { wordWrap: false, minimapEnabled: true, showLineNumbers: true, - fontSize: '12', + fontSize: '14', } as const; export const CODE_EDITOR_SETTINGS_CHANGED_EVENT = 'codeEditorSettingsChanged'; diff --git a/src/components/code-editor/utils/editorStyles.test.ts b/src/components/code-editor/utils/editorStyles.test.ts new file mode 100644 index 0000000..75d0154 --- /dev/null +++ b/src/components/code-editor/utils/editorStyles.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { getEditorFontSizeTheme } from './editorStyles'; + +test('code editor font-size setting reaches editor content and gutters', () => { + assert.deepEqual(getEditorFontSizeTheme(18), { + '&': { fontSize: '18px' }, + '.cm-content': { fontSize: 'inherit' }, + '.cm-gutters': { fontSize: 'inherit' }, + }); +}); diff --git a/src/components/code-editor/utils/editorStyles.ts b/src/components/code-editor/utils/editorStyles.ts index b3a6c61..1b4cf04 100644 --- a/src/components/code-editor/utils/editorStyles.ts +++ b/src/components/code-editor/utils/editorStyles.ts @@ -10,6 +10,12 @@ export const getEditorLoadingStyles = (isDarkMode: boolean) => { `; }; +export const getEditorFontSizeTheme = (fontSize: number) => ({ + '&': { fontSize: `${fontSize}px` }, + '.cm-content': { fontSize: 'inherit' }, + '.cm-gutters': { fontSize: 'inherit' }, +}); + export const getEditorStyles = (isDarkMode: boolean) => { return ` .cm-deletedChunk { diff --git a/src/components/code-editor/view/CodeEditor.tsx b/src/components/code-editor/view/CodeEditor.tsx index c899718..0cc7602 100644 --- a/src/components/code-editor/view/CodeEditor.tsx +++ b/src/components/code-editor/view/CodeEditor.tsx @@ -11,7 +11,7 @@ import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings'; import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts'; import type { CodeEditorFile } from '../types/types'; import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions'; -import { getEditorStyles } from '../utils/editorStyles'; +import { getEditorFontSizeTheme, getEditorStyles } from '../utils/editorStyles'; import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel'; import CodeEditorFooter from './subcomponents/CodeEditorFooter'; @@ -142,10 +142,16 @@ export default function CodeEditor({ [file, isExpanded, isSidebar, onPopOut, onToggleExpand, showDiff, t], ); + const fontSizeExtension = useMemo( + () => EditorView.theme(getEditorFontSizeTheme(fontSize)), + [fontSize], + ); + const extensions = useMemo(() => { const allExtensions: Extension[] = [ ...getLanguageExtensions(file.name), ...toolbarPanelExtension, + fontSizeExtension, ]; if (file.diffInfo && showDiff && file.diffInfo.old_string !== undefined) { @@ -170,6 +176,7 @@ export default function CodeEditor({ }, [ file.diffInfo, file.name, + fontSizeExtension, minimapExtension, scrollToFirstChunkExtension, showDiff, From 5b24ef021d45b7ddda4e67ef97b2b438720c746b Mon Sep 17 00:00:00 2001 From: YoonwooHa Date: Mon, 10 Aug 2026 06:49:10 +0900 Subject: [PATCH 2/5] feat: add interface-wide font size setting --- .../settings/hooks/useSettingsController.ts | 13 +++++++ src/components/settings/types/types.ts | 2 ++ src/components/settings/view/Settings.tsx | 4 +++ .../view/tabs/AppearanceSettingsTab.tsx | 23 +++++++++++-- src/i18n/locales/de/settings.json | 7 ++++ src/i18n/locales/en/settings.json | 7 ++++ src/i18n/locales/fr/settings.json | 7 ++++ src/i18n/locales/it/settings.json | 7 ++++ src/i18n/locales/ja/settings.json | 7 ++++ src/i18n/locales/ko/settings.json | 7 ++++ src/i18n/locales/ru/settings.json | 7 ++++ src/i18n/locales/tr/settings.json | 7 ++++ src/i18n/locales/zh-CN/settings.json | 7 ++++ src/i18n/locales/zh-TW/settings.json | 7 ++++ src/index.css | 27 +++++++++++++++ src/main.jsx | 3 ++ src/utils/interfaceFontSize.test.ts | 34 +++++++++++++++++++ src/utils/interfaceFontSize.ts | 32 +++++++++++++++++ 18 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 src/utils/interfaceFontSize.test.ts create mode 100644 src/utils/interfaceFontSize.ts diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index 6c18c48..106e93d 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -3,6 +3,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTheme } from '../../../contexts/ThemeContext'; import { authenticatedFetch } from '../../../utils/api'; import { setNotificationSoundEnabled } from '../../../utils/notificationSound'; +import { + applyInterfaceFontSize, + INTERFACE_FONT_SIZE_STORAGE_KEY, + readInterfaceFontSize, +} from '../../../utils/interfaceFontSize'; import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus'; import { DEFAULT_CODE_EDITOR_SETTINGS, @@ -144,6 +149,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl const [activeTab, setActiveTab] = useState(() => normalizeMainTab(initialTab)); const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null); const [projectSortOrder, setProjectSortOrder] = useState('name'); + const [interfaceFontSize, setInterfaceFontSize] = useState(readInterfaceFontSize); const [codeEditorSettings, setCodeEditorSettings] = useState(() => ( readCodeEditorSettings() )); @@ -314,6 +320,11 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl setNotificationSoundEnabled(notificationPreferences.channels.sound); }, [notificationPreferences.channels.sound]); + useEffect(() => { + localStorage.setItem(INTERFACE_FONT_SIZE_STORAGE_KEY, interfaceFontSize); + applyInterfaceFontSize(interfaceFontSize); + }, [interfaceFontSize]); + useEffect(() => { localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap)); localStorage.setItem('codeEditorShowMinimap', String(codeEditorSettings.showMinimap)); @@ -384,6 +395,8 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl saveStatus, projectSortOrder, setProjectSortOrder, + interfaceFontSize, + setInterfaceFontSize, codeEditorSettings, updateCodeEditorSetting, claudePermissions, diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index f43c431..afaeefb 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react'; import type { LLMProvider } from '../../../types/app'; import type { ProviderAuthStatus } from '../../provider-auth/types'; +import type { InterfaceFontSize } from '../../../utils/interfaceFontSize'; export type SettingsMainTab = 'agents' | 'appearance' | 'access'; export type AgentProvider = LLMProvider; @@ -9,6 +10,7 @@ export type AgentCategory = 'account' | 'permissions'; export type ProjectSortOrder = 'name' | 'date'; export type SaveStatus = 'success' | 'error' | null; export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions'; +export type { InterfaceFontSize }; export type SettingsProject = { name: string; diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 5521adf..1a4b925 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -23,6 +23,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set saveStatus, projectSortOrder, setProjectSortOrder, + interfaceFontSize, + setInterfaceFontSize, codeEditorSettings, updateCodeEditorSetting, claudePermissions, @@ -116,6 +118,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set updateCodeEditorSetting('wordWrap', value)} onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)} diff --git a/src/components/settings/view/tabs/AppearanceSettingsTab.tsx b/src/components/settings/view/tabs/AppearanceSettingsTab.tsx index 1535332..740e0d9 100644 --- a/src/components/settings/view/tabs/AppearanceSettingsTab.tsx +++ b/src/components/settings/view/tabs/AppearanceSettingsTab.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useUiPreferences } from '../../../../hooks/useUiPreferences'; import { DarkModeToggle } from '../../../../shared/view/ui'; -import type { CodeEditorSettingsState, ProjectSortOrder } from '../../types/types'; +import type { CodeEditorSettingsState, InterfaceFontSize, ProjectSortOrder } from '../../types/types'; import LanguageSelector from '../../../../shared/view/ui/LanguageSelector'; import SettingsCard from '../SettingsCard'; import SettingsRow from '../SettingsRow'; @@ -14,6 +14,8 @@ import InstallAppSection from './InstallAppSection'; type AppearanceSettingsTabProps = { projectSortOrder: ProjectSortOrder; onProjectSortOrderChange: (value: ProjectSortOrder) => void; + interfaceFontSize: InterfaceFontSize; + onInterfaceFontSizeChange: (value: InterfaceFontSize) => void; codeEditorSettings: CodeEditorSettingsState; onCodeEditorWordWrapChange: (value: boolean) => void; onCodeEditorShowMinimapChange: (value: boolean) => void; @@ -24,6 +26,8 @@ type AppearanceSettingsTabProps = { export default function AppearanceSettingsTab({ projectSortOrder, onProjectSortOrderChange, + interfaceFontSize, + onInterfaceFontSizeChange, codeEditorSettings, onCodeEditorWordWrapChange, onCodeEditorShowMinimapChange, @@ -36,13 +40,28 @@ export default function AppearanceSettingsTab({ return (
- + + + + + diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index a46f7f8..4663d80 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -76,6 +76,13 @@ "label": "Darkmode", "description": "Zwischen hellem und dunklem Design wechseln" }, + "interfaceFontSize": { + "label": "Schriftgröße der Benutzeroberfläche", + "description": "Textgröße in Chats, Sitzungen, Einstellungen und der übrigen Oberfläche ändern", + "small": "Klein", + "medium": "Mittel", + "large": "Groß" + }, "projectSorting": { "label": "Projektsortierung", "description": "Wie Projekte in der Seitenleiste angeordnet werden", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 7704ea1..6351f9b 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -76,6 +76,13 @@ "label": "Dark Mode", "description": "Toggle between light and dark themes" }, + "interfaceFontSize": { + "label": "Interface font size", + "description": "Change text size across chats, sessions, settings, and the rest of the interface", + "small": "Small", + "medium": "Medium", + "large": "Large" + }, "projectSorting": { "label": "Project Sorting", "description": "How projects are ordered in the sidebar", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 3d813d7..f46365e 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -76,6 +76,13 @@ "label": "Mode sombre", "description": "Basculer entre les thèmes clair et sombre" }, + "interfaceFontSize": { + "label": "Taille du texte de l’interface", + "description": "Modifier la taille du texte dans les discussions, sessions, paramètres et le reste de l’interface", + "small": "Petite", + "medium": "Moyenne", + "large": "Grande" + }, "projectSorting": { "label": "Tri des projets", "description": "Ordre d'affichage des projets dans la barre latérale", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index dcf768a..a391b30 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -76,6 +76,13 @@ "label": "Modalità scura", "description": "Alterna tra tema chiaro e scuro" }, + "interfaceFontSize": { + "label": "Dimensione testo dell’interfaccia", + "description": "Modifica la dimensione del testo in chat, sessioni, impostazioni e nel resto dell’interfaccia", + "small": "Piccola", + "medium": "Media", + "large": "Grande" + }, "projectSorting": { "label": "Ordinamento progetti", "description": "Come vengono ordinati i progetti nella barra laterale", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 03a68bb..fc4398b 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -76,6 +76,13 @@ "label": "ダークモード", "description": "ライトテーマとダークテーマを切り替えます" }, + "interfaceFontSize": { + "label": "インターフェースの文字サイズ", + "description": "チャット、セッション、設定など画面全体の文字サイズを変更します", + "small": "小", + "medium": "中", + "large": "大" + }, "projectSorting": { "label": "プロジェクトの並び順", "description": "サイドバーでのプロジェクトの並び順を設定します", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index f13bf1e..46cf5f4 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -76,6 +76,13 @@ "label": "다크 모드", "description": "라이트/다크 테마 전환" }, + "interfaceFontSize": { + "label": "전체 글꼴 크기", + "description": "채팅, 세션 목록, 설정 등 전체 화면의 글꼴 크기", + "small": "작게", + "medium": "중간", + "large": "크게" + }, "projectSorting": { "label": "프로젝트 정렬", "description": "사이드바에서 프로젝트 정렬 방식", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 022e5a5..85892e5 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -76,6 +76,13 @@ "label": "Темная тема", "description": "Переключение между светлой и темной темами" }, + "interfaceFontSize": { + "label": "Размер шрифта интерфейса", + "description": "Изменить размер текста в чатах, сеансах, настройках и остальном интерфейсе", + "small": "Маленький", + "medium": "Средний", + "large": "Большой" + }, "projectSorting": { "label": "Сортировка проектов", "description": "Как проекты упорядочены на боковой панели", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index fda5fab..154e064 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -77,6 +77,13 @@ "label": "Koyu Mod", "description": "Açık ve koyu temalar arasında geçiş yap" }, + "interfaceFontSize": { + "label": "Arayüz yazı tipi boyutu", + "description": "Sohbetler, oturumlar, ayarlar ve arayüzün geri kalanındaki metin boyutunu değiştir", + "small": "Küçük", + "medium": "Orta", + "large": "Büyük" + }, "projectSorting": { "label": "Proje Sıralama", "description": "Projelerin kenar çubuğunda nasıl sıralanacağı", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index f07e31d..4357534 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -77,6 +77,13 @@ "label": "深色模式", "description": "切换浅色和深色主题" }, + "interfaceFontSize": { + "label": "界面字体大小", + "description": "调整聊天、会话、设置及其他界面中的文字大小", + "small": "小", + "medium": "中", + "large": "大" + }, "projectSorting": { "label": "项目排序", "description": "项目在侧边栏中的排列方式", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 616b604..c2cf686 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -77,6 +77,13 @@ "label": "深色模式", "description": "切換淺色和深色佈景主題" }, + "interfaceFontSize": { + "label": "介面字型大小", + "description": "調整聊天、工作階段、設定及其他介面中的文字大小", + "small": "小", + "medium": "中", + "large": "大" + }, "projectSorting": { "label": "專案排序", "description": "專案在側邊欄中的排列方式", diff --git a/src/index.css b/src/index.css index ef36fa9..e8c22d6 100644 --- a/src/index.css +++ b/src/index.css @@ -2,6 +2,33 @@ @tailwind components; @tailwind utilities; +/* Tailwind's standard text sizes use rem and follow the root interface size. + Scale the few fixed-pixel labels too so badges and metadata keep the same + visual ratio at every interface font-size setting. */ +.text-\[9px\] { + font-size: calc(9px * var(--interface-font-scale, 1)); +} + +.text-\[10px\] { + font-size: calc(10px * var(--interface-font-scale, 1)); +} + +.text-\[11px\] { + font-size: calc(11px * var(--interface-font-scale, 1)); +} + +.text-\[12px\] { + font-size: calc(12px * var(--interface-font-scale, 1)); +} + +.text-\[13px\] { + font-size: calc(13px * var(--interface-font-scale, 1)); +} + +.text-\[14px\] { + font-size: calc(14px * var(--interface-font-scale, 1)); +} + /* Global spinner animation - defined early to ensure it loads */ @keyframes spin { 0% { diff --git a/src/main.jsx b/src/main.jsx index 0443b3b..3dc6a44 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -10,6 +10,9 @@ import './index.css' import './i18n/config.js' import './utils/pwaInstall.ts' import { refreshAfterServerUpdate, registerServiceWorker } from './services/serviceWorkerUpdate' +import { applyInterfaceFontSize, readInterfaceFontSize } from './utils/interfaceFontSize.ts' + +applyInterfaceFontSize(readInterfaceFontSize()) // Pretendard is self-hosted with Korean/Latin glyphs so Hangul does not fall // back to a serif font. The imports above load it before the application CSS. diff --git a/src/utils/interfaceFontSize.test.ts b/src/utils/interfaceFontSize.test.ts new file mode 100644 index 0000000..26b5ae7 --- /dev/null +++ b/src/utils/interfaceFontSize.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + applyInterfaceFontSize, + INTERFACE_FONT_SIZE_PIXELS, + INTERFACE_FONT_SIZE_SCALES, + normalizeInterfaceFontSize, + readInterfaceFontSize, +} from './interfaceFontSize'; + +test('normalizes interface font sizes and defaults invalid values to medium', () => { + assert.equal(normalizeInterfaceFontSize('small'), 'small'); + assert.equal(normalizeInterfaceFontSize('large'), 'large'); + assert.equal(normalizeInterfaceFontSize('unexpected'), 'medium'); + assert.equal(normalizeInterfaceFontSize(null), 'medium'); +}); + +test('reads and applies the persisted interface font size', () => { + assert.equal(readInterfaceFontSize({ getItem: () => 'large' }), 'large'); + + const customProperties = new Map(); + const root = { + dataset: {}, + style: { + setProperty: (name: string, value: string) => customProperties.set(name, value), + }, + } as unknown as HTMLElement; + applyInterfaceFontSize('small', root); + + assert.equal(root.dataset.interfaceFontSize, 'small'); + assert.equal(root.style.fontSize, `${INTERFACE_FONT_SIZE_PIXELS.small}px`); + assert.equal(customProperties.get('--interface-font-scale'), String(INTERFACE_FONT_SIZE_SCALES.small)); +}); diff --git a/src/utils/interfaceFontSize.ts b/src/utils/interfaceFontSize.ts new file mode 100644 index 0000000..0ac11d6 --- /dev/null +++ b/src/utils/interfaceFontSize.ts @@ -0,0 +1,32 @@ +export const INTERFACE_FONT_SIZE_STORAGE_KEY = 'interfaceFontSize'; + +export type InterfaceFontSize = 'small' | 'medium' | 'large'; + +export const INTERFACE_FONT_SIZE_PIXELS: Record = { + small: 14, + medium: 16, + large: 18, +}; + +export const INTERFACE_FONT_SIZE_SCALES: Record = { + small: 0.875, + medium: 1, + large: 1.125, +}; + +export const normalizeInterfaceFontSize = (value: unknown): InterfaceFontSize => ( + value === 'small' || value === 'large' ? value : 'medium' +); + +export const readInterfaceFontSize = (storage: Pick = localStorage): InterfaceFontSize => ( + normalizeInterfaceFontSize(storage.getItem(INTERFACE_FONT_SIZE_STORAGE_KEY)) +); + +export const applyInterfaceFontSize = ( + value: InterfaceFontSize, + root: HTMLElement = document.documentElement, +): void => { + root.dataset.interfaceFontSize = value; + root.style.fontSize = `${INTERFACE_FONT_SIZE_PIXELS[value]}px`; + root.style.setProperty('--interface-font-scale', String(INTERFACE_FONT_SIZE_SCALES[value])); +}; From e261b1ea0c7a1f59d06e006d9dac2c49f1245905 Mon Sep 17 00:00:00 2001 From: YoonwooHa Date: Mon, 10 Aug 2026 09:04:13 +0900 Subject: [PATCH 3/5] fix: interrupt Codex turns without exiting the CLI --- .../external-session-activity.service.ts | 2 ++ .../services/tmux-pane-actions.service.ts | 15 ++++++++----- .../external-session-activity.service.test.ts | 22 +++++++++++++++++++ .../tests/support/tmux-e2e-harness.ts | 8 ++++++- .../tests/tmux-pane-actions.service.test.ts | 18 +++++++++++++-- 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/server/modules/providers/services/external-session-activity.service.ts b/server/modules/providers/services/external-session-activity.service.ts index 073f4a5..3d5756a 100644 --- a/server/modules/providers/services/external-session-activity.service.ts +++ b/server/modules/providers/services/external-session-activity.service.ts @@ -302,11 +302,13 @@ const parseCodexEvidence = (records: JsonRecord[]): ExternalSessionParsedActivit const payload = asRecord(record.payload); const payloadType = readString(payload?.type)?.toLowerCase(); + if (type === 'turn_aborted') return evidence('waiting_user', 'none'); if (type === 'turn_failed' || type === 'error' || isErrorRecord(record) || isErrorRecord(payload ?? {})) { return evidence('waiting_user', 'failed'); } if (type === 'turn_complete') return evidence('waiting_user', 'reply_ready'); if (type === 'event_msg') { + if (payloadType === 'turn_aborted') return evidence('waiting_user', 'none'); if (payloadType === 'turn_failed' || payloadType === 'error') return evidence('waiting_user', 'failed'); if (payloadType === 'task_complete' || payloadType === 'turn_complete') return evidence('waiting_user', 'reply_ready'); if (containsAskingTool(payload)) return evidence('asking_user', 'none'); diff --git a/server/modules/providers/services/tmux-pane-actions.service.ts b/server/modules/providers/services/tmux-pane-actions.service.ts index fd3133c..5910a76 100644 --- a/server/modules/providers/services/tmux-pane-actions.service.ts +++ b/server/modules/providers/services/tmux-pane-actions.service.ts @@ -23,10 +23,15 @@ export type TmuxSelectionKey = | 'BTab' | 'Escape'; -const TMUX_PROCESS_ACTION_KEYS: Readonly> = { - interrupt: 'C-c', - escape: 'Escape', -}; +function tmuxProcessActionKey( + target: VerifiedTmuxActionTarget, + action: TmuxProcessAction, +): 'C-c' | 'Escape' { + if (action === 'escape') return 'Escape'; + // Codex handles Esc as "interrupt the active turn". Ctrl+C is a process + // signal and can terminate the CLI when its activity status is briefly stale. + return target.kind === 'codex' ? 'Escape' : 'C-c'; +} export function readTmuxPaneIdentity(value: unknown): TmuxPaneIdentity { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -170,7 +175,7 @@ export async function sendTmuxProcessAction( const identity = target.tmux; await assertTmuxPaneIdentity(identity, run); await requireTmuxSuccess(identity, [ - 'send-keys', '-t', identity.paneId, TMUX_PROCESS_ACTION_KEYS[action], + 'send-keys', '-t', identity.paneId, tmuxProcessActionKey(target, action), ], run); } diff --git a/server/modules/providers/tests/external-session-activity.service.test.ts b/server/modules/providers/tests/external-session-activity.service.test.ts index 772dfbe..741554a 100644 --- a/server/modules/providers/tests/external-session-activity.service.test.ts +++ b/server/modules/providers/tests/external-session-activity.service.test.ts @@ -173,6 +173,28 @@ test('Codex activity uses explicit task lifecycle events and request_user_input' })), 'waiting_user'); }); +test('Codex interrupted turns return to READY without a completion or failure outcome', () => { + const interrupted = { + type: 'event_msg', + payload: { type: 'turn_aborted', reason: 'interrupted' }, + }; + assert.deepEqual( + parseExternalJsonlActivityEvidence('codex', [ + line({ type: 'event_msg', payload: { type: 'task_started' } }), + line({ type: 'response_item', payload: { type: 'message', role: 'assistant' } }), + line(interrupted), + ].join('\n')), + { activity: 'waiting_user', terminalOutcome: 'none' }, + ); + assert.deepEqual( + parseExternalJsonlActivityEvidence('codex', line({ + type: 'turn_aborted', + reason: 'interrupted', + })), + { activity: 'waiting_user', terminalOutcome: 'none' }, + ); +}); + test('Cursor activity fails closed and treats unfinished tools as running', () => { assert.equal(parseExternalJsonlActivity('cursor', line({ role: 'assistant', diff --git a/server/modules/providers/tests/support/tmux-e2e-harness.ts b/server/modules/providers/tests/support/tmux-e2e-harness.ts index dfe5f2b..eb5d80b 100644 --- a/server/modules/providers/tests/support/tmux-e2e-harness.ts +++ b/server/modules/providers/tests/support/tmux-e2e-harness.ts @@ -142,14 +142,20 @@ const startLongRunningTurn = () => { }, 1_000); }; emit({ type: 'ready', pid: process.pid }); +process.stdin.setRawMode?.(true); +process.stdin.resume(); const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false }); -process.on('SIGINT', () => { +const interruptTurn = () => { emit({ type: 'interrupt' }); if (runningTurn !== undefined) { clearTimeout(runningTurn); runningTurn = undefined; emit({ type: 'turn_interrupted' }); } +}; +process.on('SIGINT', interruptTurn); +process.stdin.on('data', (chunk) => { + if (chunk.includes(0x1b) || chunk.includes(0x03)) interruptTurn(); }); input.on('line', (value) => { emit({ type: 'input', value }); diff --git a/server/modules/providers/tests/tmux-pane-actions.service.test.ts b/server/modules/providers/tests/tmux-pane-actions.service.test.ts index c690c09..0349b94 100644 --- a/server/modules/providers/tests/tmux-pane-actions.service.test.ts +++ b/server/modules/providers/tests/tmux-pane-actions.service.test.ts @@ -116,12 +116,12 @@ test('send refuses a stale pane before staging bytes in a tmux buffer', async () ); assert.equal(calls.some(({ args }) => args.includes('load-buffer')), false); }); -test('process actions assemble only the typed interrupt and escape argv arrays', async () => { +test('process actions use Codex Escape interrupts and typed escape argv arrays', async () => { const interrupt = recordingRunner(['$7\t@8\t%9\n']); await sendTmuxProcessAction(target, 'interrupt', interrupt.run); assert.deepEqual(interrupt.calls.map(({ args }) => args), [ ['-S', identity.socketPath, 'display-message', '-p', '-t', identity.paneId, '#{session_id}\t#{window_id}\t#{pane_id}'], - ['-S', identity.socketPath, 'send-keys', '-t', identity.paneId, 'C-c'], + ['-S', identity.socketPath, 'send-keys', '-t', identity.paneId, 'Escape'], ]); const escape = recordingRunner(['$7\t@8\t%9\n']); @@ -131,6 +131,20 @@ test('process actions assemble only the typed interrupt and escape argv arrays', ]); }); +test('non-Codex process interrupts retain Ctrl+C', async () => { + const claudeTarget = createVerifiedTmuxActionTarget( + identity, + { pid: 42, startedAtMs: 1234 }, + 'claude', + 'test', + ); + const interrupt = recordingRunner(['$7\t@8\t%9\n']); + await sendTmuxProcessAction(claudeTarget, 'interrupt', interrupt.run); + assert.deepEqual(interrupt.calls[1]?.args, [ + '-S', identity.socketPath, 'send-keys', '-t', identity.paneId, 'C-c', + ]); +}); + test('process action refuses a stale pane before sending keys', async () => { const { calls, run } = recordingRunner(['$7\t@8\t%99\n']); await assert.rejects( From 8789fbbe9c4628a44e527d156cb07a37f8a9f0fa Mon Sep 17 00:00:00 2001 From: YoonwooHa Date: Mon, 10 Aug 2026 13:03:26 +0900 Subject: [PATCH 4/5] feat: add chat image preview preference --- src/components/chat/types/types.ts | 1 + src/components/chat/view/ChatInterface.tsx | 2 ++ .../view/subcomponents/ChatMessagesPane.tsx | 4 ++++ .../subcomponents/MessageComponent.test.tsx | 21 +++++++++++++++++++ .../view/subcomponents/MessageComponent.tsx | 5 +++-- .../view/subcomponents/ToolGroupContainer.tsx | 3 +++ .../main-content/view/MainContent.tsx | 3 ++- .../view/tabs/AppearanceSettingsTab.tsx | 11 ++++++++++ src/hooks/useUiPreferences.ts | 2 ++ src/i18n/locales/de/settings.json | 4 ++++ src/i18n/locales/en/settings.json | 4 ++++ src/i18n/locales/fr/settings.json | 4 ++++ src/i18n/locales/it/settings.json | 4 ++++ src/i18n/locales/ja/settings.json | 4 ++++ src/i18n/locales/ko/settings.json | 4 ++++ src/i18n/locales/ru/settings.json | 4 ++++ src/i18n/locales/tr/settings.json | 4 ++++ src/i18n/locales/zh-CN/settings.json | 4 ++++ src/i18n/locales/zh-TW/settings.json | 4 ++++ 19 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index af6146a..1d76559 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -141,6 +141,7 @@ export interface ChatInterfaceProps { onShowSettings?: () => void; showRawParameters?: boolean; showThinking?: boolean; + showImagePreviews?: boolean; sendByCtrlEnter?: boolean; externalMessageUpdate?: number; newSessionTrigger?: number; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 8d3eecd..1a2792d 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -39,6 +39,7 @@ function ChatInterface({ onShowSettings, showRawParameters, showThinking, + showImagePreviews, sendByCtrlEnter, externalMessageUpdate, newSessionTrigger, @@ -449,6 +450,7 @@ function ChatInterface({ onGrantToolPermission={handleGrantToolPermission} showRawParameters={showRawParameters} showThinking={showThinking} + showImagePreviews={showImagePreviews} selectedProject={selectedProject} transcriptView={Boolean(liveSessionKind && liveSessionKind !== 'gjc')} pendingAskToolId={pendingRelayAsk?.toolId ?? null} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index 6bd4712..c970d3e 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -62,6 +62,7 @@ interface ChatMessagesPaneProps { onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean }; showRawParameters?: boolean; showThinking?: boolean; + showImagePreviews?: boolean; selectedProject: Project; transcriptView?: boolean; pendingAskToolId?: string | null; @@ -112,6 +113,7 @@ function ChatMessagesPane({ onGrantToolPermission, showRawParameters, showThinking, + showImagePreviews = true, selectedProject, transcriptView = false, pendingAskToolId = null, @@ -265,6 +267,7 @@ function ChatMessagesPane({ onGrantToolPermission={onGrantToolPermission} showRawParameters={showRawParameters} showThinking={showThinking} + showImagePreviews={showImagePreviews} selectedProject={selectedProject} provider={provider} transcriptView={transcriptView} @@ -289,6 +292,7 @@ function ChatMessagesPane({ onGrantToolPermission={onGrantToolPermission} showRawParameters={showRawParameters} showThinking={showThinking} + showImagePreviews={showImagePreviews} selectedProject={selectedProject} provider={provider} transcriptView={transcriptView} diff --git a/src/components/chat/view/subcomponents/MessageComponent.test.tsx b/src/components/chat/view/subcomponents/MessageComponent.test.tsx index 792529a..cbb6040 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.test.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.test.tsx @@ -72,3 +72,24 @@ test('screen-driven multi-question asks hide the inert transcript duplicate', () assert.match(renderMessage(message), /Second\?/); assert.doesNotMatch(renderMessage(message, 'ask-multi'), /Second\?/); }); + +test('image preview preference omits attachment markup when disabled', () => { + const message: ChatMessage = { + type: 'user', + content: 'inspect this', + timestamp: '2026-08-10T00:00:00.000Z', + images: [{ data: 'data:image/png;base64,aGVsbG8=', name: 'sample.png' }], + }; + const props = { + message, + prevMessage: null, + createDiff: () => [], + provider: 'codex', + }; + + assert.match(renderToStaticMarkup(createElement(MessageComponent, props)), /sample\.png/); + assert.doesNotMatch( + renderToStaticMarkup(createElement(MessageComponent, { ...props, showImagePreviews: false })), + /sample\.png|data:image\/png/, + ); +}); diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index 8875b5c..c65fa46 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -32,6 +32,7 @@ type MessageComponentProps = { onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined; showRawParameters?: boolean; showThinking?: boolean; + showImagePreviews?: boolean; selectedProject?: Project | null; provider: Provider | string; transcriptView?: boolean; @@ -52,7 +53,7 @@ const compactErrorSummary = (content: string, fallback: string): string => { return firstLine.length > 160 ? `${firstLine.slice(0, 157)}...` : firstLine; }; -const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, selectedProject, provider, transcriptView = false, pendingAskToolId = null, suppressedAskToolId = null, onAskChoiceSelect }: MessageComponentProps) => { +const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, showImagePreviews = true, selectedProject, provider, transcriptView = false, pendingAskToolId = null, suppressedAskToolId = null, onAskChoiceSelect }: MessageComponentProps) => { const { t } = useTranslation('chat'); const isGrouped = prevMessage && prevMessage.type === message.type && ((prevMessage.type === 'assistant') || @@ -99,7 +100,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s /* User turn on the right: claude.ai-style attachment cards above the bubble */
- {message.images && message.images.length > 0 && ( + {showImagePreviews && message.images && message.images.length > 0 && ( PermissionGrantResult | null | undefined; showRawParameters?: boolean; showThinking?: boolean; + showImagePreviews?: boolean; selectedProject?: Project | null; provider: Provider | string; transcriptView?: boolean; @@ -71,6 +72,7 @@ export default function ToolGroupContainer({ onGrantToolPermission, showRawParameters, showThinking, + showImagePreviews = true, selectedProject, provider, transcriptView = false, @@ -141,6 +143,7 @@ export default function ToolGroupContainer({ onGrantToolPermission={onGrantToolPermission} showRawParameters={showRawParameters} showThinking={showThinking} + showImagePreviews={showImagePreviews} selectedProject={selectedProject} provider={provider} transcriptView={transcriptView} diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index ae89eba..dd029d6 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -126,7 +126,7 @@ function MainContent({ }: MainContentProps) { const { preferences } = useUiPreferences(); const { t } = useTranslation('chat'); - const { showRawParameters, showThinking, sendByCtrlEnter } = preferences; + const { showRawParameters, showThinking, showImagePreviews, sendByCtrlEnter } = preferences; const [externalPaneOutput, setExternalPaneOutput] = useState(''); const [externalPaneError, setExternalPaneError] = useState(''); @@ -534,6 +534,7 @@ function MainContent({ onShowSettings={onShowSettings} showRawParameters={showRawParameters} showThinking={showThinking} + showImagePreviews={showImagePreviews} sendByCtrlEnter={sendByCtrlEnter} externalMessageUpdate={externalMessageUpdate} newSessionTrigger={newSessionTrigger} diff --git a/src/components/settings/view/tabs/AppearanceSettingsTab.tsx b/src/components/settings/view/tabs/AppearanceSettingsTab.tsx index 740e0d9..34748b9 100644 --- a/src/components/settings/view/tabs/AppearanceSettingsTab.tsx +++ b/src/components/settings/view/tabs/AppearanceSettingsTab.tsx @@ -62,6 +62,17 @@ export default function AppearanceSettingsTab({ + + + setPreference('showImagePreviews', value)} + ariaLabel={t('appearanceSettings.imagePreviews.label')} + /> + diff --git a/src/hooks/useUiPreferences.ts b/src/hooks/useUiPreferences.ts index 3ee09c0..6cb847f 100644 --- a/src/hooks/useUiPreferences.ts +++ b/src/hooks/useUiPreferences.ts @@ -3,6 +3,7 @@ import { useEffect, useReducer, useRef } from 'react'; type UiPreferences = { showRawParameters: boolean; showThinking: boolean; + showImagePreviews: boolean; sendByCtrlEnter: boolean; sidebarVisible: boolean; }; @@ -33,6 +34,7 @@ type UiPreferencesAction = const DEFAULTS: UiPreferences = { showRawParameters: false, showThinking: true, + showImagePreviews: true, sendByCtrlEnter: false, sidebarVisible: true, }; diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index 4663d80..2cb3b1f 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -83,6 +83,10 @@ "medium": "Mittel", "large": "Groß" }, + "imagePreviews": { + "label": "Bildvorschauen", + "description": "Bildanhänge im Chatverlauf laden und anzeigen" + }, "projectSorting": { "label": "Projektsortierung", "description": "Wie Projekte in der Seitenleiste angeordnet werden", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 6351f9b..17c59ed 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -83,6 +83,10 @@ "medium": "Medium", "large": "Large" }, + "imagePreviews": { + "label": "Image previews", + "description": "Load and display image attachments in chat history" + }, "projectSorting": { "label": "Project Sorting", "description": "How projects are ordered in the sidebar", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index f46365e..991a3e5 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -83,6 +83,10 @@ "medium": "Moyenne", "large": "Grande" }, + "imagePreviews": { + "label": "Aperçus d’images", + "description": "Charger et afficher les images jointes dans l’historique du chat" + }, "projectSorting": { "label": "Tri des projets", "description": "Ordre d'affichage des projets dans la barre latérale", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index a391b30..c47694d 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -83,6 +83,10 @@ "medium": "Media", "large": "Grande" }, + "imagePreviews": { + "label": "Anteprime immagini", + "description": "Carica e mostra le immagini allegate nella cronologia chat" + }, "projectSorting": { "label": "Ordinamento progetti", "description": "Come vengono ordinati i progetti nella barra laterale", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index fc4398b..edf222c 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -83,6 +83,10 @@ "medium": "中", "large": "大" }, + "imagePreviews": { + "label": "画像プレビュー", + "description": "チャット履歴の添付画像を読み込んで表示します" + }, "projectSorting": { "label": "プロジェクトの並び順", "description": "サイドバーでのプロジェクトの並び順を設定します", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 46cf5f4..6674483 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -83,6 +83,10 @@ "medium": "중간", "large": "크게" }, + "imagePreviews": { + "label": "이미지 미리보기", + "description": "채팅 기록의 첨부 이미지를 불러와 표시" + }, "projectSorting": { "label": "프로젝트 정렬", "description": "사이드바에서 프로젝트 정렬 방식", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 85892e5..85e3a91 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -83,6 +83,10 @@ "medium": "Средний", "large": "Большой" }, + "imagePreviews": { + "label": "Предпросмотр изображений", + "description": "Загружать и показывать изображения из истории чата" + }, "projectSorting": { "label": "Сортировка проектов", "description": "Как проекты упорядочены на боковой панели", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 154e064..e3cf9fb 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -84,6 +84,10 @@ "medium": "Orta", "large": "Büyük" }, + "imagePreviews": { + "label": "Görsel önizlemeleri", + "description": "Sohbet geçmişindeki görsel eklerini yükle ve göster" + }, "projectSorting": { "label": "Proje Sıralama", "description": "Projelerin kenar çubuğunda nasıl sıralanacağı", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 4357534..21aac4a 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -84,6 +84,10 @@ "medium": "中", "large": "大" }, + "imagePreviews": { + "label": "图片预览", + "description": "加载并显示聊天记录中的图片附件" + }, "projectSorting": { "label": "项目排序", "description": "项目在侧边栏中的排列方式", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index c2cf686..56a7fac 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -84,6 +84,10 @@ "medium": "中", "large": "大" }, + "imagePreviews": { + "label": "圖片預覽", + "description": "載入並顯示聊天記錄中的圖片附件" + }, "projectSorting": { "label": "專案排序", "description": "專案在側邊欄中的排列方式", From 880fdc5a60c7108807cf346c89255bc239881f63 Mon Sep 17 00:00:00 2001 From: YoonwooHa Date: Mon, 10 Aug 2026 13:27:31 +0900 Subject: [PATCH 5/5] perf: speed up large transcript history --- .../list/codex/codex-sessions.provider.ts | 99 +++++++++---- server/modules/providers/provider.routes.ts | 12 ++ .../providers/services/sessions.service.ts | 134 +++++++++++++++++- .../providers/tests/codex-sessions.test.ts | 82 ++++++++++- server/shared/types.ts | 6 + src/components/chat/hooks/useChatMessages.ts | 6 + .../chat/hooks/useChatSessionState.ts | 20 ++- src/components/chat/types/types.ts | 3 + src/components/chat/view/ChatInterface.tsx | 1 + .../subcomponents/MessageComponent.test.tsx | 19 +++ .../view/subcomponents/MessageComponent.tsx | 74 +++++++++- src/i18n/locales/en/chat.json | 3 + src/i18n/locales/ko/chat.json | 3 + src/stores/sessionMessageFetch.test.ts | 5 + src/stores/sessionMessageFetch.ts | 7 +- src/stores/useSessionStore.ts | 24 +++- 16 files changed, 451 insertions(+), 47 deletions(-) diff --git a/server/modules/providers/list/codex/codex-sessions.provider.ts b/server/modules/providers/list/codex/codex-sessions.provider.ts index 288acb8..15a9ae0 100644 --- a/server/modules/providers/list/codex/codex-sessions.provider.ts +++ b/server/modules/providers/list/codex/codex-sessions.provider.ts @@ -103,7 +103,6 @@ function extractCodexTextContent(content: unknown): string { type CodexHistoryAccumulator = { messages: AnyRecord[]; tokenUsage: AnyRecord | null; - retainedBytes: number; malformed: boolean; }; @@ -113,11 +112,11 @@ type CodexHistoryCacheEntry = { inode: number | bigint; offset: number; tail: string; - modifiedAtMs: number; + boundary: Buffer; messages: NormalizedMessage[]; tokenUsage: AnyRecord | null; malformed: boolean; - retainedBytes: number; + normalizedBytes: number; toolResults: Map; toolUses: Map; sortTimestamps: WeakMap; @@ -129,7 +128,12 @@ type CodexHistoryNormalizer = ( ) => NormalizedMessage[]; const CODEX_HISTORY_CACHE_MAX_ENTRIES = 4; -const CODEX_HISTORY_CACHE_MAX_RETAINED_BYTES = 8 * 1024 * 1024; +// Bound cached *normalized* history rather than the raw rollout size. Codex +// rollouts often contain large context records that never become UI messages; +// evicting based on raw bytes made those files get reparsed from byte zero on +// every 20-message page request. +const CODEX_HISTORY_CACHE_MAX_NORMALIZED_BYTES = 96 * 1024 * 1024; +const CODEX_HISTORY_BOUNDARY_BYTES = 4 * 1024; const codexHistoryCache = new Map(); const codexHistoryRefreshes = new Map>(); @@ -297,11 +301,43 @@ function parseCodexHistoryLine(line: string, accumulator: CodexHistoryAccumulato function touchCodexHistoryCache(sessionId: string, entry: CodexHistoryCacheEntry): void { codexHistoryCache.delete(sessionId); codexHistoryCache.set(sessionId, entry); - while (codexHistoryCache.size > CODEX_HISTORY_CACHE_MAX_ENTRIES) { + const cachedBytes = () => Array.from(codexHistoryCache.values()) + .reduce((total, candidate) => total + candidate.normalizedBytes, 0); + while ( + codexHistoryCache.size > CODEX_HISTORY_CACHE_MAX_ENTRIES + || (codexHistoryCache.size > 1 && cachedBytes() > CODEX_HISTORY_CACHE_MAX_NORMALIZED_BYTES) + ) { codexHistoryCache.delete(codexHistoryCache.keys().next().value!); } } +function estimateCodexMessageBytes(message: NormalizedMessage): number { + let value: string; + try { + value = JSON.stringify({ + content: message.content, + images: message.images, + toolInput: message.toolInput, + }) || ''; + } catch { + value = String(message.content || ''); + } + return Buffer.byteLength(value) + 256; +} + +async function readCodexHistoryBoundary(filePath: string, offset: number): Promise { + if (offset <= 0) return Buffer.alloc(0); + const length = Math.min(offset, CODEX_HISTORY_BOUNDARY_BYTES); + const buffer = Buffer.allocUnsafe(length); + const handle = await fsSync.promises.open(filePath, 'r'); + try { + const { bytesRead } = await handle.read(buffer, 0, length, offset - length); + return buffer.subarray(0, bytesRead); + } finally { + await handle.close(); + } +} + function codexMessageTimestamp( message: NormalizedMessage, sortTimestamps?: WeakMap, @@ -327,25 +363,38 @@ function appendNormalizedCodexHistory( const rawTimestamp = new Date(raw.timestamp || 0).getTime(); const sortTimestamp = Number.isFinite(rawTimestamp) ? rawTimestamp : 0; for (const message of normalize(raw, sessionId)) { + entry.normalizedBytes += estimateCodexMessageBytes(message); entry.sortTimestamps.set(message, sortTimestamp); - if (sortTimestamp < lastTimestamp) needsSort = true; - lastTimestamp = Math.max(lastTimestamp, sortTimestamp); if (message.kind === 'tool_result' && message.toolId) { entry.toolResults.set(message.toolId, message); - for (const toolUse of entry.toolUses.get(message.toolId) ?? []) { + const matchingToolUses = entry.toolUses.get(message.toolId) ?? []; + for (const toolUse of matchingToolUses) { toolUse.toolResult = { content: message.content, isError: message.isError }; } + if (matchingToolUses.length > 0) { + entry.toolUses.delete(message.toolId); + entry.toolResults.delete(message.toolId); + } } else if (message.kind === 'tool_use' && message.toolId) { const toolResult = entry.toolResults.get(message.toolId); if (toolResult) { message.toolResult = { content: toolResult.content, isError: toolResult.isError }; + entry.toolResults.delete(message.toolId); + } else { + const toolUses = entry.toolUses.get(message.toolId) ?? []; + toolUses.push(message); + entry.toolUses.set(message.toolId, toolUses); } - const toolUses = entry.toolUses.get(message.toolId) ?? []; - toolUses.push(message); - entry.toolUses.set(message.toolId, toolUses); } + // Tool results are represented inside their tool-use card. Returning the + // standalone result as well doubled large outputs and also made limit / + // offset count a different list than the frontend received. + if (message.kind === 'tool_result') continue; + + if (sortTimestamp < lastTimestamp) needsSort = true; + lastTimestamp = Math.max(lastTimestamp, sortTimestamp); entry.messages.push(message); } } @@ -369,10 +418,12 @@ async function refreshCodexHistoryCache( && entry.filePath === sessionFilePath && entry.device === metadata.dev && entry.inode === metadata.ino; - const appendOnly = entry != null + const boundaryMatches = entry != null && sameFile && metadata.size >= entry.offset - && !(metadata.size === entry.offset && metadata.mtimeMs !== entry.modifiedAtMs); + && (entry.offset === 0 + || (await readCodexHistoryBoundary(sessionFilePath, entry.offset)).equals(entry.boundary)); + const appendOnly = entry != null && boundaryMatches; if (!entry || !appendOnly) { entry = { @@ -381,10 +432,10 @@ async function refreshCodexHistoryCache( inode: metadata.ino, offset: 0, tail: '', - modifiedAtMs: metadata.mtimeMs, + boundary: Buffer.alloc(0), messages: [], tokenUsage: null, - retainedBytes: 0, + normalizedBytes: 0, malformed: false, toolResults: new Map(), toolUses: new Map(), @@ -396,7 +447,6 @@ async function refreshCodexHistoryCache( const appended: CodexHistoryAccumulator = { messages: [], tokenUsage: entry.tokenUsage, - retainedBytes: 0, malformed: entry.malformed, }; let tail = entry.tail; @@ -409,7 +459,6 @@ async function refreshCodexHistoryCache( const lines = `${tail}${chunk}`.split(/\r?\n/); tail = lines.pop() ?? ''; for (const line of lines) { - appended.retainedBytes += Buffer.byteLength(line); parseCodexHistoryLine(line, appended); if (appended.messages.length > 0) { appendNormalizedCodexHistory(entry, appended.messages, sessionId, normalize); @@ -421,16 +470,11 @@ async function refreshCodexHistoryCache( entry.tokenUsage = appended.tokenUsage; entry.tail = tail; entry.offset = metadata.size; - entry.retainedBytes += appended.retainedBytes; + entry.boundary = await readCodexHistoryBoundary(sessionFilePath, entry.offset); entry.malformed = appended.malformed; - entry.modifiedAtMs = metadata.mtimeMs; } - if (entry.retainedBytes + Buffer.byteLength(entry.tail) <= CODEX_HISTORY_CACHE_MAX_RETAINED_BYTES) { - touchCodexHistoryCache(sessionId, entry); - } else { - codexHistoryCache.delete(sessionId); - } + touchCodexHistoryCache(sessionId, entry); return entry; } @@ -801,12 +845,7 @@ export class CodexSessionsProvider implements IProviderSessions { const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage; const sourceStatus = Array.isArray(result) ? 'available' : result.sourceStatus; - let total = 0; - for (const msg of normalized) { - if (msg.kind !== 'tool_result') { - total += 1; - } - } + const total = normalized.length; const normalizedOffset = Math.max(0, offset); const normalizedLimit = limit === null ? null : Math.max(0, limit); const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset); diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 4f0ff51..c02166d 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -1452,6 +1452,7 @@ router.get( const sessionId = parseSessionId(req.params.sessionId); const limitRaw = readOptionalQueryString(req.query.limit); const offsetRaw = readOptionalQueryString(req.query.offset); + const includeImages = parseOptionalBooleanQuery(req.query.includeImages, 'includeImages'); let limit: number | null = null; if (limitRaw !== undefined) { @@ -1480,11 +1481,22 @@ router.get( const result = await sessionsService.fetchHistory(sessionId, { limit, offset, + includeImages, }); res.json(createApiSuccessResponse(result)); }), ); +router.get( + '/sessions/:sessionId/tool-result', + asyncHandler(async (req: Request, res: Response) => { + const sessionId = parseSessionId(req.params.sessionId); + const toolId = readAskToolId(req.query.toolId); + const result = await sessionsService.fetchToolResult(sessionId, toolId); + res.json(createApiSuccessResponse(result)); + }), +); + router.get('/search/sessions', asyncHandler(async (req: Request, res: Response) => { const query = parseSessionSearchQuery(req.query.q); const limit = parseSessionSearchLimit(req.query.limit); diff --git a/server/modules/providers/services/sessions.service.ts b/server/modules/providers/services/sessions.service.ts index 2698150..53f137e 100644 --- a/server/modules/providers/services/sessions.service.ts +++ b/server/modules/providers/services/sessions.service.ts @@ -18,6 +18,84 @@ type CreateAppSessionResult = { provider: LLMProvider; projectPath: string; }; + +const HISTORY_TOOL_OUTPUT_PREVIEW_BYTES = 64 * 1024; + +function stringifyToolOutput(content: unknown): string { + if (typeof content === 'string') return content; + try { + return JSON.stringify(content, null, 2) ?? String(content ?? ''); + } catch { + return String(content ?? ''); + } +} + +function buildToolOutputPreview(content: unknown): { + content: unknown; + truncated: boolean; + bytes: number; +} { + const serialized = stringifyToolOutput(content); + const bytes = Buffer.byteLength(serialized); + if (bytes <= HISTORY_TOOL_OUTPUT_PREVIEW_BYTES) { + return { content, truncated: false, bytes }; + } + + const source = Buffer.from(serialized); + const headBytes = Math.floor(HISTORY_TOOL_OUTPUT_PREVIEW_BYTES * 0.75); + const tailBytes = HISTORY_TOOL_OUTPUT_PREVIEW_BYTES - headBytes; + const head = source.subarray(0, headBytes).toString('utf8'); + const tail = source.subarray(source.length - tailBytes).toString('utf8'); + return { + content: `${head}\n\n… [${bytes - HISTORY_TOOL_OUTPUT_PREVIEW_BYTES} bytes omitted] …\n\n${tail}`, + truncated: true, + bytes, + }; +} + +/** + * Keeps history pages cheap to transfer without discarding persisted data. + * The full tool result remains available through fetchToolResult(). + */ +export function prepareHistoryMessagesForTransport( + messages: NormalizedMessage[], + includeImages = true, +): NormalizedMessage[] { + return messages.map((message) => { + let prepared = includeImages || message.images === undefined + ? message + : { ...message, images: undefined }; + + if (message.kind === 'tool_result') { + const preview = buildToolOutputPreview(message.content); + if (preview.truncated) { + prepared = { + ...prepared, + content: preview.content as string, + toolResultTruncated: true, + toolResultBytes: preview.bytes, + }; + } + } + + if (message.toolResult && 'content' in message.toolResult) { + const preview = buildToolOutputPreview(message.toolResult.content); + if (preview.truncated) { + prepared = { + ...prepared, + toolResult: { + ...message.toolResult, + content: preview.content as string, + }, + toolResultTruncated: true, + toolResultBytes: preview.bytes, + }; + } + } + + return prepared; + }); +} function normalizeJsonlPath(filePath: string): string { return path.isAbsolute(filePath) ? path.normalize(filePath) : path.resolve(filePath); } @@ -118,7 +196,7 @@ export const sessionsService = { */ async fetchHistory( sessionId: string, - options: Pick = {}, + options: Pick = {}, ): Promise { const session = sessionsDb.getSessionById(sessionId); if (!session) { @@ -150,13 +228,59 @@ export const sessionsService = { return { ...result, - messages: result.messages.map((message) => ({ - ...message, - sessionId, - })), + messages: prepareHistoryMessagesForTransport( + result.messages.map((message) => ({ + ...message, + sessionId, + })), + options.includeImages !== false, + ), }; }, + /** Loads one complete persisted tool result only when the user requests it. */ + async fetchToolResult( + sessionId: string, + toolId: string, + ): Promise<{ toolId: string; toolResult: NonNullable }> { + const session = sessionsDb.getSessionById(sessionId); + if (!session?.provider_session_id) { + throw new AppError(`Session "${sessionId}" was not found.`, { + code: 'SESSION_NOT_FOUND', + statusCode: 404, + }); + } + + const provider = providerRegistry.resolveProvider(session.provider as LLMProvider); + const history = await provider.sessions.fetchHistory(sessionId, { + limit: null, + offset: 0, + projectPath: session.project_path ?? '', + providerSessionId: session.provider_session_id, + }); + const toolUse = history.messages.find( + (message) => message.kind === 'tool_use' && message.toolId === toolId && message.toolResult, + ); + const standaloneResult = history.messages.find( + (message) => message.kind === 'tool_result' && message.toolId === toolId, + ); + const toolResult = toolUse?.toolResult ?? (standaloneResult + ? { + content: standaloneResult.content, + isError: standaloneResult.isError, + toolUseResult: standaloneResult.toolUseResult, + } + : null); + + if (!toolResult) { + throw new AppError(`Tool result "${toolId}" was not found.`, { + code: 'TOOL_RESULT_NOT_FOUND', + statusCode: 404, + }); + } + return { toolId, toolResult }; + }, + /** * Permanently deletes one persisted session row and its transcript file. */ diff --git a/server/modules/providers/tests/codex-sessions.test.ts b/server/modules/providers/tests/codex-sessions.test.ts index e3d1eb1..8ca09c2 100644 --- a/server/modules/providers/tests/codex-sessions.test.ts +++ b/server/modules/providers/tests/codex-sessions.test.ts @@ -11,6 +11,7 @@ import { CodexSessionsProvider, normalizeCodexToolName, } from '@/modules/providers/list/codex/codex-sessions.provider.js'; +import { sessionsService } from '@/modules/providers/services/sessions.service.js'; test('Codex request_user_input uses the shared question renderer', () => { assert.equal(normalizeCodexToolName('request_user_input'), 'AskUserQuestion'); @@ -315,13 +316,19 @@ test('Codex history incrementally appends complete JSONL records', { concurrency content: '/workspace', isError: false, }); + assert.equal( + toolFinished.messages.some((message) => message.kind === 'tool_result'), + false, + 'tool results are carried by their tool-use card, not duplicated as standalone rows', + ); + assert.equal(toolFinished.total, toolFinished.messages.length); }); } finally { await rm(tempRoot, { recursive: true, force: true }); } }); -test('Codex history drops rollouts that exceed the retained cache bound', { concurrency: false }, async () => { +test('Codex history detects same-size rewrites beyond the former raw cache bound', { concurrency: false }, async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-history-cache-bound-')); const workspacePath = path.join(tempRoot, 'workspace'); await mkdir(workspacePath, { recursive: true }); @@ -368,6 +375,79 @@ test('Codex history drops rollouts that exceed the retained cache bound', { conc } }); +test('Codex history sends bounded tool previews and loads the full result on demand', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-history-tool-preview-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + + try { + const sessionId = 'codex-tool-preview-history'; + const transcriptPath = await writeCodexTranscript(tempRoot, sessionId, workspacePath); + const output = `start-${'x'.repeat(96 * 1024)}-end`; + await appendFile(transcriptPath, [ + JSON.stringify({ + type: 'event_msg', + timestamp: '2026-08-10T00:00:00.000Z', + payload: { + type: 'user_message', + message: 'Run the diagnostic', + images: ['data:image/png;base64,QUJD'], + }, + }), + JSON.stringify({ + type: 'response_item', + timestamp: '2026-08-10T00:00:01.000Z', + payload: { + type: 'function_call', + name: 'exec_command', + arguments: JSON.stringify({ cmd: 'diagnostic' }), + call_id: 'large-result', + }, + }), + JSON.stringify({ + type: 'response_item', + timestamp: '2026-08-10T00:00:02.000Z', + payload: { + type: 'function_call_output', + call_id: 'large-result', + output, + }, + }), + '', + ].join('\n'), 'utf8'); + + await withIsolatedDatabase(async () => { + sessionsDb.createSession( + sessionId, + 'codex', + workspacePath, + undefined, + undefined, + undefined, + transcriptPath, + ); + + const history = await sessionsService.fetchHistory(sessionId, { + limit: 20, + offset: 0, + includeImages: false, + }); + const userMessage = history.messages.find((message) => message.role === 'user'); + const toolUse = history.messages.find((message) => message.kind === 'tool_use'); + assert.equal(userMessage?.images, undefined); + assert.equal(toolUse?.toolResultTruncated, true); + assert.equal(toolUse?.toolResultBytes, Buffer.byteLength(output)); + assert.ok(String(toolUse?.toolResult?.content).length < output.length); + assert.equal(history.messages.some((message) => message.kind === 'tool_result'), false); + + const full = await sessionsService.fetchToolResult(sessionId, 'large-result'); + assert.equal(full.toolResult.content, output); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); + test('Codex history reports a missing transcript source', { concurrency: false }, async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-history-missing-')); const workspacePath = path.join(tempRoot, 'workspace'); diff --git a/server/shared/types.ts b/server/shared/types.ts index 2455708..8f95a3f 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -260,6 +260,10 @@ export type NormalizedMessage = { isError?: boolean; toolUseResult?: unknown; }; + /** History transport sent only a bounded preview of this tool output. */ + toolResultTruncated?: boolean; + /** UTF-8 byte size of the complete persisted tool output. */ + toolResultBytes?: number; isError?: boolean; text?: string; tokens?: number; @@ -296,6 +300,8 @@ export type FetchHistoryOptions = { limit?: number | null; offset?: number; providerSessionId?: string; + /** False omits image attachment metadata/data from the transport response. */ + includeImages?: boolean; }; /** diff --git a/src/components/chat/hooks/useChatMessages.ts b/src/components/chat/hooks/useChatMessages.ts index 2f8db25..0bd327d 100644 --- a/src/components/chat/hooks/useChatMessages.ts +++ b/src/components/chat/hooks/useChatMessages.ts @@ -121,6 +121,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes for (const [index, msg] of messages.entries()) { const sharedMetadata = { + sessionId: msg.sessionId, displayText: msg.displayText, commandName: msg.commandName, commandMessage: msg.commandMessage, @@ -215,6 +216,11 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes toolInput: typeof msg.toolInput === 'string' ? msg.toolInput : JSON.stringify(msg.toolInput ?? '', null, 2), toolId: msg.toolId, toolResult, + toolResultTruncated: Boolean( + msg.toolResultTruncated || (tr as NormalizedMessage | null)?.toolResultTruncated, + ), + toolResultBytes: + msg.toolResultBytes ?? (tr as NormalizedMessage | null)?.toolResultBytes, isSubagentContainer, subagentState: isSubagentContainer ? { diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 83a115f..a66e276 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -30,6 +30,7 @@ interface UseChatSessionStateArgs { /** Highest live seq observed per session; sent as `lastSeq` on subscribe. */ lastSeqRef: MutableRefObject>; sessionStore: SessionStore; + showImagePreviews?: boolean; } interface ScrollRestoreState { @@ -116,6 +117,7 @@ export function useChatSessionState({ statusCheckSentAtRef, lastSeqRef, sessionStore, + showImagePreviews = true, }: UseChatSessionStateArgs) { const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null); const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); @@ -386,6 +388,7 @@ export function useChatSessionState({ try { const slot = await sessionStore.fetchMore(selectedSession.id, { limit: MESSAGES_PER_PAGE, + includeImages: showImagePreviews, }); if (!slot) return false; const didLoadOlderMessages = slot.offset > previousOffset; @@ -436,6 +439,7 @@ export function useChatSessionState({ selectedProject, selectedSession, sessionStore, + showImagePreviews, ], ); @@ -627,6 +631,7 @@ export function useChatSessionState({ sessionStore.fetchFromServer(selectedSessionId, { limit: MESSAGES_PER_PAGE, offset: 0, + includeImages: showImagePreviews, }).then(slot => { if (slot) { setHasMoreMessages(slot.hasMore); @@ -648,6 +653,7 @@ export function useChatSessionState({ lastSeqRef, ws, sessionStore, + showImagePreviews, ]); // External message update (e.g. WebSocket reconnect, background refresh) @@ -658,7 +664,9 @@ export function useChatSessionState({ try { // Skip store refresh during active streaming if (!isProcessing && !isLoadingMoreRef.current) { - await sessionStore.refreshFromServer(selectedSession.id); + await sessionStore.refreshFromServer(selectedSession.id, { + includeImages: showImagePreviews, + }); if (isNearBottom()) { setTimeout(() => scrollToBottom(), 200); @@ -678,6 +686,7 @@ export function useChatSessionState({ selectedSession, sessionStore, isProcessing, + showImagePreviews, ]); // Search navigation target @@ -708,6 +717,7 @@ export function useChatSessionState({ const slot = await sessionStore.fetchFromServer(selectedSession.id, { limit: null, offset: 0, + includeImages: showImagePreviews, }); if (slot) { setHasMoreMessages(false); @@ -891,7 +901,9 @@ export function useChatSessionState({ // A live external transcript is polled frequently. Reconcile the entire // window the user has already opened instead of replacing it with the // initial tail page and hiding older messages again. - const slot = await sessionStore.refreshFromServer(requestSessionId); + const slot = await sessionStore.refreshFromServer(requestSessionId, { + includeImages: showImagePreviews, + }); if (!slot || currentSessionId !== requestSessionId) { return; } @@ -901,7 +913,7 @@ export function useChatSessionState({ } finally { transcriptRefreshInFlightRef.current = false; } - }, [selectedSession?.id, currentSessionId, sessionStore]); + }, [selectedSession?.id, currentSessionId, sessionStore, showImagePreviews]); const loadAllMessages = useCallback(async () => { if (!selectedSession || !selectedProject) return; @@ -930,6 +942,7 @@ export function useChatSessionState({ const slot = await sessionStore.fetchFromServer(requestSessionId, { limit: null, offset: 0, + includeImages: showImagePreviews, }); if (currentSessionId !== requestSessionId) return; @@ -977,6 +990,7 @@ export function useChatSessionState({ currentSessionId, schedulePendingScrollRestore, sessionStore, + showImagePreviews, ]); const loadEarlierMessages = useCallback(() => { diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index 1d76559..a10ab3a 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -36,6 +36,7 @@ export interface SubagentChildTool { } export interface ChatMessage { + sessionId?: string; type: string; content?: string; displayText?: string; @@ -49,6 +50,8 @@ export interface ChatMessage { toolName?: string; toolInput?: unknown; toolResult?: ToolResult | null; + toolResultTruncated?: boolean; + toolResultBytes?: number; toolId?: string; toolCallId?: string; commandName?: string; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 1a2792d..0a34f8e 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -142,6 +142,7 @@ function ChatInterface({ statusCheckSentAtRef, lastSeqRef, sessionStore, + showImagePreviews, }); useEffect(() => { diff --git a/src/components/chat/view/subcomponents/MessageComponent.test.tsx b/src/components/chat/view/subcomponents/MessageComponent.test.tsx index cbb6040..ef6d3c0 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.test.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.test.tsx @@ -93,3 +93,22 @@ test('image preview preference omits attachment markup when disabled', () => { /sample\.png|data:image\/png/, ); }); + +test('truncated tool history offers an explicit full-output load action', () => { + const html = renderMessage({ + sessionId: 'session-1', + type: 'assistant', + content: '', + timestamp: '2026-08-10T00:00:00.000Z', + isToolUse: true, + toolName: 'Bash', + toolInput: JSON.stringify({ command: 'diagnostic' }), + toolId: 'tool-large', + toolResult: { content: 'bounded preview', isError: false }, + toolResultTruncated: true, + toolResultBytes: 2 * 1024 * 1024, + }); + + assert.match(html, /Load full output/); + assert.match(html, /2048 KB/); +}); diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index c65fa46..5e7c091 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useRef } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; @@ -7,11 +7,13 @@ import type { ClaudePermissionSuggestion, PermissionGrantResult, Provider, + ToolResult, } from '../../types/types'; import { formatUsageLimitText } from '../../utils/chatFormatting'; import type { Project } from '../../../../types/app'; import { ToolRenderer, shouldHideToolResult } from '../../tools'; import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui'; +import { authenticatedFetch } from '../../../../utils/api'; import ChatMessageImages from './ChatMessageImages'; import { Markdown } from './Markdown'; @@ -66,9 +68,18 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s () => formatUsageLimitText(String(message.content || '')), [message.content] ); + const [fullToolResult, setFullToolResult] = useState(null); + const [isLoadingFullToolResult, setIsLoadingFullToolResult] = useState(false); + const [fullToolResultError, setFullToolResultError] = useState(false); + useEffect(() => { + setFullToolResult(null); + setIsLoadingFullToolResult(false); + setFullToolResultError(false); + }, [message.sessionId, message.toolId]); + const effectiveToolResult = fullToolResult ?? message.toolResult; const errorContent = String(message.content || ''); const errorSummary = compactErrorSummary(errorContent, t('messageTypes.error')); - const toolResultContent = String(message.toolResult?.content || ''); + const toolResultContent = String(effectiveToolResult?.content || ''); const toolErrorSummary = compactErrorSummary(toolResultContent, t('messageTypes.error')); const assistantCopyContent = message.isToolUse ? String(message.displayText || message.content || '') @@ -86,6 +97,33 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]); const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking); + const loadFullToolResult = async () => { + if (!message.sessionId || !message.toolId || isLoadingFullToolResult) return; + setIsLoadingFullToolResult(true); + setFullToolResultError(false); + try { + const params = new URLSearchParams({ toolId: message.toolId }); + const response = await authenticatedFetch( + `/api/providers/sessions/${encodeURIComponent(message.sessionId)}/tool-result?${params}`, + ); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = await response.json(); + const data = body?.data ?? body; + const result = data.toolResult as ToolResult | null | undefined; + if (!result) { + throw new Error('Missing tool result'); + } + const content = typeof result.content === 'string' + ? result.content + : JSON.stringify(result.content ?? '', null, 2); + setFullToolResult({ ...result, content }); + } catch { + setFullToolResultError(true); + } finally { + setIsLoadingFullToolResult(false); + } + }; + if (shouldHideThinkingMessage) { return null; } @@ -187,7 +225,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s ) )} + {message.toolResultTruncated && !fullToolResult && ( +
+ + {message.toolResultBytes && ( + + {(message.toolResultBytes / 1024).toFixed(0)} KB + + )} + {fullToolResultError && ( + + {t('session.messages.fullToolOutputFailed')} + + )} +
+ )} ) : message.isInteractivePrompt ? ( // Special handling for interactive prompts diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index f90ab1e..969f3d0 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -173,6 +173,9 @@ "showingLast": "Showing last {{count}} messages ({{total}} total)", "loadEarlier": "Load earlier messages", "loadAll": "Load all messages", + "loadFullToolOutput": "Load full output", + "loadingFullToolOutput": "Loading full output...", + "fullToolOutputFailed": "Could not load full output.", "loadingAll": "Loading all messages...", "allLoaded": "All messages loaded", "perfWarning": "All messages loaded — scrolling may be slower. Click \"Scroll to bottom\" to restore performance." diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index dba1b77..76ba112 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -161,6 +161,9 @@ "showingLast": "마지막 {{count}}개 메시지 표시 (총 {{total}}개)", "loadEarlier": "이전 메시지 로드", "loadAll": "모든 메시지 로드", + "loadFullToolOutput": "전체 출력 불러오기", + "loadingFullToolOutput": "전체 출력 불러오는 중...", + "fullToolOutputFailed": "전체 출력을 불러오지 못했습니다.", "loadingAll": "모든 메시지 로딩 중...", "allLoaded": "모든 메시지 로드 완료", "perfWarning": "모든 메시지가 로드됨 - 스크롤이 느려질 수 있습니다. \"맨 아래로 스크롤\"을 클릭하면 성능이 복구됩니다." diff --git a/src/stores/sessionMessageFetch.test.ts b/src/stores/sessionMessageFetch.test.ts index 9855293..1fbb099 100644 --- a/src/stores/sessionMessageFetch.test.ts +++ b/src/stores/sessionMessageFetch.test.ts @@ -28,3 +28,8 @@ test('buildRefreshMessagesUrl encodes the session id', () => { const url = buildRefreshMessagesUrl('a/b c', 0); assert.ok(url.includes('a%2Fb%20c'), 'session id must be URL-encoded'); }); + +test('buildRefreshMessagesUrl omits image data when previews are disabled', () => { + const params = new URL(buildRefreshMessagesUrl('s', 20, false), 'http://x').searchParams; + assert.equal(params.get('includeImages'), 'false'); +}); diff --git a/src/stores/sessionMessageFetch.ts b/src/stores/sessionMessageFetch.ts index e3d1092..f1b83b4 100644 --- a/src/stores/sessionMessageFetch.ts +++ b/src/stores/sessionMessageFetch.ts @@ -23,9 +23,14 @@ export const REFRESH_RECONCILE_MIN_MESSAGES = 20; * this so it never shrinks the visible window nor pulls the whole * transcript. */ -export function buildRefreshMessagesUrl(sessionId: string, loadedCount: number): string { +export function buildRefreshMessagesUrl( + sessionId: string, + loadedCount: number, + includeImages = true, +): string { const safeLoaded = Number.isFinite(loadedCount) ? Math.max(0, Math.floor(loadedCount)) : 0; const reconcileLimit = Math.max(safeLoaded, REFRESH_RECONCILE_MIN_MESSAGES); const params = new URLSearchParams({ limit: String(reconcileLimit), offset: '0' }); + if (!includeImages) params.set('includeImages', 'false'); return `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages?${params.toString()}`; } diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index 0509c03..3df51d9 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -67,6 +67,8 @@ export interface NormalizedMessage { toolInput?: unknown; toolId?: string; toolResult?: { content: string; isError: boolean; toolUseResult?: unknown } | null; + toolResultTruncated?: boolean; + toolResultBytes?: number; isError?: boolean; text?: string; tokens?: number; @@ -114,6 +116,8 @@ export interface SessionSlot { _reconcilePending: boolean; /** @internal Request currently allowed to settle `loading`. */ _loadingTicket: number | null; + /** Whether subsequent pages/reconciles should request image attachment data. */ + _includeImages: boolean; status: SessionStatus; fetchedAt: number; total: number; @@ -142,6 +146,7 @@ function createEmptySlot(): SessionSlot { _pendingRequests: 0, _reconcilePending: false, _loadingTicket: null, + _includeImages: true, }; } @@ -617,9 +622,13 @@ export function useSessionStore() { opts: { limit?: number | null; offset?: number; + includeImages?: boolean; } = {}, ) => { const slot = beginRequest(sessionId); + if (typeof opts.includeImages === 'boolean') { + slot._includeImages = opts.includeImages; + } const fetchTicket = ++slot._fetchSeq; if (slot.status !== 'streaming') { slot._loadingTicket = fetchTicket; @@ -633,6 +642,7 @@ export function useSessionStore() { params.append('limit', String(opts.limit)); params.append('offset', String(opts.offset ?? 0)); } + if (!slot._includeImages) params.set('includeImages', 'false'); const qs = params.toString(); const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; @@ -695,10 +705,14 @@ export function useSessionStore() { sessionId: string, opts: { limit?: number; + includeImages?: boolean; } = {}, ) => { const store = storeRef.current; const slot = store.get(sessionId) ?? createEmptySlot(); + if (typeof opts.includeImages === 'boolean') { + slot._includeImages = opts.includeImages; + } if (!slot.hasMore || slot._fetchMoreTicket !== null) { touchSlot(sessionId, slot); return slot; @@ -716,6 +730,7 @@ export function useSessionStore() { const limit = opts.limit ?? 20; params.append('limit', String(limit)); params.append('offset', String(expectedOffset)); + if (!slot._includeImages) params.set('includeImages', 'false'); const qs = params.toString(); const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; @@ -828,12 +843,16 @@ export function useSessionStore() { */ const refreshFromServer = useCallback(async ( sessionId: string, + opts: { includeImages?: boolean } = {}, ) => { // Reconcile polling is lower priority than an explicit initial, paginated, // or load-all request. Let that window mutation finish instead of // invalidating its fetch ticket and making the UI believe an unchanged // slot was successfully expanded. const pendingSlot = storeRef.current.get(sessionId); + if (pendingSlot && typeof opts.includeImages === 'boolean') { + pendingSlot._includeImages = opts.includeImages; + } if (pendingSlot && pendingSlot._pendingRequests > 0) { pendingSlot._reconcilePending = true; touchSlot(sessionId, pendingSlot); @@ -850,6 +869,9 @@ export function useSessionStore() { } const slot = beginRequest(sessionId); + if (typeof opts.includeImages === 'boolean') { + slot._includeImages = opts.includeImages; + } const fetchTicket = ++slot._fetchSeq; if (slot.status === 'loading') { slot._loadingTicket = fetchTicket; @@ -859,7 +881,7 @@ export function useSessionStore() { // transcript is not re-pulled in full on every refresh (latest-N + scroll-up // lazy-load stays intact). total/hasMore below keep older messages reachable. const loadedCount = slot.serverMessages.length + slot.realtimeMessages.length; - const url = buildRefreshMessagesUrl(sessionId, loadedCount); + const url = buildRefreshMessagesUrl(sessionId, loadedCount, slot._includeImages); const response = await authenticatedFetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`);