diff --git a/adapters/common/__tests__/chat-runtime.test.ts b/adapters/common/__tests__/chat-runtime.test.ts index f80e95ec..9c38bf84 100644 --- a/adapters/common/__tests__/chat-runtime.test.ts +++ b/adapters/common/__tests__/chat-runtime.test.ts @@ -266,6 +266,69 @@ describe('ImChatRuntime authorization', () => { }) describe('ImChatRuntime session lifecycle', () => { + it.each(['/status', '/stop', '/clear'])('reports a retryable disconnect for %s without changing the binding', async (command) => { + const { runtime, bridge, httpClient, sessionStore, notices } = createRuntime() + sessionStore.set(CHAT_ID, 'old-session', path.join(tmpDir, 'original-project')) + httpClient.existingSessions.add('old-session') + const original = sessionStore.get(CHAT_ID) + bridge.waitForOpen = async () => false + + await inbound(runtime, command) + + expect(sessionStore.get(CHAT_ID)).toEqual(original) + expect(httpClient.createdSessions).toEqual([]) + expect(bridge.sent).toEqual([]) + expect(bridge.stopped).toEqual([]) + expect(notices).toHaveLength(1) + expect(notices[0]).toContain('重试') + expect(notices[0]).not.toContain('当前没有活动会话') + }) + + it.each(['timeout', 'error'])('preserves the stored session and project after a reconnect %s, then retries it', async (failure) => { + const { runtime, bridge, httpClient, sessionStore, notices } = createRuntime() + const workDir = path.join(tmpDir, 'original-project') + sessionStore.set(CHAT_ID, 'old-session', workDir) + httpClient.existingSessions.add('old-session') + const original = sessionStore.get(CHAT_ID) + let attempts = 0 + bridge.isSessionOpen = () => false + bridge.waitForOpen = async () => { + attempts += 1 + if (attempts === 1) { + if (failure === 'error') throw new Error('connection failed') + return false + } + return true + } + + await inbound(runtime, 'continue') + + expect(sessionStore.get(CHAT_ID)).toEqual(original) + expect(new SessionStore(path.join(tmpDir, 'adapter-sessions.json')).get(CHAT_ID)).toEqual(original) + expect(httpClient.createdSessions).toEqual([]) + expect(bridge.sent).toEqual([]) + expect(notices.join('\n')).toContain('重试') + + await inbound(runtime, 'continue') + + expect(attempts).toBe(2) + expect(bridge.getSessionId(CHAT_ID)).toBe('old-session') + expect(sessionStore.get(CHAT_ID)).toEqual(original) + expect(httpClient.createdSessions).toEqual([]) + expect(bridge.sent.map((item) => item.content)).toEqual(['continue']) + }) + + it('creates a replacement only when the stored session is confirmed missing', async () => { + const { runtime, bridge, httpClient, sessionStore } = createRuntime() + sessionStore.set(CHAT_ID, 'deleted-session', path.join(tmpDir, 'original-project')) + + await inbound(runtime, 'continue') + + expect(httpClient.createdSessions).toEqual([tmpDir]) + expect(sessionStore.get(CHAT_ID)?.sessionId).not.toBe('deleted-session') + expect(bridge.sent.map((item) => item.content)).toEqual(['continue']) + }) + it('creates a session on the first message and reuses it for the second', async () => { const { runtime, httpClient, bridge } = createRuntime() diff --git a/adapters/common/__tests__/session-recovery.test.ts b/adapters/common/__tests__/session-recovery.test.ts index d3d0d956..f42d2863 100644 --- a/adapters/common/__tests__/session-recovery.test.ts +++ b/adapters/common/__tests__/session-recovery.test.ts @@ -49,6 +49,59 @@ function makeBridge(options?: { currentSessionId?: string | null; open?: boolean } describe('restoreStoredSessionBinding', () => { + it.each(['timeout', 'connect error', 'wait error'])('preserves the binding and transient state after a %s', async (failure) => { + const entry = { sessionId: 'stored-session', workDir: '/original-project', updatedAt: 1 } + const store = makeStore(entry) + const bridge = makeBridge() + const connect = bridge.connectSession + const wait = bridge.waitForOpen + if (failure === 'connect error') bridge.connectSession = () => { throw new Error('connect failed') } + else bridge.waitForOpen = async () => { + if (failure === 'wait error') throw new Error('wait failed') + return false + } + let cleared = 0 + const options = { + chatId: 'chat-1', + bridge, + sessionStore: store, + httpClient: { sessionExists: async () => true }, + onServerMessage: () => {}, + logPrefix: '[Test]', + clearTransientState: () => { cleared += 1 }, + } + + expect(await restoreStoredSessionBinding(options)).toEqual({ status: 'unavailable', session: entry }) + expect(store.current()).toEqual(entry) + expect(cleared).toBe(0) + expect(bridge.calls).not.toContain('reset') + + bridge.connectSession = connect + bridge.waitForOpen = wait + expect(await restoreStoredSessionBinding(options)).toEqual({ status: 'restored', session: entry }) + expect(store.current()).toEqual(entry) + }) + + it.each([false, true])('keeps the binding when verification fails and reconnect returns %s', async (opened) => { + const entry = { sessionId: 'stored-session', workDir: '/original-project', updatedAt: 1 } + const store = makeStore(entry) + const bridge = makeBridge() + bridge.waitForOpen = async () => opened + + const result = await restoreStoredSessionBinding({ + chatId: 'chat-1', + bridge, + sessionStore: store, + httpClient: { sessionExists: async () => { throw new Error('HTTP timeout') } }, + onServerMessage: () => {}, + logPrefix: '[Test]', + }) + + expect(result).toEqual({ status: opened ? 'restored' : 'unavailable', session: entry }) + expect(store.current()).toEqual(entry) + expect(bridge.calls).toEqual(['connect:stored-session', 'handler']) + }) + it('resets stale bridge memory when server-side delete removed the stored mapping', async () => { const store = makeStore(null) const bridge = makeBridge({ currentSessionId: 'deleted-session', hasSession: true }) @@ -66,7 +119,7 @@ describe('restoreStoredSessionBinding', () => { }, }) - expect(restored).toBeNull() + expect(restored).toEqual({ status: 'missing' }) expect(bridge.calls).toEqual(['reset']) expect(cleared).toBe(1) }) @@ -89,7 +142,7 @@ describe('restoreStoredSessionBinding', () => { }, }) - expect(restored).toBeNull() + expect(restored).toEqual({ status: 'missing' }) expect(store.current()).toBeNull() expect(bridge.calls).toEqual([]) expect(cleared).toBe(1) @@ -113,7 +166,7 @@ describe('restoreStoredSessionBinding', () => { }, }) - expect(restored).toEqual(entry) + expect(restored).toEqual({ status: 'restored', session: entry }) expect(bridge.calls).toEqual(['reset', 'connect:stored-session', 'handler', 'wait']) expect(cleared).toBe(1) }) @@ -138,7 +191,7 @@ describe('restoreStoredSessionBinding', () => { logPrefix: '[Test]', }) - expect(restored).toEqual(entry) + expect(restored).toEqual({ status: 'restored', session: entry }) expect(checked).toBe(0) expect(bridge.calls).toEqual([]) }) @@ -157,7 +210,7 @@ describe('restoreStoredSessionBinding', () => { logPrefix: '[Test]', }) - expect(restored).toEqual(entry) + expect(restored).toEqual({ status: 'restored', session: entry }) expect(bridge.calls).toEqual(['connect:stored-session', 'handler', 'wait']) }) }) diff --git a/adapters/common/chat-runtime.ts b/adapters/common/chat-runtime.ts index 3db366d6..56d22ffd 100644 --- a/adapters/common/chat-runtime.ts +++ b/adapters/common/chat-runtime.ts @@ -34,7 +34,7 @@ import { parsePermissionCommand, type PermissionDecision, } from './permission.js' -import { restoreStoredSessionBinding } from './session-recovery.js' +import { restoreStoredSessionBinding, SESSION_RECONNECT_NOTICE, type SessionRestoreResult } from './session-recovery.js' import { SessionSelectionController } from './session-selection.js' import { syncImPermissionState } from './permission-sync.js' import type { SessionStore } from './session-store.js' @@ -300,9 +300,9 @@ export class ImChatRuntime { return true } if (STOP_ALIASES.has(text)) { - const stored = await this.ensureExistingSession(chatId) - if (!stored) { - await this.port.sendNotice(chatId, formatImStatus(null)) + const result = await this.ensureExistingSession(chatId) + if (result.status !== 'restored') { + await this.port.sendNotice(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) return true } this.bridge.sendStopGeneration(chatId) @@ -310,9 +310,9 @@ export class ImChatRuntime { return true } if (CLEAR_ALIASES.has(text)) { - const stored = await this.ensureExistingSession(chatId) - if (!stored) { - await this.port.sendNotice(chatId, formatImStatus(null)) + const result = await this.ensureExistingSession(chatId) + if (result.status !== 'restored') { + await this.port.sendNotice(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) return true } this.clearTransientChatState(chatId) @@ -502,7 +502,7 @@ export class ImChatRuntime { // ---------- sessions ---------- - async ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { + async ensureExistingSession(chatId: string): Promise { return await restoreStoredSessionBinding({ chatId, bridge: this.bridge, @@ -515,8 +515,12 @@ export class ImChatRuntime { } private async ensureSession(chatId: string): Promise { - const stored = await this.ensureExistingSession(chatId) - if (stored) return true + const result = await this.ensureExistingSession(chatId) + if (result.status === 'restored') return true + if (result.status === 'unavailable') { + await this.port.sendNotice(chatId, SESSION_RECONNECT_NOTICE) + return false + } if (this.defaultWorkDir) { return await this.createSessionForChat(chatId, this.defaultWorkDir) @@ -621,8 +625,10 @@ export class ImChatRuntime { } async buildStatusText(chatId: string): Promise { - const stored = await this.ensureExistingSession(chatId) - if (!stored) return formatImStatus(null) + const result = await this.ensureExistingSession(chatId) + if (result.status === 'unavailable') return SESSION_RECONNECT_NOTICE + if (result.status === 'missing') return formatImStatus(null) + const stored = result.session const runtime = this.getRuntimeState(chatId) let projectName = path.basename(stored.workDir) || stored.workDir diff --git a/adapters/common/session-recovery.ts b/adapters/common/session-recovery.ts index fa44f6d2..48bb1dc3 100644 --- a/adapters/common/session-recovery.ts +++ b/adapters/common/session-recovery.ts @@ -2,6 +2,13 @@ import type { AdapterHttpClient } from './http-client.js' import type { SessionEntry, SessionStore } from './session-store.js' import type { ServerMessage, WsBridge } from './ws-bridge.js' +export type SessionRestoreResult = + | { status: 'restored'; session: SessionEntry } + | { status: 'missing' } + | { status: 'unavailable'; session: SessionEntry } + +export const SESSION_RECONNECT_NOTICE = '暂时无法连接原会话,已保留会话和工作目录,请稍后重试。' + type BridgeSessionOps = Pick< WsBridge, | 'connectSession' @@ -41,11 +48,11 @@ export async function restoreStoredSessionBinding({ onServerMessage, logPrefix, clearTransientState, -}: RestoreStoredSessionBindingOptions): Promise { +}: RestoreStoredSessionBindingOptions): Promise { const stored = sessionStore.get(chatId) if (!stored) { resetStaleBridge(chatId, bridge, clearTransientState) - return null + return { status: 'missing' } } const currentSessionId = bridge.getSessionId(chatId) @@ -54,7 +61,7 @@ export async function restoreStoredSessionBinding({ } if (bridge.isSessionOpen(chatId, stored.sessionId)) { - return stored + return { status: 'restored', session: stored } } let exists = true @@ -73,11 +80,23 @@ export async function restoreStoredSessionBinding({ const hadBridgeSession = bridge.hasSession(chatId) resetStaleBridge(chatId, bridge, clearTransientState) if (!hadBridgeSession) clearTransientState?.() - return null + return { status: 'missing' } } - bridge.connectSession(chatId, stored.sessionId) - bridge.onServerMessage(chatId, onServerMessage) - const opened = await bridge.waitForOpen(chatId) - return opened ? stored : null + // A transport failure is not evidence that the session was deleted. Keep + // its binding so the next inbound message can retry the same session. + try { + bridge.connectSession(chatId, stored.sessionId) + bridge.onServerMessage(chatId, onServerMessage) + if (await bridge.waitForOpen(chatId)) { + return { status: 'restored', session: stored } + } + } catch (err) { + console.warn( + `${logPrefix} Failed to reconnect stored session ${stored.sessionId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ) + } + return { status: 'unavailable', session: stored } } diff --git a/adapters/dingtalk/index.ts b/adapters/dingtalk/index.ts index 181286a3..b7c4b975 100644 --- a/adapters/dingtalk/index.ts +++ b/adapters/dingtalk/index.ts @@ -28,7 +28,7 @@ import { formatProjectSelectionOutcome, ProjectSelectionController, } from '../common/project-selection-router.js' -import { restoreStoredSessionBinding } from '../common/session-recovery.js' +import { restoreStoredSessionBinding, SESSION_RECONNECT_NOTICE, type SessionRestoreResult } from '../common/session-recovery.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { AttachmentStore } from '../common/attachment/attachment-store.js' import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js' @@ -248,7 +248,7 @@ function clearPendingPermissions(chatId: string): void { pendingPermissions.delete(chatId) } -async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { +async function ensureExistingSession(chatId: string): Promise { return await restoreStoredSessionBinding({ chatId, bridge, @@ -261,8 +261,10 @@ async function ensureExistingSession(chatId: string): Promise<{ sessionId: strin } async function buildStatusText(chatId: string): Promise { - const stored = await ensureExistingSession(chatId) - if (!stored) return formatImStatus(null) + const result = await ensureExistingSession(chatId) + if (result.status === 'unavailable') return SESSION_RECONNECT_NOTICE + if (result.status === 'missing') return formatImStatus(null) + const stored = result.session const runtime = getRuntimeState(chatId) let projectName = path.basename(stored.workDir) || stored.workDir @@ -312,8 +314,12 @@ async function buildStatusText(chatId: string): Promise { } async function ensureSession(chatId: string): Promise { - const stored = await ensureExistingSession(chatId) - if (stored) return true + const result = await ensureExistingSession(chatId) + if (result.status === 'restored') return true + if (result.status === 'unavailable') { + await sendText(chatId, SESSION_RECONNECT_NOTICE) + return false + } return await createSessionForChat(chatId, defaultWorkDir) } @@ -510,9 +516,9 @@ async function routeUserMessage(chatId: string, text: string, attachments: Attac return } if (!hasAttachments && (trimmed === '/clear' || trimmed === '清空')) { - const stored = await ensureExistingSession(chatId) - if (!stored) { - await sendText(chatId, formatImStatus(null)) + const result = await ensureExistingSession(chatId) + if (result.status !== 'restored') { + await sendText(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) return } clearTransientChatState(chatId) @@ -525,9 +531,9 @@ async function routeUserMessage(chatId: string, text: string, attachments: Attac return } if (!hasAttachments && (trimmed === '/stop' || trimmed === '停止')) { - const stored = await ensureExistingSession(chatId) - if (!stored) { - await sendText(chatId, formatImStatus(null)) + const result = await ensureExistingSession(chatId) + if (result.status !== 'restored') { + await sendText(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) return } bridge.sendStopGeneration(chatId) diff --git a/adapters/feishu/__tests__/legacy-session-selection-entrypoint.test.ts b/adapters/feishu/__tests__/legacy-session-selection-entrypoint.test.ts index da7e2bce..fabff061 100644 --- a/adapters/feishu/__tests__/legacy-session-selection-entrypoint.test.ts +++ b/adapters/feishu/__tests__/legacy-session-selection-entrypoint.test.ts @@ -187,6 +187,100 @@ async function send(platform: Platform, text: string, options: { unauthorized?: for (const platform of platforms) { describe(`${platform} actual module session selection`, () => { + it.each(['/status', '/stop', '/clear'])('preserves the binding when %s cannot reconnect and retries the command', async (command) => { + const adapter = adapterFor(platform) + const chatId = chatFor(platform) + const originalProject = path.join(temporaryRoot, `${platform}-command-project`) + fs.mkdirSync(originalProject, { recursive: true }) + adapter.bridge.resetSession(chatId) + adapter.clearTransientChatState(chatId) + adapter.sessionStore.set(chatId, 'old-cmd', originalProject) + const originalBinding = adapter.sessionStore.get(chatId) + const creationsBefore = newSessionCount + const noticesBefore = notices.length + const failedOpen = spyOn(adapter.bridge, 'waitForOpen').mockImplementationOnce(async () => { + adapter.bridge.resetSession(chatId) + return false + }) + const sendSpy = spyOn(adapter.bridge, 'sendUserMessage') + const stopSpy = spyOn(adapter.bridge, 'sendStopGeneration') + try { + await send(platform, command) + + expect(adapter.sessionStore.get(chatId)).toEqual(originalBinding) + expect(newSessionCount).toBe(creationsBefore) + expect(sendSpy).not.toHaveBeenCalled() + expect(stopSpy).not.toHaveBeenCalled() + expect(notices).toHaveLength(noticesBefore + 1) + expect(notices.at(-1)).toContain('已保留会话和工作目录') + expect(notices.at(-1)).toContain('重试') + expect(notices.at(-1)).not.toContain('当前没有活动会话') + + await send(platform, command) + + expect(adapter.bridge.getSessionId(chatId)).toBe('old-cmd') + expect(adapter.sessionStore.get(chatId)).toEqual(originalBinding) + expect(newSessionCount).toBe(creationsBefore) + if (command === '/clear') { + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledWith(chatId, '/clear') + expect(stopSpy).not.toHaveBeenCalled() + } else if (command === '/stop') { + expect(stopSpy).toHaveBeenCalledTimes(1) + expect(stopSpy).toHaveBeenCalledWith(chatId) + expect(sendSpy).not.toHaveBeenCalled() + } else { + expect(notices.at(-1)).toContain('old-cmd') + expect(sendSpy).not.toHaveBeenCalled() + expect(stopSpy).not.toHaveBeenCalled() + } + } finally { + failedOpen.mockRestore() + sendSpy.mockRestore() + stopSpy.mockRestore() + adapter.bridge.resetSession(chatId) + adapter.clearTransientChatState(chatId) + } + }) + + it('keeps the original session and project after reconnect timeout and resumes it on retry', async () => { + const adapter = adapterFor(platform) + const chatId = chatFor(platform) + const originalProject = path.join(temporaryRoot, `${platform}-original`) + fs.mkdirSync(originalProject) + adapter.bridge.resetSession(chatId) + adapter.clearTransientChatState(chatId) + adapter.sessionStore.set(chatId, 'original-session', originalProject) + const originalBinding = adapter.sessionStore.get(chatId) + const creationsBefore = newSessionCount + const failedPrompt = `${platform}: Continue after reconnect` + const failedOpen = spyOn(adapter.bridge, 'waitForOpen').mockImplementationOnce(async () => { + adapter.bridge.resetSession(chatId) + return false + }) + const sendSpy = spyOn(adapter.bridge, 'sendUserMessage') + try { + await send(platform, failedPrompt) + expect(adapter.sessionStore.get(chatId)).toEqual(originalBinding) + expect(newSessionCount).toBe(creationsBefore) + expect(sentPrompts.some((prompt) => prompt.content === failedPrompt)).toBe(false) + expect(sendSpy).not.toHaveBeenCalled() + expect(notices.at(-1)).toContain('已保留会话和工作目录') + expect(notices.at(-1)).not.toContain('/new') + + await send(platform, 'Retry the original conversation') + expect(adapter.bridge.getSessionId(chatId)).toBe('original-session') + expect(adapter.sessionStore.get(chatId)).toEqual(originalBinding) + expect(newSessionCount).toBe(creationsBefore) + expect(sendSpy).toHaveBeenCalledWith(chatId, 'Retry the original conversation', undefined) + } finally { + failedOpen.mockRestore() + sendSpy.mockRestore() + adapter.bridge.resetSession(chatId) + adapter.clearTransientChatState(chatId) + } + }) + it('restores original history and honors the real initial active-turn snapshot', async () => { const adapter = adapterFor(platform) const chatId = chatFor(platform) diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index ba088149..a3c513c8 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -34,7 +34,7 @@ import { formatProjectSelectionOutcome, ProjectSelectionController, } from '../common/project-selection-router.js' -import { restoreStoredSessionBinding } from '../common/session-recovery.js' +import { restoreStoredSessionBinding, SESSION_RECONNECT_NOTICE, type SessionRestoreResult } from '../common/session-recovery.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { extractInboundPayload } from './extract-payload.js' import { FeishuMediaService } from './media.js' @@ -220,7 +220,7 @@ function clearTransientChatState(chatId: string): void { pendingPermissions.delete(chatId) } -async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { +async function ensureExistingSession(chatId: string): Promise { return await restoreStoredSessionBinding({ chatId, bridge, @@ -233,8 +233,10 @@ async function ensureExistingSession(chatId: string): Promise<{ sessionId: strin } async function buildStatusText(chatId: string): Promise { - const stored = await ensureExistingSession(chatId) - if (!stored) return formatImStatus(null) + const result = await ensureExistingSession(chatId) + if (result.status === 'unavailable') return SESSION_RECONNECT_NOTICE + if (result.status === 'missing') return formatImStatus(null) + const stored = result.session const runtime = getRuntimeState(chatId) let projectName = path.basename(stored.workDir) || stored.workDir @@ -647,8 +649,12 @@ function buildPermissionCard( // ---------- session management ---------- async function ensureSession(chatId: string): Promise { - const stored = await ensureExistingSession(chatId) - if (stored) return true + const result = await ensureExistingSession(chatId) + if (result.status === 'restored') return true + if (result.status === 'unavailable') { + await sendText(chatId, SESSION_RECONNECT_NOTICE) + return false + } const workDir = defaultWorkDir if (workDir) { @@ -1000,9 +1006,9 @@ async function handleMessage(data: any): Promise { return } if (!hasAttachments && (msgText === '/clear' || msgText === '清空')) { - const stored = await ensureExistingSession(chatId) - if (!stored) { - await sendText(chatId, formatImStatus(null)) + const result = await ensureExistingSession(chatId) + if (result.status !== 'restored') { + await sendText(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) return } clearTransientChatState(chatId) @@ -1016,9 +1022,9 @@ async function handleMessage(data: any): Promise { return } if (!hasAttachments && (msgText === '/stop' || msgText === '停止')) { - const stored = await ensureExistingSession(chatId) - if (!stored) { - await sendText(chatId, formatImStatus(null)) + const result = await ensureExistingSession(chatId) + if (result.status !== 'restored') { + await sendText(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) return } bridge.sendStopGeneration(chatId) diff --git a/adapters/telegram/__tests__/commands.test.ts b/adapters/telegram/__tests__/commands.test.ts index 9621b35e..cc57eb5b 100644 --- a/adapters/telegram/__tests__/commands.test.ts +++ b/adapters/telegram/__tests__/commands.test.ts @@ -131,7 +131,7 @@ function createController(overrides?: Record) { }, defaultWorkDir: '/work/repo', isAllowedUser: mock(() => true), - ensureExistingSession: mock(async () => ({ sessionId: 'active', workDir: '/work/repo' })), + ensureExistingSession: mock(async () => ({ status: 'restored' as const, session: { sessionId: 'active', workDir: '/work/repo', updatedAt: 1 } })), clearTransientChatState: mock((chatId: string) => bridgeEvents.push(`clear:${chatId}`)), clearOtherSelections: mock(() => {}), isBusy: mock(() => false), @@ -372,7 +372,7 @@ describe('Telegram command controller helpers', () => { it('reports empty lists and command failures without throwing', async () => { const { controller, sent } = createController({ defaultWorkDir: '', - ensureExistingSession: mock(async () => null), + ensureExistingSession: mock(async () => ({ status: 'missing' })), httpClient: { listProviders: mock(async () => { throw new Error('providers down') }), activateOfficialProvider: mock(async () => {}), @@ -516,7 +516,7 @@ describe('Telegram command controller helpers', () => { it('does not claim a selected skill ran when the agent session is unavailable', async () => { const unavailable = createController({ - ensureExistingSession: mock(async () => null), + ensureExistingSession: mock(async () => ({ status: 'missing' })), }) await unavailable.controller.handleSkillsCommand(createCommandContext().ctx) expect(unavailable.sent.at(-1)?.text).toContain('当前项目可用 Skills') @@ -532,6 +532,46 @@ describe('Telegram command controller helpers', () => { expect(callback.edits[0]).toContain('会话已失效') }) + it('keeps skill listing on the original project after a temporary reconnect failure', async () => { + const session = { sessionId: 'original', workDir: '/work/original', updatedAt: 1 } + const restore = mock(async () => ({ status: 'restored', session })) + .mockResolvedValueOnce({ status: 'unavailable', session }) + const { controller, deps, sent } = createController({ ensureExistingSession: restore }) + + await controller.handleSkillsCommand(createCommandContext().ctx) + expect(sent.at(-1)?.text).toContain('已保留会话和工作目录') + expect(sent.at(-1)?.text).not.toContain('/new') + expect(deps.httpClient.listSkills).not.toHaveBeenCalled() + expect(deps.setStoredSession).not.toHaveBeenCalled() + expect(deps.deleteStoredSession).not.toHaveBeenCalled() + + await controller.handleSkillsCommand(createCommandContext().ctx) + expect(deps.httpClient.listSkills).toHaveBeenCalledWith('/work/original') + expect(sent.at(-1)?.text).toContain('/work/original') + }) + + it('retains the skill selection for retry when the original session temporarily cannot reconnect', async () => { + const session = { sessionId: 'original', workDir: '/work/original', updatedAt: 1 } + const restore = mock(async () => ({ status: 'restored', session })) + const { controller, deps, sent, sentUserMessages } = createController({ ensureExistingSession: restore }) + await controller.handleSkillsCommand(createCommandContext().ctx) + restore.mockResolvedValueOnce({ status: 'unavailable', session }) + + const failed = createCommandContext() + await controller.handleSelectionCallback(failed.ctx, { kind: 'skill', action: 'pick', index: 0 }) + expect(sent.at(-1)?.text).toContain('已保留会话和工作目录') + expect(sent.at(-1)?.text).not.toContain('/new') + expect(failed.edits).toEqual([]) + expect(sentUserMessages).toEqual([]) + expect(deps.setStoredSession).not.toHaveBeenCalled() + expect(deps.deleteStoredSession).not.toHaveBeenCalled() + + const retry = createCommandContext() + await controller.handleSelectionCallback(retry.ctx, { kind: 'skill', action: 'pick', index: 0 }) + expect(sentUserMessages).toEqual([{ chatId: '42', content: '/skill-a' }]) + expect(retry.edits[0]).toContain('已调用 Skill') + }) + it('reports a disconnected bridge instead of dropping a selected skill', async () => { const disconnected = createController({ sendUserMessage: mock(() => false), @@ -827,7 +867,7 @@ describe('Telegram command controller helpers', () => { delete: (chatId) => events.push(`delete:${chatId}`), }, isAllowedUser: () => allowPermissionUser, - ensureExistingSession: mock(async () => ({ sessionId: 'active', workDir: '/work/repo' })), + ensureExistingSession: mock(async () => ({ status: 'restored' as const, session: { sessionId: 'active', workDir: '/work/repo', updatedAt: 1 } })), clearTransientChatState: (chatId) => events.push(`clear:${chatId}`), clearOtherSelections: () => {}, isBusy: () => false, diff --git a/adapters/telegram/__tests__/entrypoint-session-routing.test.ts b/adapters/telegram/__tests__/entrypoint-session-routing.test.ts index 7db606b6..ab728c1f 100644 --- a/adapters/telegram/__tests__/entrypoint-session-routing.test.ts +++ b/adapters/telegram/__tests__/entrypoint-session-routing.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { ServerWebSocket } from 'bun' import { SessionStore } from '../../common/session-store.js' +import { WsBridge } from '../../common/ws-bridge.js' import { AttachmentStore } from '../../common/attachment/attachment-store.js' // Import the actual entrypoint with isolated configuration. Telegram API calls @@ -149,6 +150,79 @@ describe('Telegram entrypoint session routing', () => { if (directory) rmSync(directory, { recursive: true, force: true }) }) + it('retains the original session and project through reconnect timeout and retries that conversation', async () => { + const chatId = 709 + store.set(String(chatId), 'history', worktree) + const originalBinding = store.get(String(chatId)) + const creationsBefore = requests.filter((request) => request === 'POST /api/sessions').length + const failedOpen = spyOn(WsBridge.prototype, 'waitForOpen').mockImplementationOnce(async function (this: WsBridge, id) { + this.resetSession(id) + return false + }) + try { + await text(chatId, 'Continue after reconnect') + expect(store.get(String(chatId))).toEqual(originalBinding) + expect(requests.filter((request) => request === 'POST /api/sessions').length).toBe(creationsBefore) + expect(messages.some((item) => item.message.content === 'Continue after reconnect')).toBe(false) + expect(texts(chatId).at(-1)).toContain('已保留会话和工作目录') + expect(texts(chatId).at(-1)).not.toContain('/new') + + await text(chatId, 'Retry the original conversation') + await eventually(() => expect(messages.some((item) => item.sessionId === 'history' && item.message.content === 'Retry the original conversation')).toBe(true)) + expect(store.get(String(chatId))).toEqual(originalBinding) + expect(requests.filter((request) => request === 'POST /api/sessions').length).toBe(creationsBefore) + broadcast('history', { type: 'message_complete' }) + } finally { + failedOpen.mockRestore() + } + }) + + it.each(['status', 'stop', 'clear'] as const)('preserves the session and project when /%s cannot reconnect, then retries the original session', async (command) => { + const chatId = nextId++ + const sessionId = `retry-${command}` + sessionPaths.set(sessionId, worktree) + store.set(String(chatId), sessionId, worktree) + const originalBinding = store.get(String(chatId)) + const creationsBefore = requests.filter((request) => request === 'POST /api/sessions').length + const sendMessage = spyOn(WsBridge.prototype, 'sendUserMessage') + const sendStop = spyOn(WsBridge.prototype, 'sendStopGeneration') + const failedOpen = spyOn(WsBridge.prototype, 'waitForOpen').mockImplementationOnce(async function (this: WsBridge, id) { + this.resetSession(id) + return false + }) + try { + await text(chatId, `/${command}`) + // /stop and /clear dispatch asynchronously, so wait for their failure reply. + await eventually(() => expect(texts(chatId).at(-1)).toContain('已保留会话和工作目录')) + expect(store.get(String(chatId))).toEqual(originalBinding) + expect(requests.filter((request) => request === 'POST /api/sessions').length).toBe(creationsBefore) + expect(sendMessage).not.toHaveBeenCalled() + expect(sendStop).not.toHaveBeenCalled() + expect(messages.filter((item) => item.sessionId === sessionId)).toEqual([]) + expect(texts(chatId).at(-1)).not.toContain('/new') + + await text(chatId, `/${command}`) + await eventually(() => { + if (command === 'status') { + expect(texts(chatId).at(-1)).toContain(sessionId) + } else if (command === 'stop') { + expect(messages.some((item) => item.sessionId === sessionId && item.message.type === 'stop_generation')).toBe(true) + expect(texts(chatId).at(-1)).toContain('已发送停止信号') + } else { + expect(messages.some((item) => item.sessionId === sessionId && item.message.content === '/clear')).toBe(true) + expect(texts(chatId).some((value) => value.includes('已清空当前会话上下文'))).toBe(true) + } + }) + expect(store.get(String(chatId))).toEqual(originalBinding) + expect(requests.filter((request) => request === 'POST /api/sessions').length).toBe(creationsBefore) + expect(failedOpen).toHaveBeenCalledTimes(2) + } finally { + failedOpen.mockRestore() + sendMessage.mockRestore() + sendStop.mockRestore() + } + }) + it('runs registered history commands after authorization and deduplication', async () => { const before = requests.length await text(701, '/sessions', { userId: 99 }) diff --git a/adapters/telegram/commands.ts b/adapters/telegram/commands.ts index 077d4e56..a7312010 100644 --- a/adapters/telegram/commands.ts +++ b/adapters/telegram/commands.ts @@ -1,5 +1,6 @@ import { formatImHelp } from '../common/format.js' import { listProjectSessionHistory, restoreSelectedSession } from '../common/session-selection.js' +import { SESSION_RECONNECT_NOTICE, type SessionRestoreResult } from '../common/session-recovery.js' import type { SessionEntry } from '../common/session-store.js' import type { ServerMessage } from '../common/ws-bridge.js' import { @@ -70,7 +71,7 @@ export type TelegramCommandControllerDeps = { httpClient: AdapterHttpClient defaultWorkDir: string isAllowedUser: (userId: number) => boolean - ensureExistingSession: (chatId: string) => Promise<{ sessionId: string; workDir: string } | null> + ensureExistingSession: (chatId: string) => Promise clearTransientChatState: (chatId: string) => void clearOtherSelections: (chatId: string) => void isBusy: (chatId: string) => boolean @@ -240,7 +241,7 @@ export type TelegramRuntimeCommandControllerDeps = { delete: (chatId: string) => void } isAllowedUser: (userId: number) => boolean - ensureExistingSession: (chatId: string) => Promise<{ sessionId: string; workDir: string } | null> + ensureExistingSession: (chatId: string) => Promise clearTransientChatState: (chatId: string) => void clearOtherSelections: (chatId: string) => void isBusy: (chatId: string) => boolean @@ -527,8 +528,12 @@ export function createTelegramCommandController(deps: TelegramCommandControllerD } const showSkills = async (chatId: string): Promise => { - const stored = await deps.ensureExistingSession(chatId) - const cwd = stored?.workDir || deps.defaultWorkDir + const restored = await deps.ensureExistingSession(chatId) + if (restored.status === 'unavailable') { + await deps.api.sendMessage(Number(chatId), SESSION_RECONNECT_NOTICE) + return + } + const cwd = restored.status === 'restored' ? restored.session.workDir : deps.defaultWorkDir if (!cwd) { await deps.api.sendMessage(Number(chatId), '请先发送 /new 选择项目,再查看 Skills。') return @@ -565,8 +570,12 @@ export function createTelegramCommandController(deps: TelegramCommandControllerD const chatId = getCallbackChatId(ctx) if (!chatId) return - const stored = await deps.ensureExistingSession(chatId) - if (!stored) { + const restored = await deps.ensureExistingSession(chatId) + if (restored.status === 'unavailable') { + await deps.api.sendMessage(Number(chatId), SESSION_RECONNECT_NOTICE) + return + } + if (restored.status === 'missing') { pendingSelections.delete(chatId) await ctx.editMessageText('⚠️ 会话已失效,请发送 /new 重新选择项目后再调用 Skill。') return @@ -574,7 +583,7 @@ export function createTelegramCommandController(deps: TelegramCommandControllerD const invocation = `/${item.value}` if (!deps.sendUserMessage(chatId, invocation)) { - await ctx.editMessageText('⚠️ Skill 发送失败,连接可能已断开。请发送 /new 重新连接会话。') + await ctx.editMessageText(`⚠️ Skill 发送失败。${SESSION_RECONNECT_NOTICE}`) return } diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index 480ab24d..bed48a49 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -28,7 +28,7 @@ import { } from '../common/permission.js' import { SessionStore } from '../common/session-store.js' import { createAdapterClient } from '../common/adapter-client.js' -import { restoreStoredSessionBinding } from '../common/session-recovery.js' +import { restoreStoredSessionBinding, SESSION_RECONNECT_NOTICE, type SessionRestoreResult } from '../common/session-recovery.js' import { SessionSelectionController } from '../common/session-selection.js' import { syncImPermissionState } from '../common/permission-sync.js' import { isAllowedUser, tryPair } from '../common/pairing.js' @@ -150,7 +150,7 @@ async function handlePermissionDecision(chatId: string, decision: PermissionDeci ) } -async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { +async function ensureExistingSession(chatId: string): Promise { return await restoreStoredSessionBinding({ chatId, bridge, @@ -163,8 +163,10 @@ async function ensureExistingSession(chatId: string): Promise<{ sessionId: strin } async function buildStatusText(chatId: string): Promise { - const stored = await ensureExistingSession(chatId) - if (!stored) return formatImStatus(null) + const result = await ensureExistingSession(chatId) + if (result.status === 'unavailable') return SESSION_RECONNECT_NOTICE + if (result.status === 'missing') return formatImStatus(null) + const stored = result.session const runtime = getRuntimeState(chatId) let projectName = path.basename(stored.workDir) || stored.workDir @@ -216,8 +218,12 @@ async function buildStatusText(chatId: string): Promise { // ---------- session management ---------- async function ensureSession(chatId: string): Promise { - const stored = await ensureExistingSession(chatId) - if (stored) return true + const result = await ensureExistingSession(chatId) + if (result.status === 'restored') return true + if (result.status === 'unavailable') { + await bot.api.sendMessage(Number(chatId), SESSION_RECONNECT_NOTICE) + return false + } const workDir = defaultWorkDir if (workDir) { @@ -486,9 +492,9 @@ const isAuthorizedTelegramUser = (userId: number) => isAllowedUser('telegram', u registerAuthorizedTelegramCommand(bot, 'stop', isAuthorizedTelegramUser, (ctx) => { const chatId = String(ctx.chat!.id) void (async () => { - const stored = await ensureExistingSession(chatId) - if (!stored) { - await ctx.reply(formatImStatus(null)) + const result = await ensureExistingSession(chatId) + if (result.status !== 'restored') { + await ctx.reply(result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) return } bridge.sendStopGeneration(chatId) @@ -504,9 +510,9 @@ registerAuthorizedTelegramCommand(bot, 'status', isAuthorizedTelegramUser, async registerAuthorizedTelegramCommand(bot, 'clear', isAuthorizedTelegramUser, (ctx) => { const chatId = String(ctx.chat!.id) void (async () => { - const stored = await ensureExistingSession(chatId) - if (!stored) { - await ctx.reply(formatImStatus(null)) + const result = await ensureExistingSession(chatId) + if (result.status !== 'restored') { + await ctx.reply(result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) return } clearTransientChatState(chatId) diff --git a/adapters/wechat/index.ts b/adapters/wechat/index.ts index be7f219e..3bd796c1 100644 --- a/adapters/wechat/index.ts +++ b/adapters/wechat/index.ts @@ -19,7 +19,7 @@ import { SessionStore } from '../common/session-store.js' import { syncImPermissionState } from '../common/permission-sync.js' import { SessionSelectionController } from '../common/session-selection.js' import { createAdapterClient } from '../common/adapter-client.js' -import { restoreStoredSessionBinding } from '../common/session-recovery.js' +import { restoreStoredSessionBinding, SESSION_RECONNECT_NOTICE, type SessionRestoreResult } from '../common/session-recovery.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { AttachmentStore } from '../common/attachment/attachment-store.js' import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js' @@ -219,7 +219,7 @@ function enqueueWechat(chatId: string, task: () => Promise): void { }) } -async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { +async function ensureExistingSession(chatId: string): Promise { return await restoreStoredSessionBinding({ chatId, bridge, @@ -232,8 +232,10 @@ async function ensureExistingSession(chatId: string): Promise<{ sessionId: strin } async function buildStatusText(chatId: string): Promise { - const stored = await ensureExistingSession(chatId) - if (!stored) return formatImStatus(null) + const result = await ensureExistingSession(chatId) + if (result.status === 'unavailable') return SESSION_RECONNECT_NOTICE + if (result.status === 'missing') return formatImStatus(null) + const stored = result.session const runtime = getRuntimeState(chatId) let projectName = path.basename(stored.workDir) || stored.workDir @@ -283,8 +285,12 @@ async function buildStatusText(chatId: string): Promise { } async function ensureSession(chatId: string): Promise { - const stored = await ensureExistingSession(chatId) - if (stored) return true + const result = await ensureExistingSession(chatId) + if (result.status === 'restored') return true + if (result.status === 'unavailable') { + await sendText(chatId, SESSION_RECONNECT_NOTICE) + return false + } const workDir = defaultWorkDir if (workDir) return await createSessionForChat(chatId, workDir) @@ -496,9 +502,9 @@ async function routeUserMessage(message: WechatMessage): Promise { return } if (!hasAttachments && (text === '/stop' || text === '停止')) { - const stored = await ensureExistingSession(chatId) - if (!stored) { - await sendText(chatId, formatImStatus(null)) + const result = await ensureExistingSession(chatId) + if (result.status !== 'restored') { + await sendText(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) return } bridge.sendStopGeneration(chatId) @@ -506,9 +512,9 @@ async function routeUserMessage(message: WechatMessage): Promise { return } if (!hasAttachments && (text === '/clear' || text === '清空')) { - const stored = await ensureExistingSession(chatId) - if (!stored) { - await sendText(chatId, formatImStatus(null)) + const result = await ensureExistingSession(chatId) + if (result.status !== 'restored') { + await sendText(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) return } clearTransientChatState(chatId) diff --git a/adapters/whatsapp/index.ts b/adapters/whatsapp/index.ts index 84ce03ae..5dc41a70 100644 --- a/adapters/whatsapp/index.ts +++ b/adapters/whatsapp/index.ts @@ -29,7 +29,7 @@ import { SessionStore } from '../common/session-store.js' import { syncImPermissionState } from '../common/permission-sync.js' import { SessionSelectionController } from '../common/session-selection.js' import { createAdapterClient } from '../common/adapter-client.js' -import { restoreStoredSessionBinding } from '../common/session-recovery.js' +import { restoreStoredSessionBinding, SESSION_RECONNECT_NOTICE, type SessionRestoreResult } from '../common/session-recovery.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { AttachmentStore } from '../common/attachment/attachment-store.js' import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js' @@ -166,7 +166,7 @@ async function handlePermissionDecision(chatId: string, decision: PermissionDeci ) } -async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { +async function ensureExistingSession(chatId: string): Promise { return await restoreStoredSessionBinding({ chatId, bridge, @@ -179,8 +179,10 @@ async function ensureExistingSession(chatId: string): Promise<{ sessionId: strin } async function buildStatusText(chatId: string): Promise { - const stored = await ensureExistingSession(chatId) - if (!stored) return formatImStatus(null) + const result = await ensureExistingSession(chatId) + if (result.status === 'unavailable') return SESSION_RECONNECT_NOTICE + if (result.status === 'missing') return formatImStatus(null) + const stored = result.session const runtime = getRuntimeState(chatId) let projectName = path.basename(stored.workDir) || stored.workDir @@ -206,8 +208,12 @@ async function buildStatusText(chatId: string): Promise { } async function ensureSession(chatId: string): Promise { - const stored = await ensureExistingSession(chatId) - if (stored) return true + const result = await ensureExistingSession(chatId) + if (result.status === 'restored') return true + if (result.status === 'unavailable') { + await sendWhatsAppText(chatId, SESSION_RECONNECT_NOTICE) + return false + } const workDir = defaultWorkDir if (workDir) { @@ -461,9 +467,9 @@ async function routeUserMessage( return } if (command === '/stop') { - const stored = await ensureExistingSession(chatId) - if (!stored) { - await sendWhatsAppText(chatId, formatImStatus(null)) + const result = await ensureExistingSession(chatId) + if (result.status !== 'restored') { + await sendWhatsAppText(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) return } bridge.sendStopGeneration(chatId) @@ -475,9 +481,9 @@ async function routeUserMessage( return } if (command === '/clear') { - const stored = await ensureExistingSession(chatId) - if (!stored) { - await sendWhatsAppText(chatId, formatImStatus(null)) + const result = await ensureExistingSession(chatId) + if (result.status !== 'restored') { + await sendWhatsAppText(chatId, result.status === 'unavailable' ? SESSION_RECONNECT_NOTICE : formatImStatus(null)) return } clearTransientChatState(chatId) diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index ae96efdc..dbf44d1e 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -138,9 +138,10 @@ export const AssistantMessage = memo(function AssistantMessage({ // what says who is speaking (see the note above), so a border here // repeats that at the cost of ~50px per reply and makes prose look // like the tool rows it sits between. The turn rail groups it now. - className="w-full text-[14.5px] text-[var(--color-text-primary)]" + className="w-full text-[var(--color-text-primary)]" > { render() const editor = screen.getByRole('textbox') + expect(editor).toHaveClass('chat-reading-text') expect(editor).toHaveAttribute( 'data-placeholder', 'This temporary workspace was cleaned up. Start a new session in the original project to continue.', @@ -2682,6 +2683,16 @@ describe('ChatInput file mentions', () => { const wrapper = getComposerElement().parentElement expect(wrapper).toHaveClass('flex-1') expect(wrapper).toHaveClass('min-w-0') + expect(getComposerElement()).toHaveClass('chat-reading-text') + }) + + it.each([false, true])('shares the transcript reading width with compact=%s', (compact) => { + const { container } = render() + const readingColumn = Array.from(container.querySelectorAll('div')).find((element) => + element.classList.contains('max-w-[var(--chat-content-max-width)]')) + expect(readingColumn).toBeTruthy() + expect(readingColumn?.contains(getComposerElement())).toBe(true) + expect(getComposerElement()).toHaveClass('chat-reading-text') }) it('uses Shift+Enter for a newline when Enter is the configured send shortcut', async () => { diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 27061f13..60333ac1 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -1230,11 +1230,8 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis className={ isHeroComposer ? 'mx-auto flex w-full max-w-3xl flex-col' - : compact - ? 'mx-auto max-w-full' - // 900px matches the transcript column above it; at 860 the - // composer sat 20px narrower on each side than the messages. - : `${isMobileComposer ? 'mx-0 max-w-none' : 'mx-auto max-w-[900px]'}` + // Match the transcript even while a side panel tightens padding. + : `${isMobileComposer ? 'mx-0 max-w-none' : 'mx-auto max-w-[var(--chat-content-max-width)]'}` } >
{ composingRef.current = false }} placeholder={composerPlaceholder} disabled={composerDisabled} - editorClassName={`max-h-[200px] overflow-y-auto text-sm leading-relaxed text-[var(--color-text-primary)] ${ + editorClassName={`chat-reading-text max-h-[200px] overflow-y-auto text-sm leading-relaxed text-[var(--color-text-primary)] ${ useCompactChrome ? 'py-1.5' : 'py-2' }`} aria={{ diff --git a/desktop/src/components/chat/CodeViewer.test.tsx b/desktop/src/components/chat/CodeViewer.test.tsx index f3414d0d..f1331bb4 100644 --- a/desktop/src/components/chat/CodeViewer.test.tsx +++ b/desktop/src/components/chat/CodeViewer.test.tsx @@ -16,6 +16,10 @@ describe('CodeViewer', () => { expect(contentWrapper?.style.padding).toBe('0.5rem 12px') expect(contentWrapper?.style.whiteSpace).toBe('pre') expect(contentWrapper?.style.wordBreak).toBe('normal') + expect(contentWrapper?.style.fontSize).toBe('var(--code-viewer-font-size, 13px)') + expect(contentWrapper?.style.fontFamily).toBe('var(--font-mono)') + // Reading preferences resize code content, while the toolbar retains UI typography. + expect((container.firstElementChild as HTMLElement).style.fontFamily).toBe('var(--font-body)') const codeArea = container.querySelector('.code-viewer-area') as HTMLElement | null expect(codeArea?.getAttribute('data-has-line-numbers')).toBe('true') diff --git a/desktop/src/components/chat/CodeViewer.tsx b/desktop/src/components/chat/CodeViewer.tsx index fbeba957..4976d84b 100644 --- a/desktop/src/components/chat/CodeViewer.tsx +++ b/desktop/src/components/chat/CodeViewer.tsx @@ -155,7 +155,7 @@ function PrismCodeContent({ margin: 0, padding: CODE_AREA_PADDING, fontFamily: 'var(--font-mono)', - fontSize: '13px', + fontSize: 'var(--code-viewer-font-size, 13px)', lineHeight: String(CODE_LINE_HEIGHT), whiteSpace: wrapLongLines ? 'pre-wrap' : 'pre', wordBreak: wrapLongLines ? 'break-word' : 'normal', @@ -272,7 +272,7 @@ function CodeArea({ style={{ margin: 0, fontFamily: 'var(--font-mono)', - fontSize: '13px', + fontSize: 'var(--code-viewer-font-size, 13px)', lineHeight: String(CODE_LINE_HEIGHT), whiteSpace: wrapLongLines ? 'pre-wrap' : 'pre', wordBreak: wrapLongLines ? 'break-word' : 'normal', @@ -311,6 +311,7 @@ export function CodeViewer({ return (
{ dropSession(sessionId) }) + it.each(['local', 'other window'])('retains the reading row and invalidates measurements after a font change from %s', async (source) => { + const sessionId = 'appearance-anchor' + dropSession(sessionId) + useChatAppearanceStore.setState({ appearance: { font: 'system', fontSize: 14, width: 'standard' } }) + localStorage.setItem(CHAT_APPEARANCE_STORAGE_KEY, JSON.stringify({ version: 1, font: 'system', fontSize: 14, width: 'standard' })) + const dispose = initializeChatAppearance() + let rowHeight = 100 + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return { top: 0, bottom: rowHeight, width: 800, height: this.hasAttribute('data-virtual-message-item') ? rowHeight : 0 } as DOMRect + }) + useChatStore.setState({ sessions: { [sessionId]: makeSessionState({ + messages: Array.from({ length: 220 }, (_, index) => ({ + id: `appearance-row-${index}`, type: 'assistant_text' as const, + content: `transcript line ${index}`, timestamp: index, + })), + }) } }) + const { container } = render() + const scroller = container.querySelector('.chat-scroll-area')! + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 }) + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 22000 }) + await waitForProgrammaticScrollReset() + fireEvent.wheel(scroller, { deltaY: -100 }) + scroller.scrollTop = 0 + fireEvent.scroll(scroller) + scroller.scrollTop = 150 + fireEvent.scroll(scroller) + expect(container.querySelector('[data-virtual-message-item="appearance-row-1"]')).not.toBeNull() + getHeightsForSession(sessionId).set('appearance-row-100', 987) + rowHeight = 200 + act(() => { + if (source === 'local') useChatAppearanceStore.getState().setAppearance({ fontSize: 24 }) + else { + localStorage.setItem(CHAT_APPEARANCE_STORAGE_KEY, JSON.stringify({ version: 1, fontSize: 24 })) + window.dispatchEvent(new StorageEvent('storage', { key: CHAT_APPEARANCE_STORAGE_KEY, storageArea: localStorage })) + } + }) + // Row 1 remains the reading anchor, 50px below its top, despite row 0 growing. + expect(scroller.scrollTop).toBe(250) + expect(getHeightsForSession(sessionId).has('appearance-row-100')).toBe(false) + expect(getHeightsForSession(sessionId).get('appearance-row-1')).toBe(200) + act(() => useChatAppearanceStore.setState({ appearance: { font: 'system', fontSize: 14, width: 'standard' } })) + dispose() + localStorage.removeItem(CHAT_APPEARANCE_STORAGE_KEY) + dropSession(sessionId) + }) + it('keeps fractional border-box jitter from invalidating a settled virtual row', async () => { const sessionId = 'virtual-row-measurement-jitter' const observers: Array<{ @@ -6293,6 +6341,26 @@ describe('MessageList nested tool calls', () => { }) it('preserves the expanded change card through virtual unmount and returns to its file opener', async () => { + const frames = new Map() + let nextFrameId = 0 + vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { + const id = ++nextFrameId + frames.set(id, callback) + return id + })) + vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => frames.delete(id))) + const advanceFrame = async (time: number) => { + const scheduled = [...frames.keys()] + await act(async () => { + for (const id of scheduled) { + const callback = frames.get(id) + frames.delete(id) + callback?.(time) + } + await Promise.resolve() + }) + } + vi.mocked(sessionsApi.getTurnCheckpoints).mockResolvedValue({ checkpoints: [{ target: { targetUserMessageId: 'user-virtual-file', userMessageIndex: 0, userMessageCount: 221 }, code: { available: true, filesChanged: ['src/virtual.ts'], insertions: 1, deletions: 0 }, @@ -6314,7 +6382,7 @@ describe('MessageList nested tool calls', () => { const scrollArea = container.querySelector('.chat-scroll-area')! Object.defineProperty(scrollArea, 'clientHeight', { configurable: true, value: 500 }) Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 222 * 112 }) - await waitForProgrammaticScrollReset() + await advanceFrame(0) scrollArea.scrollTop = 0 fireEvent.scroll(scrollArea) fireEvent.click(await screen.findByRole('button', { name: 'Show 1 changed files' })) @@ -6323,15 +6391,19 @@ describe('MessageList nested tool calls', () => { await waitFor(() => expect(useWorkspaceStore.getState().getSession(ACTIVE_TAB).origin) .toEqual({ sourceTurnKey: 'assistant-virtual-file', sourceElementId: opener.id })) - await waitForProgrammaticScrollReset() + await advanceFrame(16) scrollArea.scrollTop = 222 * 112 - 500 fireEvent.scroll(scrollArea) await waitFor(() => expect(container.querySelector('[data-chat-render-item-key="assistant-virtual-file"]')).toBeNull()) act(() => useWorkspaceStore.getState().setLayout(ACTIVE_TAB, 'hidden')) - const remountedOpener = await screen.findByRole('button', { name: 'Open src/virtual.ts in workspace' }) + // The first frame remounts the virtual row; the next frame restores focus. + // Flush React between frames instead of racing real rAF against role queries. + await advanceFrame(32) + const remountedOpener = screen.getByRole('button', { name: 'Open src/virtual.ts in workspace' }) expect(remountedOpener).not.toBe(opener) - await waitFor(() => expect(document.activeElement).toBe(remountedOpener)) + await advanceFrame(48) + expect(document.activeElement).toBe(remountedOpener) expect(screen.getByRole('button', { name: 'Hide changed files' }).getAttribute('aria-expanded')).toBe('true') expect(useWorkspaceStore.getState().getSession(ACTIVE_TAB).origin).toBeNull() }) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 057d7c5f..9318e6ba 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -12,6 +12,7 @@ import { useWorkspaceReviewStore } from '../../stores/workspaceReviewStore' import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' import { teamTaskWindowsForSnapshot, useTeamStore } from '../../stores/teamStore' import { useUIStore } from '../../stores/uiStore' +import { useChatAppearanceStore } from '../../stores/chatAppearanceStore' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n/locales/en' import { UserMessage } from './UserMessage' @@ -2347,12 +2348,18 @@ export function MessageList({ chatState === 'tool_executing' || hasPendingPermissionCard || (chatState === 'thinking' && Boolean(activeThinkingId)) + const appearanceSignature = useChatAppearanceStore((state) => + `${state.appearance.font}:${state.appearance.fontSize}:${state.appearance.width}`, + ) + const previousAppearanceSignature = useRef(appearanceSignature) + const pendingAppearanceScrollTop = useRef(null) const messageListRef = useRef(null) const scrollContainerRef = useRef(null) const scrollContentRef = useRef(null) - const virtualItemHeightsRef = useRef>( - resolvedSessionId ? getHeightsForSession(resolvedSessionId) : new Map(), + const [initialVirtualItemHeights] = useState(() => + resolvedSessionId ? getHeightsForSession(resolvedSessionId, appearanceSignature) : new Map(), ) + const virtualItemHeightsRef = useRef(initialVirtualItemHeights) const virtualItemMetricCacheRef = useRef>( resolvedSessionId ? getMetricsForSession(resolvedSessionId) : new Map(), ) @@ -2663,7 +2670,7 @@ export function MessageList({ lastSessionIdRef.current = resolvedSessionId setProgrammaticNavigationItemId(null) virtualItemHeightsRef.current = resolvedSessionId - ? getHeightsForSession(resolvedSessionId) + ? getHeightsForSession(resolvedSessionId, appearanceSignature) : new Map() virtualItemMetricCacheRef.current = resolvedSessionId ? getMetricsForSession(resolvedSessionId) @@ -2711,7 +2718,7 @@ export function MessageList({ scrollToBottom() } } - }, [resolvedSessionId, scrollToBottom]) + }, [appearanceSignature, resolvedSessionId, scrollToBottom]) const tailMessage = messages[messages.length - 1] ?? null const tailMessageId = tailMessage?.id ?? null @@ -2948,6 +2955,46 @@ export function MessageList({ [measuredItemsVersion, renderItemKeys, renderItemMetrics, renderItems, virtualViewport], ) + useLayoutEffect(() => { + if (previousAppearanceSignature.current === appearanceSignature) return + previousAppearanceSignature.current = appearanceSignature + const container = scrollContainerRef.current + if (resolvedSessionId) getHeightsForSession(resolvedSessionId, appearanceSignature) + if (!container || !virtualTranscriptWindow.enabled) return + + // Off-screen rows retain no valid measurements after a font/measure change. + // Re-measure mounted rows before paint and preserve the reader's row plus + // its intra-row offset while the spacer above it returns to estimates. + const oldOffsets = virtualTranscriptWindow.offsets + const oldScrollTop = virtualViewport.scrollTop + let anchorIndex = 0 + while (anchorIndex + 1 < renderItemKeys.length && oldOffsets[anchorIndex + 1]! <= oldScrollTop) anchorIndex += 1 + virtualItemHeightsRef.current.clear() + for (const node of container.querySelectorAll('[data-virtual-message-item]')) { + const height = node.getBoundingClientRect().height + if (height > 0) virtualItemHeightsRef.current.set(node.dataset.virtualMessageItem!, clampNumber(height, VIRTUAL_MIN_ITEM_HEIGHT, VIRTUAL_MAX_ITEM_HEIGHT)) + } + const offsets = buildVirtualItemOffsets(renderItemKeys, renderItemMetrics, virtualItemHeightsRef.current) + const intraRowOffset = Math.max(0, oldScrollTop - (oldOffsets[anchorIndex] ?? 0)) + const rowHeight = (offsets[anchorIndex + 1] ?? 0) - (offsets[anchorIndex] ?? 0) + const nextScrollTop = shouldAutoScrollRef.current + ? SCROLL_BOTTOM_SENTINEL + : (offsets[anchorIndex] ?? 0) + Math.min(intraRowOffset, Math.max(0, rowHeight - 1)) + ignoreProgrammaticScrollUntilRef.current = performance.now() + 250 + ignoreProgrammaticScrollTopRef.current = nextScrollTop + pendingAppearanceScrollTop.current = nextScrollTop + setVirtualViewport((current) => ({ ...current, scrollTop: nextScrollTop })) + setMeasuredItemsVersion((version) => version + 1) + }, [appearanceSignature, renderItemKeys, renderItemMetrics, resolvedSessionId, virtualTranscriptWindow, virtualViewport.scrollTop]) + + useLayoutEffect(() => { + const target = pendingAppearanceScrollTop.current + if (target === null || !scrollContainerRef.current) return + pendingAppearanceScrollTop.current = null + setScrollTopWithoutLayoutRead(scrollContainerRef.current, target) + ignoreProgrammaticScrollTopRef.current = scrollContainerRef.current.scrollTop + }, [measuredItemsVersion]) + const activeConversationNavigationItemId = useMemo( () => isAwayFromLatest ? getActiveConversationNavigationItemId( @@ -3645,7 +3692,7 @@ export function MessageList({ // open — `compact` only tightens padding. Dropping to `max-w-full` // was what made the transcript lose its centred structure the moment // the agent-teams workbench appeared. - className="mx-auto max-w-[900px]" + className="mx-auto max-w-[var(--chat-content-max-width)]" > {sessionState?.historyWindowed && sessionState?.historyPage?.nextCursor ? ( // The server's byte budget cut history short; this is the only @@ -3731,7 +3778,7 @@ export function MessageList({ )} {!isLoadingTurnChangeCards && visibleTurnChangeCards.length === 0 && turnChangeLoadError && ( -
+
{turnChangeLoadError}
)} diff --git a/desktop/src/components/chat/SessionChatSurface.test.tsx b/desktop/src/components/chat/SessionChatSurface.test.tsx index eb1d1183..0fb25e4a 100644 --- a/desktop/src/components/chat/SessionChatSurface.test.tsx +++ b/desktop/src/components/chat/SessionChatSurface.test.tsx @@ -31,7 +31,7 @@ describe('SessionChatSurface', () => { expect(screen.getByText('Activity')).toBeInTheDocument() const header = screen.getByTestId('session-header') - expect(header.firstElementChild).toHaveClass('max-w-[900px]') + expect(header.firstElementChild).toHaveClass('max-w-[var(--chat-content-max-width)]') expect(within(header).getByRole('heading', { name: 'Child run' })).toHaveClass('truncate') expect(header.querySelectorAll('[aria-hidden="true"]')).toHaveLength(1) }) diff --git a/desktop/src/components/chat/SessionChatSurface.tsx b/desktop/src/components/chat/SessionChatSurface.tsx index 089be5bc..c2613234 100644 --- a/desktop/src/components/chat/SessionChatSurface.tsx +++ b/desktop/src/components/chat/SessionChatSurface.tsx @@ -30,7 +30,7 @@ export function SessionChatHeader({ compact ? 'px-4 py-2.5' : 'px-9 py-3', ].join(' ')} > -
+
{leading}
diff --git a/desktop/src/components/chat/ThinkingBlock.test.tsx b/desktop/src/components/chat/ThinkingBlock.test.tsx index a43b4d13..1f3d6d46 100644 --- a/desktop/src/components/chat/ThinkingBlock.test.tsx +++ b/desktop/src/components/chat/ThinkingBlock.test.tsx @@ -79,6 +79,8 @@ describe('ThinkingBlock', () => { expect(first.container.querySelector('[data-thinking-content]')).toBeNull() fireEvent.click(screen.getByRole('button')) expect(first.container.querySelector('[data-thinking-content="expanded"]')).not.toBeNull() + expect(first.container.querySelector('.markdown-prose')).toHaveClass('chat-reading-markdown') + expect(screen.getByRole('button')).not.toHaveClass('chat-reading-markdown', 'chat-reading-text') first.unmount() const second = render() expect(second.container.querySelector('[data-thinking-content="expanded"]')).not.toBeNull() diff --git a/desktop/src/components/chat/ThinkingBlock.tsx b/desktop/src/components/chat/ThinkingBlock.tsx index 113e2510..e18d2884 100644 --- a/desktop/src/components/chat/ThinkingBlock.tsx +++ b/desktop/src/components/chat/ThinkingBlock.tsx @@ -96,7 +96,7 @@ export function ThinkingBlock({ variant="compact" cache={!isActive} streaming={isActive} - className="thinking-markdown text-[var(--color-text-secondary)]" + className="thinking-markdown chat-reading-markdown text-[var(--color-text-secondary)]" /> {isActive && }
diff --git a/desktop/src/components/chat/UserMessage.tsx b/desktop/src/components/chat/UserMessage.tsx index b5830d49..6a55da72 100644 --- a/desktop/src/components/chat/UserMessage.tsx +++ b/desktop/src/components/chat/UserMessage.tsx @@ -124,13 +124,13 @@ export const UserMessage = memo(function UserMessage({ {hasText && (
)} @@ -169,7 +169,7 @@ export const UserMessage = memo(function UserMessage({ {hasText && (
{ expect(getHeightsForSession('s2').get('x')).toBe(99) }) + it('invalidates an inactive session on return after reading preferences change', () => { + const first = getHeightsForSession('first', 'system:14:standard') + first.set('row', 200) + getHeightsForSession('second', 'system:14:standard').set('row', 300) + getHeightsForSession('second', 'serif:24:wide') + expect(getHeightsForSession('first', 'serif:24:wide')).toBe(first) + expect(first.size).toBe(0) + first.set('row', 400) + expect(getHeightsForSession('first', 'serif:24:wide').get('row')).toBe(400) + }) + it('evicts the least recently used session beyond the LRU bound', () => { for (let i = 0; i < 18; i++) { getHeightsForSession(`session-${i}`).set('marker', i) diff --git a/desktop/src/components/chat/virtualHeightCache.ts b/desktop/src/components/chat/virtualHeightCache.ts index 941d6e32..8ae539b8 100644 --- a/desktop/src/components/chat/virtualHeightCache.ts +++ b/desktop/src/components/chat/virtualHeightCache.ts @@ -12,6 +12,7 @@ const MAX_TRACKED_SESSIONS = 16 const sessionHeightCache = new Map>() const sessionMetricCache = new Map>() +const sessionAppearanceSignatures = new Map() function touchSession(sessionId: string, map: Map>) { // Reinsert to move to LRU tail. @@ -28,10 +29,11 @@ function evictSessionsBeyondLimit(): void { if (typeof oldest !== 'string') break sessionHeightCache.delete(oldest) sessionMetricCache.delete(oldest) + sessionAppearanceSignatures.delete(oldest) } } -export function getHeightsForSession(sessionId: string): Map { +export function getHeightsForSession(sessionId: string, appearanceSignature?: string): Map { let heights = sessionHeightCache.get(sessionId) if (!heights) { heights = new Map() @@ -40,6 +42,11 @@ export function getHeightsForSession(sessionId: string): Map { } else { touchSession(sessionId, sessionHeightCache as Map>) } + if (appearanceSignature !== undefined) { + const previous = sessionAppearanceSignatures.get(sessionId) + if (previous !== undefined && previous !== appearanceSignature) heights.clear() + sessionAppearanceSignatures.set(sessionId, appearanceSignature) + } return heights } @@ -57,6 +64,7 @@ export function getMetricsForSession(sessionId: string): Map { sessionHeightCache.clear() sessionMetricCache.clear() + sessionAppearanceSignatures.clear() }, } diff --git a/desktop/src/components/markdown/ChatTypography.test.tsx b/desktop/src/components/markdown/ChatTypography.test.tsx new file mode 100644 index 00000000..92e1da65 --- /dev/null +++ b/desktop/src/components/markdown/ChatTypography.test.tsx @@ -0,0 +1,27 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { MarkdownRenderer } from './MarkdownRenderer' +import { UserMessage } from '../chat/UserMessage' +import { AssistantMessage } from '../chat/AssistantMessage' + +describe('chat reading preferences', () => { + it('opts conversation prose into reading preferences without changing other markdown', () => { + const content = '# Heading\n\nParagraph with `code`.\n\n- List item\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n```js\nconst value = 1\n```' + const { container } = render(<> + + + + ) + const roots = container.querySelectorAll('.markdown-prose') + const conversation = roots[0]! + const document = roots[1]! + expect(conversation.classList.contains('chat-reading-markdown')).toBe(true) + expect(document.classList.contains('chat-reading-markdown')).toBe(false) + expect(container.querySelector('[data-message-body="user"]')?.classList.contains('chat-reading-text')).toBe(true) + expect(conversation.querySelector('h1')).toBeTruthy() + expect(conversation.querySelector('table')).toBeTruthy() + const code = conversation.querySelector('[data-code-viewer-content]') + expect(code?.style.fontSize).toBe('var(--code-viewer-font-size, 13px)') + expect(code?.style.fontFamily).toBe('var(--font-mono)') + }) +}) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index ce367baa..c96e3d1b 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -81,6 +81,22 @@ export const en = { "sideQuestion.send": "Ask", "sideQuestion.commandDescription": "Ask a side question without interrupting this task", + "settings.chatAppearance.title": "Chat appearance", + "settings.chatAppearance.description": "Adjust chat text independently of UI zoom. Changes apply immediately on this device.", + "settings.chatAppearance.reset": "Reset chat appearance", + "settings.chatAppearance.font": "Chat font", + "settings.chatAppearance.fontSystem": "System default", + "settings.chatAppearance.fontSans": "Sans serif", + "settings.chatAppearance.fontSerif": "Serif", + "settings.chatAppearance.fontMono": "Monospace", + "settings.chatAppearance.fontSize": "Chat font size", + "settings.chatAppearance.width": "Conversation width", + "settings.chatAppearance.widthStandard": "Standard", + "settings.chatAppearance.widthWide": "Wide", + "settings.chatAppearance.widthFull": "Fill available space", + "settings.chatAppearance.hint": "Fonts use those available on your system. Code stays monospace. Width applies to messages and the composer; narrow windows fit automatically.", + "settings.chatAppearance.preview": "Font preview", + "settings.chatAppearance.previewMarkdown": "### A clearer conversation\n\nAdjust text for comfortable reading. 中文・日本語・한국어 · Hello 123.\n\n- Lists follow your chosen font and size.\n- Inline code stays monospace: `const answer = 42`.\n\n| Content | Example |\n| --- | --- |\n| Body text | Read at your own pace |", "chat.sessionNoProject": "No project", "chat.referenceSessions": "Sessions", "chat.sessionReferencesLoadFailed": "Could not load sessions.", diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 30cc9c4d..64774ffb 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -82,6 +82,22 @@ export const jp: Record = { "sideQuestion.send": "質問する", "sideQuestion.commandDescription": "タスクを中断せずに質問する", + "settings.chatAppearance.title": "チャットの外観", + "settings.chatAppearance.description": "UIの拡大率とは別にチャットの文字を調整します。変更はすぐに反映され、この端末に保存されます。", + "settings.chatAppearance.reset": "チャットの外観をリセット", + "settings.chatAppearance.font": "チャットのフォント", + "settings.chatAppearance.fontSystem": "システム標準", + "settings.chatAppearance.fontSans": "ゴシック体", + "settings.chatAppearance.fontSerif": "明朝体", + "settings.chatAppearance.fontMono": "等幅", + "settings.chatAppearance.fontSize": "チャットの文字サイズ", + "settings.chatAppearance.width": "会話の幅", + "settings.chatAppearance.widthStandard": "標準", + "settings.chatAppearance.widthWide": "広い", + "settings.chatAppearance.widthFull": "利用可能な幅に合わせる", + "settings.chatAppearance.hint": "システムにあるフォントを使用します。コードは等幅のままです。幅はメッセージと入力欄に適用され、小さいウィンドウでは自動調整されます。", + "settings.chatAppearance.preview": "フォントのプレビュー", + "settings.chatAppearance.previewMarkdown": "### 読みやすい会話\n\n文字を調整して快適に読みましょう。中文・日本語・한국어 · Hello 123。\n\n- リストにも選択したフォントとサイズが適用されます。\n- インラインコードは等幅のままです:`const answer = 42`。\n\n| 内容 | 例 |\n| --- | --- |\n| 本文 | 自分のペースで読む |", "chat.sessionNoProject": "プロジェクトなし", "chat.referenceSessions": "セッション", "chat.sessionReferencesLoadFailed": "セッションを読み込めませんでした。", diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index d521f204..c4d2f6c7 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -82,6 +82,22 @@ export const kr: Record = { "sideQuestion.send": "질문하기", "sideQuestion.commandDescription": "작업을 중단하지 않고 별도로 질문하기", + "settings.chatAppearance.title": "채팅 모양", + "settings.chatAppearance.description": "UI 확대와 별도로 채팅 글자를 조정합니다. 변경 사항은 즉시 적용되며 이 기기에 저장됩니다.", + "settings.chatAppearance.reset": "채팅 모양 초기화", + "settings.chatAppearance.font": "채팅 글꼴", + "settings.chatAppearance.fontSystem": "시스템 기본값", + "settings.chatAppearance.fontSans": "고딕체", + "settings.chatAppearance.fontSerif": "명조체", + "settings.chatAppearance.fontMono": "고정폭", + "settings.chatAppearance.fontSize": "채팅 글자 크기", + "settings.chatAppearance.width": "대화 너비", + "settings.chatAppearance.widthStandard": "기본", + "settings.chatAppearance.widthWide": "넓게", + "settings.chatAppearance.widthFull": "사용 가능한 너비 채우기", + "settings.chatAppearance.hint": "시스템에 있는 글꼴을 사용합니다. 코드는 고정폭을 유지합니다. 너비는 메시지와 입력창에 적용되며 작은 창에서는 자동 조정됩니다.", + "settings.chatAppearance.preview": "글꼴 미리보기", + "settings.chatAppearance.previewMarkdown": "### 편안하게 읽는 대화\n\n글자를 조정하여 편안하게 읽으세요. 中文・日本語・한국어 · Hello 123.\n\n- 목록에도 선택한 글꼴과 크기가 적용됩니다.\n- 인라인 코드는 고정폭을 유지합니다: `const answer = 42`.\n\n| 내용 | 예시 |\n| --- | --- |\n| 본문 | 자신의 속도로 읽기 |", "chat.sessionNoProject": "프로젝트 없음", diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 7ef4c3a4..a04c6e38 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -82,6 +82,22 @@ export const zh: Record = { "sideQuestion.send": "提問", "sideQuestion.commandDescription": "不中斷主任務,單獨提問", + "settings.chatAppearance.title": "聊天外觀", + "settings.chatAppearance.description": "獨立於介面縮放調整聊天文字。修改立即生效,並儲存在目前裝置。", + "settings.chatAppearance.reset": "還原聊天外觀預設值", + "settings.chatAppearance.font": "聊天字型", + "settings.chatAppearance.fontSystem": "跟隨系統", + "settings.chatAppearance.fontSans": "無襯線", + "settings.chatAppearance.fontSerif": "襯線", + "settings.chatAppearance.fontMono": "等寬", + "settings.chatAppearance.fontSize": "聊天字級", + "settings.chatAppearance.width": "對話寬度", + "settings.chatAppearance.widthStandard": "標準", + "settings.chatAppearance.widthWide": "寬", + "settings.chatAppearance.widthFull": "填滿可用區域", + "settings.chatAppearance.hint": "字型使用系統已有字型,程式碼保持等寬。寬度同時套用於訊息和輸入框,小視窗會自動適配。", + "settings.chatAppearance.preview": "字型預覽", + "settings.chatAppearance.previewMarkdown": "### 更舒適的對話\n\n調整文字,讓閱讀更舒適。中文・日本語・한국어 · Hello 123。\n\n- 清單跟隨所選字型與字級。\n- 行內程式碼保持等寬:`const answer = 42`。\n\n| 內容 | 範例 |\n| --- | --- |\n| 本文 | 按自己的步調閱讀 |", "chat.sessionNoProject": "無專案", "chat.referenceSessions": "對話", "chat.sessionReferencesLoadFailed": "無法載入對話。", diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index f26636e5..e348bc3b 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -82,6 +82,22 @@ export const zh: Record = { "sideQuestion.send": "提问", "sideQuestion.commandDescription": "不打断主任务,单独提问", + "settings.chatAppearance.title": "聊天外观", + "settings.chatAppearance.description": "独立于界面缩放调整聊天文字。修改立即生效,并保存在当前设备。", + "settings.chatAppearance.reset": "恢复聊天外观默认值", + "settings.chatAppearance.font": "聊天字体", + "settings.chatAppearance.fontSystem": "跟随系统", + "settings.chatAppearance.fontSans": "无衬线", + "settings.chatAppearance.fontSerif": "衬线", + "settings.chatAppearance.fontMono": "等宽", + "settings.chatAppearance.fontSize": "聊天字号", + "settings.chatAppearance.width": "对话宽度", + "settings.chatAppearance.widthStandard": "标准", + "settings.chatAppearance.widthWide": "宽", + "settings.chatAppearance.widthFull": "铺满可用区域", + "settings.chatAppearance.hint": "字体使用系统已有字体,代码保持等宽。宽度同时应用于消息和输入框,小窗口会自动适配。", + "settings.chatAppearance.preview": "字体预览", + "settings.chatAppearance.previewMarkdown": "### 更舒适的对话\n\n调整文字,让阅读更舒适。中文・日本語・한국어 · Hello 123。\n\n- 列表跟随所选字体与字号。\n- 行内代码保持等宽:`const answer = 42`。\n\n| 内容 | 示例 |\n| --- | --- |\n| 正文 | 按自己的节奏阅读 |", "chat.sessionNoProject": "无项目", "chat.referenceSessions": "会话", "chat.sessionReferencesLoadFailed": "无法加载会话。", diff --git a/desktop/src/lib/chatAppearance.test.ts b/desktop/src/lib/chatAppearance.test.ts new file mode 100644 index 00000000..1f586fba --- /dev/null +++ b/desktop/src/lib/chatAppearance.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { CHAT_APPEARANCE_STORAGE_KEY, DEFAULT_CHAT_APPEARANCE, getChatAppearanceStyle, migrateChatAppearance, normalizeChatAppearance, persistChatAppearance, readChatAppearance } from './chatAppearance' + +describe('chat appearance preferences', () => { + beforeEach(() => localStorage.clear()) + + it.each([null, [], 'serif', { font: '__proto__', width: 'constructor', fontSize: NaN }])('rejects malformed preference values: %j', (value) => { + expect(normalizeChatAppearance(value)).toEqual(DEFAULT_CHAT_APPEARANCE) + }) + + it('bounds and rounds sizes and never interpolates arbitrary CSS', () => { + expect(normalizeChatAppearance({ fontSize: 100 }).fontSize).toBe(24) + expect(normalizeChatAppearance({ fontSize: 1 }).fontSize).toBe(12) + expect(normalizeChatAppearance({ fontSize: 16.6 }).fontSize).toBe(17) + expect(getChatAppearanceStyle({ font: 'serif', fontSize: 20, width: 'wide' })).toMatchObject({ + '--chat-font-size': '20px', '--chat-content-max-width': '1200px', + }) + const font = getChatAppearanceStyle(DEFAULT_CHAT_APPEARANCE)['--chat-font-family'] + expect(font).toContain('PingFang SC') + expect(font).toContain('Microsoft YaHei UI') + expect(font).not.toContain('SimSun') + }) + + it('round trips preferences while preserving unknown fields', () => { + localStorage.setItem(CHAT_APPEARANCE_STORAGE_KEY, JSON.stringify({ version: 1, extra: 'keep' })) + persistChatAppearance({ font: 'serif', fontSize: 18, width: 'full' }) + expect(readChatAppearance()).toEqual({ font: 'serif', fontSize: 18, width: 'full' }) + expect(JSON.parse(localStorage.getItem(CHAT_APPEARANCE_STORAGE_KEY)!)).toHaveProperty('extra', 'keep') + }) + + it('preserves future schemas through reads, migrations and user changes', () => { + const raw = JSON.stringify({ version: 2, font: 'future-font', custom: [1, 2] }) + localStorage.setItem(CHAT_APPEARANCE_STORAGE_KEY, raw) + expect(readChatAppearance()).toEqual(DEFAULT_CHAT_APPEARANCE) + expect(migrateChatAppearance(localStorage)).toBe(false) + persistChatAppearance({ font: 'mono', fontSize: 24, width: 'full' }) + expect(localStorage.getItem(CHAT_APPEARANCE_STORAGE_KEY)).toBe(raw) + }) + + it('falls back safely when storage is corrupt or blocked', () => { + localStorage.setItem(CHAT_APPEARANCE_STORAGE_KEY, '{bad') + expect(readChatAppearance()).toEqual(DEFAULT_CHAT_APPEARANCE) + const blocked = { getItem: () => { throw new Error('blocked') }, setItem: () => { throw new Error('blocked') } } + expect(readChatAppearance(blocked)).toEqual(DEFAULT_CHAT_APPEARANCE) + expect(() => persistChatAppearance(DEFAULT_CHAT_APPEARANCE, blocked)).not.toThrow() + }) +}) diff --git a/desktop/src/lib/chatAppearance.ts b/desktop/src/lib/chatAppearance.ts new file mode 100644 index 00000000..46404751 --- /dev/null +++ b/desktop/src/lib/chatAppearance.ts @@ -0,0 +1,94 @@ +export const CHAT_APPEARANCE_STORAGE_KEY = 'cc-haha-chat-appearance' +export const CHAT_APPEARANCE_VERSION = 1 + +export type ChatAppearance = { + font: 'system' | 'sans' | 'serif' | 'mono' + fontSize: number + width: 'standard' | 'wide' | 'full' +} + +export const DEFAULT_CHAT_APPEARANCE: ChatAppearance = { font: 'system', fontSize: 14, width: 'standard' } +export type AppearanceStorage = Pick + +const FONT_STACKS: Record = { + system: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif', + sans: 'Arial, "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif', + serif: 'Georgia, "Songti SC", "Source Han Serif SC", "Noto Serif CJK SC", SimSun, serif', + mono: '"JetBrains Mono", ui-monospace, Menlo, Consolas, "PingFang SC", "Microsoft YaHei", monospace', +} +const WIDTHS = { standard: '900px', wide: '1200px', full: '100%' } + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function normalizeChatAppearance(value: unknown): ChatAppearance { + const record = isRecord(value) ? value : {} + return { + font: record.font === 'sans' || record.font === 'serif' || record.font === 'mono' ? record.font : 'system', + fontSize: typeof record.fontSize === 'number' && Number.isFinite(record.fontSize) + ? Math.round(Math.min(24, Math.max(12, record.fontSize))) : 14, + width: record.width === 'wide' || record.width === 'full' ? record.width : 'standard', + } +} + +export function getAppearanceStorage(): AppearanceStorage | null { + try { return globalThis.localStorage ?? null } catch { return null } +} + +function parseStored(raw: string | null): Record | null { + if (!raw) return null + try { + const value: unknown = JSON.parse(raw) + return isRecord(value) ? value : null + } catch { return null } +} + +function isFutureVersion(value: Record | null): boolean { + return value?.version !== undefined && value.version !== CHAT_APPEARANCE_VERSION +} + +export function readChatAppearance(storage: AppearanceStorage | null = getAppearanceStorage()): ChatAppearance { + try { + const value = parseStored(storage?.getItem(CHAT_APPEARANCE_STORAGE_KEY) ?? null) + return normalizeChatAppearance(isFutureVersion(value) ? null : value) + } catch { return { ...DEFAULT_CHAT_APPEARANCE } } +} + +/** Upgrade the pre-feature fixture (missing key) without touching other UI preferences. + * Unversioned data is normalized; unknown versions are left intact for newer builds. + */ +export function migrateChatAppearance(storage: AppearanceStorage): boolean { + const raw = storage.getItem(CHAT_APPEARANCE_STORAGE_KEY) + const value = parseStored(raw) + if (isFutureVersion(value)) return false + const next = JSON.stringify({ ...value, version: CHAT_APPEARANCE_VERSION, ...normalizeChatAppearance(value) }) + if (raw === next) return false + storage.setItem(CHAT_APPEARANCE_STORAGE_KEY, next) + return true +} + +export function persistChatAppearance(appearance: ChatAppearance, storage: AppearanceStorage | null = getAppearanceStorage()): void { + if (!storage) return + try { + const value = parseStored(storage.getItem(CHAT_APPEARANCE_STORAGE_KEY)) + if (isFutureVersion(value)) return + storage.setItem(CHAT_APPEARANCE_STORAGE_KEY, JSON.stringify({ ...value, version: CHAT_APPEARANCE_VERSION, ...normalizeChatAppearance(appearance) })) + } catch { /* Keep controls usable when storage is unavailable. */ } +} + +export function getChatAppearanceStyle(appearance: ChatAppearance): Record { + const normalized = normalizeChatAppearance(appearance) + return { + '--chat-font-family': FONT_STACKS[normalized.font], + '--chat-font-size': `${normalized.fontSize}px`, + '--chat-content-max-width': WIDTHS[normalized.width], + } +} + +export function applyChatAppearance(appearance: ChatAppearance): void { + if (typeof document === 'undefined') return + for (const [name, value] of Object.entries(getChatAppearanceStyle(appearance))) { + document.documentElement.style.setProperty(name, value) + } +} diff --git a/desktop/src/lib/persistenceMigrations.test.ts b/desktop/src/lib/persistenceMigrations.test.ts index 70ae9874..77ea284f 100644 --- a/desktop/src/lib/persistenceMigrations.test.ts +++ b/desktop/src/lib/persistenceMigrations.test.ts @@ -5,12 +5,36 @@ import { runDesktopPersistenceMigrations, } from './persistenceMigrations' import { WORKSPACE_STORAGE_VERSION } from './workspace/storageKey' +import { CHAT_APPEARANCE_STORAGE_KEY, DEFAULT_CHAT_APPEARANCE } from './chatAppearance' describe('desktop persistence migrations', () => { beforeEach(() => { window.localStorage.clear() }) + test('upgrades a frozen schema-4 install with reading defaults without changing zoom or theme', () => { + localStorage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, '4') + localStorage.setItem('cc-haha-app-zoom', '1.25') + localStorage.setItem('cc-haha-theme', 'ink-blue') + localStorage.setItem('unrelated-user-key', 'keep') + runDesktopPersistenceMigrations() + expect(JSON.parse(localStorage.getItem(CHAT_APPEARANCE_STORAGE_KEY)!)).toEqual({ version: 1, ...DEFAULT_CHAT_APPEARANCE }) + expect(localStorage.getItem('cc-haha-app-zoom')).toBe('1.25') + expect(localStorage.getItem('cc-haha-theme')).toBe('ink-blue') + expect(localStorage.getItem('unrelated-user-key')).toBe('keep') + expect(runDesktopPersistenceMigrations().migratedKeys).not.toContain(CHAT_APPEARANCE_STORAGE_KEY) + }) + + test('normalizes unversioned reading preferences and preserves a future schema', () => { + localStorage.setItem(CHAT_APPEARANCE_STORAGE_KEY, JSON.stringify({ font: 'serif', fontSize: 80, extra: true })) + runDesktopPersistenceMigrations() + expect(JSON.parse(localStorage.getItem(CHAT_APPEARANCE_STORAGE_KEY)!)).toEqual({ version: 1, font: 'serif', fontSize: 24, width: 'standard', extra: true }) + const future = '{"version":2,"font":"future","extra":true}' + localStorage.setItem(CHAT_APPEARANCE_STORAGE_KEY, future) + runDesktopPersistenceMigrations() + expect(localStorage.getItem(CHAT_APPEARANCE_STORAGE_KEY)).toBe(future) + }) + test('migrates legacy open-tab arrays into the current tab persistence shape', () => { window.localStorage.setItem('cc-haha-open-tabs', JSON.stringify([ { sessionId: 'session-1', title: 'Old tab' }, diff --git a/desktop/src/lib/persistenceMigrations.ts b/desktop/src/lib/persistenceMigrations.ts index 0dd89859..0d15002e 100644 --- a/desktop/src/lib/persistenceMigrations.ts +++ b/desktop/src/lib/persistenceMigrations.ts @@ -10,7 +10,9 @@ import { normalizeAppZoomLevel, } from './appZoom' -export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 4 +import { CHAT_APPEARANCE_STORAGE_KEY, migrateChatAppearance } from './chatAppearance' + +export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 5 export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion' type DesktopMigrationReport = { @@ -326,6 +328,9 @@ export function runDesktopPersistenceMigrations(storage: StorageLike | null = ge runMigrationStep(report, LOCALE_STORAGE_KEY, () => normalizeEnumKey(storage, LOCALE_STORAGE_KEY, SUPPORTED_LOCALES, report)) runMigrationStep(report, APP_ZOOM_STORAGE_KEY, () => normalizeAppZoomKey(storage, report)) runMigrationStep(report, WORKSPACE_STORAGE_KEY, () => migrateWorkspaceState(storage, report)) + runMigrationStep(report, CHAT_APPEARANCE_STORAGE_KEY, () => { + if (migrateChatAppearance(storage)) report.migratedKeys.push(CHAT_APPEARANCE_STORAGE_KEY) + }) try { storage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION)) } catch { diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 514709e3..f578ab3e 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -8,6 +8,7 @@ import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations' import { initWorkspacePersistence } from './lib/workspace/persistenceBridge' import { getDesktopHost } from './lib/desktopHost' import { initializeLocale } from './i18n/locale' +import { initializeChatAppearance } from './stores/chatAppearanceStore' declare global { interface Window { @@ -91,6 +92,7 @@ export async function bootstrapDesktopApp( } runDesktopPersistenceMigrations() +initializeChatAppearance() // Strictly after the migrations: the hydrator trusts the shape the migration // step just normalized rather than re-validating a possibly future schema. if (isPrimaryWorkspaceWindow()) initWorkspacePersistence() diff --git a/desktop/src/pages/SubagentRunPage.test.tsx b/desktop/src/pages/SubagentRunPage.test.tsx index 0bd4ff0b..4100b72d 100644 --- a/desktop/src/pages/SubagentRunPage.test.tsx +++ b/desktop/src/pages/SubagentRunPage.test.tsx @@ -134,7 +134,7 @@ function expectSharedSessionSurface(agentRunKind: 'subagent' | 'team-member') { agentRunKind, ) expect(screen.getByTestId('agent-run-conversation-column')).toHaveClass('min-w-[360px]') - expect(screen.getByTestId('session-header').firstElementChild).toHaveClass('max-w-[900px]') + expect(screen.getByTestId('session-header').firstElementChild).toHaveClass('max-w-[var(--chat-content-max-width)]') } describe('SubagentRunPage', () => { diff --git a/desktop/src/pages/settings/ChatAppearanceSettings.test.tsx b/desktop/src/pages/settings/ChatAppearanceSettings.test.tsx new file mode 100644 index 00000000..cfcefa17 --- /dev/null +++ b/desktop/src/pages/settings/ChatAppearanceSettings.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useSettingsStore } from '@/stores/settingsStore' +import { useChatAppearanceStore } from '@/stores/chatAppearanceStore' +import { CHAT_APPEARANCE_STORAGE_KEY, DEFAULT_CHAT_APPEARANCE } from '@/lib/chatAppearance' +import { ChatAppearanceSettings } from './ChatAppearanceSettings' + +describe('Chat appearance settings', () => { + beforeEach(() => { + localStorage.clear() + useSettingsStore.setState({ locale: 'en' }) + useChatAppearanceStore.setState({ appearance: { ...DEFAULT_CHAT_APPEARANCE } }) + }) + + afterEach(() => { + cleanup() + localStorage.clear() + useChatAppearanceStore.setState(useChatAppearanceStore.getInitialState(), true) + useSettingsStore.setState(useSettingsStore.getInitialState(), true) + for (const property of ['--chat-font-family', '--chat-font-size', '--chat-content-max-width']) { + document.documentElement.style.removeProperty(property) + } + }) + + it('updates real Markdown preview and saves independent controls immediately', () => { + render() + const preview = screen.getByRole('region', { name: 'Font preview' }) + expect(preview.querySelector('.chat-reading-markdown')).toBeInTheDocument() + expect(preview.querySelector('h3')).toHaveTextContent('A clearer conversation') + expect(preview.querySelector('table')).toBeInTheDocument() + expect(preview.querySelector('code')).toHaveTextContent('const answer = 42') + + fireEvent.change(screen.getByRole('combobox', { name: 'Chat font' }), { target: { value: 'serif' } }) + fireEvent.change(screen.getByRole('slider', { name: 'Chat font size' }), { target: { value: '24' } }) + fireEvent.change(screen.getByRole('combobox', { name: 'Conversation width' }), { target: { value: 'full' } }) + + expect(useChatAppearanceStore.getState().appearance).toEqual({ font: 'serif', fontSize: 24, width: 'full' }) + expect(preview.style.getPropertyValue('--chat-font-family')).toContain('Songti SC') + expect(preview.style.getPropertyValue('--chat-font-size')).toBe('24px') + expect(document.documentElement.style.getPropertyValue('--chat-content-max-width')).toBe('100%') + expect(JSON.parse(localStorage.getItem(CHAT_APPEARANCE_STORAGE_KEY)!)).toMatchObject({ font: 'serif', fontSize: 24, width: 'full' }) + expect(useSettingsStore.getState().uiZoom).toBe(useSettingsStore.getInitialState().uiZoom) + }) + + it('restores all controls, saved values and preview together', () => { + useChatAppearanceStore.getState().setAppearance({ font: 'mono', fontSize: 12, width: 'wide' }) + render() + expect(screen.getByRole('slider')).toHaveAttribute('min', '12') + expect(screen.getByRole('slider')).toHaveAttribute('max', '24') + expect(screen.getByRole('slider')).toHaveAttribute('step', '1') + + fireEvent.click(screen.getByRole('button', { name: 'Reset chat appearance' })) + + expect(screen.getByRole('combobox', { name: 'Chat font' })).toHaveValue('system') + expect(screen.getByRole('slider')).toHaveValue('14') + expect(screen.getByRole('combobox', { name: 'Conversation width' })).toHaveValue('standard') + expect(screen.getByRole('region').style.getPropertyValue('--chat-font-size')).toBe('14px') + expect(JSON.parse(localStorage.getItem(CHAT_APPEARANCE_STORAGE_KEY)!)).toMatchObject(DEFAULT_CHAT_APPEARANCE) + }) +}) diff --git a/desktop/src/pages/settings/ChatAppearanceSettings.tsx b/desktop/src/pages/settings/ChatAppearanceSettings.tsx new file mode 100644 index 00000000..ba6c46f0 --- /dev/null +++ b/desktop/src/pages/settings/ChatAppearanceSettings.tsx @@ -0,0 +1,84 @@ +import { useId } from 'react' +import { Button } from '@/components/ui/Button' +import { Card } from '@/components/ui/Card' +import { SelectField } from '@/components/ui/SelectField' +import { SettingsSection } from '@/components/settings/SettingsSection' +import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer' +import { useTranslation } from '@/i18n' +import { getChatAppearanceStyle } from '@/lib/chatAppearance' +import { useChatAppearanceStore } from '@/stores/chatAppearanceStore' + +export function ChatAppearanceSettings() { + const t = useTranslation() + const { appearance, setAppearance, resetAppearance } = useChatAppearanceStore() + const sizeId = useId() + const previewId = useId() + + return ( + + {t('settings.chatAppearance.reset')} + + )} + > + +
+ setAppearance({ font })} + options={[ + { value: 'system', label: t('settings.chatAppearance.fontSystem') }, + { value: 'sans', label: t('settings.chatAppearance.fontSans') }, + { value: 'serif', label: t('settings.chatAppearance.fontSerif') }, + { value: 'mono', label: t('settings.chatAppearance.fontMono') }, + ]} + /> + setAppearance({ width })} + options={[ + { value: 'standard', label: t('settings.chatAppearance.widthStandard') }, + { value: 'wide', label: t('settings.chatAppearance.widthWide') }, + { value: 'full', label: t('settings.chatAppearance.widthFull') }, + ]} + /> +
+
+
+ + {appearance.fontSize}px +
+ setAppearance({ fontSize: Number(event.currentTarget.value) })} + className="w-full rounded-[var(--radius-sm)] accent-[var(--color-brand)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--color-border-focus)]" + /> +
+

{t('settings.chatAppearance.hint')}

+
+

{t('settings.chatAppearance.preview')}

+
+ +
+
+
+
+ ) +} diff --git a/desktop/src/pages/settings/GeneralSettings.tsx b/desktop/src/pages/settings/GeneralSettings.tsx index 6de06a8d..33c4419d 100644 --- a/desktop/src/pages/settings/GeneralSettings.tsx +++ b/desktop/src/pages/settings/GeneralSettings.tsx @@ -35,6 +35,7 @@ import { SETTINGS_CHECKBOX_INPUT_CLASS, SettingsCheckboxMark, isValidHttpProxyUr import { isTouchH5Document } from '../../lib/touchH5' import { MODEL_REASONING_EFFORTS } from '../../../../src/shared/modelReasoning' import { AUTO_QUESTION_TIMEOUT_OPTIONS } from '../../../../src/shared/autoQuestionSettings' +import { ChatAppearanceSettings } from './ChatAppearanceSettings' /** * The General settings panel — the largest of the seven, and the one most often @@ -1306,6 +1307,8 @@ export function GeneralSettings() { {uiZoomSection} + +

{t('settings.general.networkTitle')}

{t('settings.general.networkDescription')}

diff --git a/desktop/src/stores/chatAppearanceStore.test.ts b/desktop/src/stores/chatAppearanceStore.test.ts new file mode 100644 index 00000000..976910e7 --- /dev/null +++ b/desktop/src/stores/chatAppearanceStore.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CHAT_APPEARANCE_STORAGE_KEY, DEFAULT_CHAT_APPEARANCE } from '@/lib/chatAppearance' +import { initializeChatAppearance, useChatAppearanceStore } from './chatAppearanceStore' + +describe('chat appearance lifecycle', () => { + let dispose: (() => void) | undefined + beforeEach(() => { + localStorage.clear() + useChatAppearanceStore.setState({ appearance: { ...DEFAULT_CHAT_APPEARANCE } }) + }) + afterEach(() => { dispose?.(); vi.restoreAllMocks() }) + + it('restores preferences before rendering and resets only reading preferences', () => { + localStorage.setItem('cc-haha-app-zoom', '1.25') + localStorage.setItem(CHAT_APPEARANCE_STORAGE_KEY, JSON.stringify({ version: 1, font: 'mono', fontSize: 20, width: 'wide' })) + dispose = initializeChatAppearance() + expect(document.documentElement.style.getPropertyValue('--chat-font-size')).toBe('20px') + expect(useChatAppearanceStore.getState().appearance.width).toBe('wide') + useChatAppearanceStore.getState().setAppearance({ fontSize: 24 }) + expect(useChatAppearanceStore.getState().appearance.font).toBe('mono') + expect(JSON.parse(localStorage.getItem(CHAT_APPEARANCE_STORAGE_KEY)!).fontSize).toBe(24) + useChatAppearanceStore.getState().resetAppearance() + expect(useChatAppearanceStore.getState().appearance).toEqual(DEFAULT_CHAT_APPEARANCE) + expect(localStorage.getItem('cc-haha-app-zoom')).toBe('1.25') + }) + + it('updates other windows without echoing writes and handles key removal', () => { + dispose = initializeChatAppearance() + localStorage.setItem(CHAT_APPEARANCE_STORAGE_KEY, JSON.stringify({ version: 1, fontSize: 19, width: 'full' })) + const write = vi.spyOn(Storage.prototype, 'setItem') + window.dispatchEvent(new StorageEvent('storage', { key: CHAT_APPEARANCE_STORAGE_KEY, storageArea: localStorage })) + expect(useChatAppearanceStore.getState().appearance.fontSize).toBe(19) + expect(document.documentElement.style.getPropertyValue('--chat-content-max-width')).toBe('100%') + expect(write).not.toHaveBeenCalled() + localStorage.removeItem(CHAT_APPEARANCE_STORAGE_KEY) + window.dispatchEvent(new StorageEvent('storage', { key: null, storageArea: localStorage })) + expect(useChatAppearanceStore.getState().appearance).toEqual(DEFAULT_CHAT_APPEARANCE) + }) +}) diff --git a/desktop/src/stores/chatAppearanceStore.ts b/desktop/src/stores/chatAppearanceStore.ts new file mode 100644 index 00000000..ac6e293d --- /dev/null +++ b/desktop/src/stores/chatAppearanceStore.ts @@ -0,0 +1,45 @@ +import { create } from 'zustand' +import { + applyChatAppearance, + CHAT_APPEARANCE_STORAGE_KEY, + DEFAULT_CHAT_APPEARANCE, + getAppearanceStorage, + normalizeChatAppearance, + persistChatAppearance, + readChatAppearance, + type ChatAppearance, +} from '@/lib/chatAppearance' + +type ChatAppearanceState = { + appearance: ChatAppearance + setAppearance: (patch: Partial) => void + resetAppearance: () => void +} + +export const useChatAppearanceStore = create((set, get) => ({ + appearance: readChatAppearance(), + setAppearance: (patch) => { + const appearance = normalizeChatAppearance({ ...get().appearance, ...patch }) + persistChatAppearance(appearance) + applyChatAppearance(appearance) + set({ appearance }) + }, + resetAppearance: () => get().setAppearance(DEFAULT_CHAT_APPEARANCE), +})) + +/** Called after persistence migrations, before the app renders. */ +export function initializeChatAppearance(): () => void { + const refresh = () => { + const appearance = readChatAppearance() + applyChatAppearance(appearance) + useChatAppearanceStore.setState({ appearance }) + } + refresh() + if (typeof window === 'undefined') return () => {} + const onStorage = (event: StorageEvent) => { + if (event.storageArea !== null && event.storageArea !== getAppearanceStorage()) return + if (event.key === CHAT_APPEARANCE_STORAGE_KEY || event.key === null) refresh() + } + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) +} diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 801a94df..7eecf419 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -2076,6 +2076,44 @@ body.sidebar-resizing * { border-spacing: 0; } +/* Explicit conversation scope: unlayered rules override Tailwind prose-sm and + document typography without resizing tool controls or non-chat Markdown. */ +:root { + --chat-font-family: var(--font-body); + --chat-font-size: 14px; + --chat-content-max-width: 900px; +} + +.chat-reading-text, +.markdown-prose.chat-reading-markdown { + font-family: var(--chat-font-family); + font-size: var(--chat-font-size); + line-height: 1.625; +} + +.markdown-prose.chat-reading-markdown { + --code-viewer-font-size: calc(var(--chat-font-size) * 0.928571); +} + +.markdown-prose.chat-reading-markdown :is(p, li, table, blockquote) { + font-size: 1em; + line-height: 1.625; +} + +.markdown-prose.chat-reading-markdown :is(h1, h2, h3, h4, h5, h6) { + font-family: var(--chat-font-family); + line-height: 1.35; +} + +.markdown-prose.chat-reading-markdown h1 { font-size: 1.75em; } +.markdown-prose.chat-reading-markdown h2 { font-size: 1.5em; } +.markdown-prose.chat-reading-markdown h3 { font-size: 1.25em; } +.markdown-prose.chat-reading-markdown :is(h4, h5, h6) { font-size: 1em; } +.markdown-prose.chat-reading-markdown code { + font-family: var(--font-mono); + font-size: var(--code-viewer-font-size); +} + .markdown-prose { --tw-prose-body: var(--color-text-primary); --tw-prose-headings: var(--color-text-primary); @@ -2419,6 +2457,11 @@ html[data-touch-h5] select { font-size: 16px; } +/* A small desktop reading preference must not trigger iOS focus zoom. */ +html[data-touch-h5] .composer-pm.chat-reading-text { + font-size: max(16px, var(--chat-font-size)); +} + /* Long-press text selection on iOS WebKit fights content-visibility: auto — extending a selection into a skipped row forces synchronous layout, which makes the selection handles jump or drops the selection entirely. The paint