mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
merge: integrate remote main with local development
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<SessionRestoreResult> {
|
||||
return await restoreStoredSessionBinding({
|
||||
chatId,
|
||||
bridge: this.bridge,
|
||||
@@ -515,8 +515,12 @@ export class ImChatRuntime {
|
||||
}
|
||||
|
||||
private async ensureSession(chatId: string): Promise<boolean> {
|
||||
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<string> {
|
||||
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
|
||||
|
||||
@@ -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<SessionEntry | null> {
|
||||
}: RestoreStoredSessionBindingOptions): Promise<SessionRestoreResult> {
|
||||
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 }
|
||||
}
|
||||
|
||||
+18
-12
@@ -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<SessionRestoreResult> {
|
||||
return await restoreStoredSessionBinding({
|
||||
chatId,
|
||||
bridge,
|
||||
@@ -261,8 +261,10 @@ async function ensureExistingSession(chatId: string): Promise<{ sessionId: strin
|
||||
}
|
||||
|
||||
async function buildStatusText(chatId: string): Promise<string> {
|
||||
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<string> {
|
||||
}
|
||||
|
||||
async function ensureSession(chatId: string): Promise<boolean> {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+18
-12
@@ -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<SessionRestoreResult> {
|
||||
return await restoreStoredSessionBinding({
|
||||
chatId,
|
||||
bridge,
|
||||
@@ -233,8 +233,10 @@ async function ensureExistingSession(chatId: string): Promise<{ sessionId: strin
|
||||
}
|
||||
|
||||
async function buildStatusText(chatId: string): Promise<string> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
|
||||
@@ -131,7 +131,7 @@ function createController(overrides?: Record<string, unknown>) {
|
||||
},
|
||||
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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<SessionRestoreResult>
|
||||
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<SessionRestoreResult>
|
||||
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<void> => {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+18
-12
@@ -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<SessionRestoreResult> {
|
||||
return await restoreStoredSessionBinding({
|
||||
chatId,
|
||||
bridge,
|
||||
@@ -163,8 +163,10 @@ async function ensureExistingSession(chatId: string): Promise<{ sessionId: strin
|
||||
}
|
||||
|
||||
async function buildStatusText(chatId: string): Promise<string> {
|
||||
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<string> {
|
||||
// ---------- session management ----------
|
||||
|
||||
async function ensureSession(chatId: string): Promise<boolean> {
|
||||
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)
|
||||
|
||||
+18
-12
@@ -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>): void {
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> {
|
||||
async function ensureExistingSession(chatId: string): Promise<SessionRestoreResult> {
|
||||
return await restoreStoredSessionBinding({
|
||||
chatId,
|
||||
bridge,
|
||||
@@ -232,8 +232,10 @@ async function ensureExistingSession(chatId: string): Promise<{ sessionId: strin
|
||||
}
|
||||
|
||||
async function buildStatusText(chatId: string): Promise<string> {
|
||||
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<string> {
|
||||
}
|
||||
|
||||
async function ensureSession(chatId: string): Promise<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
|
||||
+18
-12
@@ -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<SessionRestoreResult> {
|
||||
return await restoreStoredSessionBinding({
|
||||
chatId,
|
||||
bridge,
|
||||
@@ -179,8 +179,10 @@ async function ensureExistingSession(chatId: string): Promise<{ sessionId: strin
|
||||
}
|
||||
|
||||
async function buildStatusText(chatId: string): Promise<string> {
|
||||
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<string> {
|
||||
}
|
||||
|
||||
async function ensureSession(chatId: string): Promise<boolean> {
|
||||
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)
|
||||
|
||||
@@ -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)]"
|
||||
>
|
||||
<MarkdownRenderer
|
||||
className="chat-reading-markdown"
|
||||
content={content}
|
||||
variant={documentLayout ? 'document' : 'default'}
|
||||
streaming={isStreaming}
|
||||
|
||||
@@ -384,6 +384,7 @@ describe('ChatInput file mentions', () => {
|
||||
render(<ChatInput />)
|
||||
|
||||
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(<ChatInput compact={compact} />)
|
||||
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 () => {
|
||||
|
||||
@@ -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)]'}`
|
||||
}
|
||||
>
|
||||
<div
|
||||
@@ -1472,7 +1469,7 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
|
||||
// break-word` does not shrink it. Without this the flex item
|
||||
// refuses to shrink and the whole editor paints past the panel.
|
||||
className="flex-1 min-w-0"
|
||||
editorClassName="max-h-[200px] overflow-y-auto py-2 leading-relaxed text-[var(--color-text-primary)]"
|
||||
editorClassName="chat-reading-text max-h-[200px] overflow-y-auto py-2 leading-relaxed text-[var(--color-text-primary)]"
|
||||
aria={{
|
||||
role: isSlashMenuVisible || isReferenceMenuVisible ? 'combobox' : 'textbox',
|
||||
'aria-autocomplete': isSlashMenuVisible || isReferenceMenuVisible ? 'list' : undefined,
|
||||
@@ -1501,7 +1498,7 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
|
||||
onCompositionEnd={() => { 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={{
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-code-viewer-chrome={chrome}
|
||||
style={{ fontFamily: 'var(--font-body)' }}
|
||||
className={
|
||||
isEmbedded
|
||||
? 'overflow-hidden bg-[var(--color-code-bg)]'
|
||||
|
||||
@@ -30,6 +30,8 @@ import { useChatStore } from '../../stores/chatStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { useWorkspaceStore } from '../../stores/workspaceStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { initializeChatAppearance, useChatAppearanceStore } from '../../stores/chatAppearanceStore'
|
||||
import { CHAT_APPEARANCE_STORAGE_KEY } from '../../lib/chatAppearance'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
@@ -589,6 +591,52 @@ describe('MessageList nested tool calls', () => {
|
||||
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(<MessageList sessionId={sessionId} />)
|
||||
const scroller = container.querySelector<HTMLElement>('.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<number, FrameRequestCallback>()
|
||||
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<HTMLElement>('.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()
|
||||
})
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
const messageListRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContentRef = useRef<HTMLDivElement>(null)
|
||||
const virtualItemHeightsRef = useRef<Map<string, number>>(
|
||||
resolvedSessionId ? getHeightsForSession(resolvedSessionId) : new Map<string, number>(),
|
||||
const [initialVirtualItemHeights] = useState(() =>
|
||||
resolvedSessionId ? getHeightsForSession(resolvedSessionId, appearanceSignature) : new Map<string, number>(),
|
||||
)
|
||||
const virtualItemHeightsRef = useRef(initialVirtualItemHeights)
|
||||
const virtualItemMetricCacheRef = useRef<Map<string, VirtualRenderItemMetric>>(
|
||||
resolvedSessionId ? getMetricsForSession(resolvedSessionId) : new Map<string, VirtualRenderItemMetric>(),
|
||||
)
|
||||
@@ -2663,7 +2670,7 @@ export function MessageList({
|
||||
lastSessionIdRef.current = resolvedSessionId
|
||||
setProgrammaticNavigationItemId(null)
|
||||
virtualItemHeightsRef.current = resolvedSessionId
|
||||
? getHeightsForSession(resolvedSessionId)
|
||||
? getHeightsForSession(resolvedSessionId, appearanceSignature)
|
||||
: new Map<string, number>()
|
||||
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<HTMLElement>('[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 && (
|
||||
<div className="mx-auto mb-5 w-full max-w-[900px] rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-4 py-3 text-xs text-[var(--color-on-error-container)]">
|
||||
<div className="mx-auto mb-5 w-full max-w-[var(--chat-content-max-width)] rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-4 py-3 text-xs text-[var(--color-on-error-container)]">
|
||||
{turnChangeLoadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ export function SessionChatHeader({
|
||||
compact ? 'px-4 py-2.5' : 'px-9 py-3',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="mx-auto w-full min-w-0 max-w-[900px]">
|
||||
<div className="mx-auto w-full min-w-0 max-w-[var(--chat-content-max-width)]">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{leading}
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -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(<ThinkingBlock content={content} disclosureKey="think-1" />)
|
||||
expect(second.container.querySelector('[data-thinking-content="expanded"]')).not.toBeNull()
|
||||
|
||||
@@ -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 && <span className="thinking-cursor" />}
|
||||
</div>
|
||||
|
||||
@@ -124,13 +124,13 @@ export const UserMessage = memo(function UserMessage({
|
||||
{hasText && (
|
||||
<div
|
||||
data-message-body="teammate"
|
||||
className="min-w-0 max-w-full rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-[16px] py-[12px] text-[14px] leading-relaxed text-[var(--color-text-primary)]"
|
||||
className="min-w-0 max-w-full rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-[16px] py-[12px] chat-reading-text leading-relaxed text-[var(--color-text-primary)]"
|
||||
style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
|
||||
>
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
onLinkClick={sessionId ? handleLinkClick : undefined}
|
||||
className="[&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_h4]:text-sm"
|
||||
className="chat-reading-markdown [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_h4]:text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -169,7 +169,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
{hasText && (
|
||||
<div
|
||||
data-message-body="user"
|
||||
className="min-w-0 max-w-full rounded-[var(--radius-lg)] bg-[var(--color-surface-user-msg)] px-[18px] py-[13px] text-[14.5px] leading-relaxed text-[var(--color-text-primary)] whitespace-pre-wrap break-words"
|
||||
className="min-w-0 max-w-full rounded-[var(--radius-lg)] bg-[var(--color-surface-user-msg)] px-[18px] py-[13px] chat-reading-text leading-relaxed text-[var(--color-text-primary)] whitespace-pre-wrap break-words"
|
||||
style={{
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
|
||||
@@ -26,6 +26,17 @@ describe('virtualHeightCache', () => {
|
||||
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)
|
||||
|
||||
@@ -12,6 +12,7 @@ const MAX_TRACKED_SESSIONS = 16
|
||||
|
||||
const sessionHeightCache = new Map<string, Map<string, number>>()
|
||||
const sessionMetricCache = new Map<string, Map<string, VirtualRenderItemMetric>>()
|
||||
const sessionAppearanceSignatures = new Map<string, string>()
|
||||
|
||||
function touchSession(sessionId: string, map: Map<string, Map<string, unknown>>) {
|
||||
// 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<string, number> {
|
||||
export function getHeightsForSession(sessionId: string, appearanceSignature?: string): Map<string, number> {
|
||||
let heights = sessionHeightCache.get(sessionId)
|
||||
if (!heights) {
|
||||
heights = new Map<string, number>()
|
||||
@@ -40,6 +42,11 @@ export function getHeightsForSession(sessionId: string): Map<string, number> {
|
||||
} else {
|
||||
touchSession(sessionId, sessionHeightCache as Map<string, Map<string, unknown>>)
|
||||
}
|
||||
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<string, VirtualRend
|
||||
export function dropSession(sessionId: string): void {
|
||||
sessionHeightCache.delete(sessionId)
|
||||
sessionMetricCache.delete(sessionId)
|
||||
sessionAppearanceSignatures.delete(sessionId)
|
||||
}
|
||||
|
||||
export const __virtualHeightCacheInternals = {
|
||||
@@ -64,5 +72,6 @@ export const __virtualHeightCacheInternals = {
|
||||
reset: () => {
|
||||
sessionHeightCache.clear()
|
||||
sessionMetricCache.clear()
|
||||
sessionAppearanceSignatures.clear()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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(<>
|
||||
<AssistantMessage content={content} />
|
||||
<MarkdownRenderer content={content} variant="document" />
|
||||
<UserMessage content="My prompt" />
|
||||
</>)
|
||||
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<HTMLElement>('[data-code-viewer-content]')
|
||||
expect(code?.style.fontSize).toBe('var(--code-viewer-font-size, 13px)')
|
||||
expect(code?.style.fontFamily).toBe('var(--font-mono)')
|
||||
})
|
||||
})
|
||||
@@ -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.",
|
||||
|
||||
@@ -82,6 +82,22 @@ export const jp: Record<TranslationKey, string> = {
|
||||
"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": "セッションを読み込めませんでした。",
|
||||
|
||||
@@ -82,6 +82,22 @@ export const kr: Record<TranslationKey, string> = {
|
||||
"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": "프로젝트 없음",
|
||||
|
||||
@@ -82,6 +82,22 @@ export const zh: Record<TranslationKey, string> = {
|
||||
"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": "無法載入對話。",
|
||||
|
||||
@@ -82,6 +82,22 @@ export const zh: Record<TranslationKey, string> = {
|
||||
"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": "无法加载会话。",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<Storage, 'getItem' | 'setItem'>
|
||||
|
||||
const FONT_STACKS: Record<ChatAppearance['font'], string> = {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw)
|
||||
return isRecord(value) ? value : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function isFutureVersion(value: Record<string, unknown> | 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<string, string> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(<ChatAppearanceSettings />)
|
||||
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(<ChatAppearanceSettings />)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<SettingsSection
|
||||
className="mt-8"
|
||||
title={t('settings.chatAppearance.title')}
|
||||
description={t('settings.chatAppearance.description')}
|
||||
action={(
|
||||
<Button size="sm" variant="secondary" onClick={resetAppearance}>
|
||||
{t('settings.chatAppearance.reset')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<Card radius="xl" surface="low" padding="none" className="space-y-4 p-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<SelectField
|
||||
label={t('settings.chatAppearance.font')}
|
||||
value={appearance.font}
|
||||
onChange={(font) => 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') },
|
||||
]}
|
||||
/>
|
||||
<SelectField
|
||||
label={t('settings.chatAppearance.width')}
|
||||
value={appearance.width}
|
||||
onChange={(width) => setAppearance({ width })}
|
||||
options={[
|
||||
{ value: 'standard', label: t('settings.chatAppearance.widthStandard') },
|
||||
{ value: 'wide', label: t('settings.chatAppearance.widthWide') },
|
||||
{ value: 'full', label: t('settings.chatAppearance.widthFull') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-sm text-[var(--color-text-primary)]">
|
||||
<label htmlFor={sizeId}>{t('settings.chatAppearance.fontSize')}</label>
|
||||
<output htmlFor={sizeId}>{appearance.fontSize}px</output>
|
||||
</div>
|
||||
<input
|
||||
id={sizeId}
|
||||
type="range"
|
||||
min={12}
|
||||
max={24}
|
||||
step={1}
|
||||
value={appearance.fontSize}
|
||||
aria-valuetext={`${appearance.fontSize}px`}
|
||||
onChange={(event) => 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)]"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">{t('settings.chatAppearance.hint')}</p>
|
||||
<div>
|
||||
<p id={previewId} className="mb-2 text-xs font-medium text-[var(--color-text-secondary)]">{t('settings.chatAppearance.preview')}</p>
|
||||
<div
|
||||
role="region"
|
||||
aria-labelledby={previewId}
|
||||
className="min-w-0 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-4"
|
||||
style={getChatAppearanceStyle(appearance)}
|
||||
>
|
||||
<MarkdownRenderer className="chat-reading-markdown" content={t('settings.chatAppearance.previewMarkdown')} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
<ChatAppearanceSettings />
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-[16.5px] font-semibold leading-tight text-[var(--color-text-primary)] mb-1" style={{ fontFamily: 'var(--font-headline)' }}>{t('settings.general.networkTitle')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.networkDescription')}</p>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<ChatAppearance>) => void
|
||||
resetAppearance: () => void
|
||||
}
|
||||
|
||||
export const useChatAppearanceStore = create<ChatAppearanceState>((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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user