From 7973036fc9aaf8f1b449729258e3caac3f476c88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E5=B0=8F=E9=9F=B5?= Date: Thu, 14 May 2026 14:59:17 +0800 Subject: [PATCH 01/36] =?UTF-8?q?fix(sidebar):=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E5=AD=90=E4=BC=9A=E8=AF=9D=E7=A5=96=E5=85=88?= =?UTF-8?q?=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在活动会话树中注入仅由后代活动触发的祖先节点,避免深层子会话脱离父级结构。 - 覆盖祖先注入、同级排序、自身活动优先和循环保护场景,确保树结构可稳定回归验证。 --- .../chat/sidebar/activeSessionTree.test.ts | 228 ++++++++++++++++-- .../chat/sidebar/activeSessionTree.ts | 147 ++++++++++- 2 files changed, 341 insertions(+), 34 deletions(-) diff --git a/src/features/chat/sidebar/activeSessionTree.test.ts b/src/features/chat/sidebar/activeSessionTree.test.ts index 1015033f..2a6079a5 100644 --- a/src/features/chat/sidebar/activeSessionTree.test.ts +++ b/src/features/chat/sidebar/activeSessionTree.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from 'vitest' import type { ActiveSessionEntry } from '../../../store/activeSessionStore' import { buildActiveSessionTree } from './activeSessionTree' +type DisplayEntry = { + sessionId: string + activitySource: 'self' | 'descendant' +} + function makeEntry(sessionId: string): ActiveSessionEntry { return { sessionId, @@ -9,33 +14,214 @@ function makeEntry(sessionId: string): ActiveSessionEntry { } } +function displayEntry(sessionId: string, activitySource: 'self' | 'descendant'): DisplayEntry { + return { sessionId, activitySource } +} + +function mapTree(tree: ReturnType) { + return { + rootEntries: tree.rootEntries.map(entry => displayEntry(entry.sessionId, entry.activitySource)), + childrenByParent: new Map( + Array.from(tree.childrenByParent.entries(), ([parentId, entries]) => [ + parentId, + entries.map(entry => displayEntry(entry.sessionId, entry.activitySource)), + ]), + ), + } +} + +function noResolvedEntry() { + return undefined +} + describe('buildActiveSessionTree', () => { - it('nests active children under an active parent', () => { - const root = makeEntry('root') - const child = makeEntry('child') - const grandchild = makeEntry('grandchild') + it('nests an active child under an active parent with self activity sources', () => { + const root = makeEntry('root-1') + const child = makeEntry('parent-1') + + const tree = mapTree( + buildActiveSessionTree( + [root, child], + sessionId => { + if (sessionId === 'parent-1') return 'root-1' + return undefined + }, + noResolvedEntry, + ), + ) + + expect(tree.rootEntries).toEqual([displayEntry('root-1', 'self')]) + expect(tree.childrenByParent).toEqual(new Map([['root-1', [displayEntry('parent-1', 'self')]]])) + }) + + it('injects inactive ancestors to preserve a deep active chain', () => { + const root = makeEntry('root-1') + const leaf = makeEntry('leaf-1') + + const tree = mapTree( + buildActiveSessionTree( + [root, leaf], + sessionId => { + if (sessionId === 'leaf-1') return 'child-1' + if (sessionId === 'child-1') return 'parent-1' + if (sessionId === 'parent-1') return 'root-1' + return undefined + }, + noResolvedEntry, + ), + ) + + expect(tree.rootEntries).toEqual([displayEntry('root-1', 'self')]) + expect(tree.childrenByParent).toEqual( + new Map([ + ['root-1', [displayEntry('parent-1', 'descendant')]], + ['parent-1', [displayEntry('child-1', 'descendant')]], + ['child-1', [displayEntry('leaf-1', 'self')]], + ]), + ) + }) + + it('keeps sibling order while injecting a shared inactive ancestor once', () => { + const root = makeEntry('root-1') + const child = makeEntry('child-1') + const sibling = makeEntry('sibling-1') + + const tree = mapTree( + buildActiveSessionTree( + [root, child, sibling], + sessionId => { + if (sessionId === 'child-1' || sessionId === 'sibling-1') return 'parent-1' + if (sessionId === 'parent-1') return 'root-1' + return undefined + }, + noResolvedEntry, + ), + ) + + expect(tree.rootEntries).toEqual([displayEntry('root-1', 'self')]) + expect(tree.childrenByParent).toEqual( + new Map([ + ['root-1', [displayEntry('parent-1', 'descendant')]], + ['parent-1', [displayEntry('child-1', 'self'), displayEntry('sibling-1', 'self')]], + ]), + ) + }) + + it('prefers a self-active parent entry over descendant-only injection', () => { + const root = makeEntry('root-1') + const parent = makeEntry('parent-1') + const child = makeEntry('child-1') + + const tree = mapTree( + buildActiveSessionTree( + [root, parent, child], + sessionId => { + if (sessionId === 'parent-1') return 'root-1' + if (sessionId === 'child-1') return 'parent-1' + return undefined + }, + noResolvedEntry, + ), + ) + + expect(tree.rootEntries).toEqual([displayEntry('root-1', 'self')]) + expect(tree.childrenByParent).toEqual( + new Map([ + ['root-1', [displayEntry('parent-1', 'self')]], + ['parent-1', [displayEntry('child-1', 'self')]], + ]), + ) + }) + + it('replaces a descendant placeholder when a busy parent is encountered after its child', () => { + const root = makeEntry('root-1') + const parent = makeEntry('parent-1') + const child = makeEntry('child-1') + + const tree = mapTree( + buildActiveSessionTree( + [root, child, parent], + sessionId => { + if (sessionId === 'parent-1') return 'root-1' + if (sessionId === 'child-1') return 'parent-1' + return undefined + }, + noResolvedEntry, + ), + ) + + expect(tree.rootEntries).toEqual([displayEntry('root-1', 'self')]) + expect(tree.childrenByParent).toEqual( + new Map([ + ['root-1', [displayEntry('parent-1', 'self')]], + ['parent-1', [displayEntry('child-1', 'self')]], + ]), + ) + }) + + it('keeps active children visible when an ancestor chain cannot be fully resolved', () => { + const root = makeEntry('root-1') + const orphanChild = makeEntry('orphan-child-1') + + const tree = mapTree( + buildActiveSessionTree( + [root, orphanChild], + sessionId => { + if (sessionId === 'orphan-child-1') return 'missing-parent' + return undefined + }, + noResolvedEntry, + ), + ) + + expect(tree.rootEntries).toEqual([displayEntry('root-1', 'self'), displayEntry('missing-parent', 'descendant')]) + expect(tree.childrenByParent).toEqual( + new Map([['missing-parent', [displayEntry('orphan-child-1', 'self')]]]), + ) + }) + + it('breaks parent cycles by falling back to top-level self entries', () => { + const child = makeEntry('child-1') + const leaf = makeEntry('leaf-1') - const tree = buildActiveSessionTree([root, child, grandchild], sessionId => { - if (sessionId === 'child') return 'root' - if (sessionId === 'grandchild') return 'child' - return undefined - }) + const tree = mapTree( + buildActiveSessionTree( + [child, leaf], + sessionId => { + if (sessionId === 'child-1') return 'leaf-1' + if (sessionId === 'leaf-1') return 'child-1' + return undefined + }, + noResolvedEntry, + ), + ) - expect(tree.rootEntries).toEqual([root]) - expect(tree.childrenByParent.get('root')).toEqual([child]) - expect(tree.childrenByParent.get('child')).toEqual([grandchild]) + expect(tree.rootEntries).toEqual([displayEntry('child-1', 'self'), displayEntry('leaf-1', 'self')]) + expect(tree.childrenByParent).toEqual(new Map()) }) - it('promotes an active child to the top level when its parent is not active', () => { - const sibling = makeEntry('sibling') - const childOnly = makeEntry('child-only') + it('does not promote an active child when its inactive parent should be preserved', () => { + const sibling = makeEntry('sibling-1') + const childOnly = makeEntry('child-1') - const tree = buildActiveSessionTree([sibling, childOnly], sessionId => { - if (sessionId === 'child-only') return 'idle-parent' - return undefined - }) + const tree = mapTree( + buildActiveSessionTree( + [sibling, childOnly], + sessionId => { + if (sessionId === 'child-1') return 'parent-1' + if (sessionId === 'parent-1') return 'root-1' + return undefined + }, + noResolvedEntry, + ), + ) - expect(tree.rootEntries).toEqual([sibling, childOnly]) - expect(tree.childrenByParent.size).toBe(0) + expect(tree.rootEntries).toEqual([displayEntry('sibling-1', 'self'), displayEntry('root-1', 'descendant')]) + expect(tree.childrenByParent).toEqual( + new Map([ + ['root-1', [displayEntry('parent-1', 'descendant')]], + ['parent-1', [displayEntry('child-1', 'self')]], + ]), + ) }) }) diff --git a/src/features/chat/sidebar/activeSessionTree.ts b/src/features/chat/sidebar/activeSessionTree.ts index 2b5dd78d..1b3adf19 100644 --- a/src/features/chat/sidebar/activeSessionTree.ts +++ b/src/features/chat/sidebar/activeSessionTree.ts @@ -1,31 +1,152 @@ import type { ActiveSessionEntry } from '../../../store/activeSessionStore' +export type ActiveSessionTreeEntry = + | (ActiveSessionEntry & { activitySource: 'self' }) + | { + sessionId: string + activitySource: 'descendant' + title?: string + directory?: string + } + export interface ActiveSessionTree { - rootEntries: ActiveSessionEntry[] - childrenByParent: Map + rootEntries: ActiveSessionTreeEntry[] + childrenByParent: Map } export function buildActiveSessionTree( busySessions: ActiveSessionEntry[], findParentId: (sessionId: string) => string | undefined, + resolveEntry: (sessionId: string) => { title?: string; directory?: string } | undefined, ): ActiveSessionTree { - const busySessionIds = new Set(busySessions.map(entry => entry.sessionId)) - const rootEntries: ActiveSessionEntry[] = [] - const childrenByParent = new Map() + const displayEntries = new Map() + const rootEntryIds: string[] = [] + const childrenByParentIds = new Map() + const rootEntryIdSet = new Set() + const childEntryIdsByParent = new Map>() - for (const entry of busySessions) { - const parentId = findParentId(entry.sessionId) + const upsertEntry = (entry: ActiveSessionTreeEntry): ActiveSessionTreeEntry => { + const existing = displayEntries.get(entry.sessionId) - if (!parentId || !busySessionIds.has(parentId)) { - rootEntries.push(entry) - continue + if (!existing) { + displayEntries.set(entry.sessionId, entry) + return entry + } + + if (existing.activitySource === 'self') { + return existing + } + + if (entry.activitySource === 'self') { + displayEntries.set(entry.sessionId, entry) + return entry + } + + const mergedEntry: ActiveSessionTreeEntry = { + sessionId: existing.sessionId, + activitySource: 'descendant', + title: existing.title ?? entry.title, + directory: existing.directory ?? entry.directory, + } + + displayEntries.set(entry.sessionId, mergedEntry) + return mergedEntry + } + + const appendRoot = (entryId: string) => { + if (rootEntryIdSet.has(entryId)) { + return } - const siblings = childrenByParent.get(parentId) + rootEntryIdSet.add(entryId) + rootEntryIds.push(entryId) + } + + const appendChild = (parentId: string, childId: string) => { + let childIds = childEntryIdsByParent.get(parentId) + if (!childIds) { + childIds = new Set() + childEntryIdsByParent.set(parentId, childIds) + } + + if (childIds.has(childId)) { + return + } + + childIds.add(childId) + const siblings = childrenByParentIds.get(parentId) if (siblings) { - siblings.push(entry) + siblings.push(childId) } else { - childrenByParent.set(parentId, [entry]) + childrenByParentIds.set(parentId, [childId]) + } + } + + for (const entry of busySessions) { + const selfEntry = upsertEntry({ + ...entry, + activitySource: 'self', + }) + const visitedIds = new Set([entry.sessionId]) + const chain: ActiveSessionTreeEntry[] = [selfEntry] + + let currentId = entry.sessionId + let cycleDetected = false + + while (true) { + const parentId = findParentId(currentId) + + if (!parentId) { + break + } + + if (visitedIds.has(parentId)) { + cycleDetected = true + break + } + + visitedIds.add(parentId) + + const parentEntry = displayEntries.get(parentId) + const resolvedMeta = resolveEntry(parentId) + const ancestorEntry = upsertEntry( + parentEntry ?? { + sessionId: parentId, + activitySource: 'descendant', + title: resolvedMeta?.title, + directory: resolvedMeta?.directory, + }, + ) + + chain.unshift(ancestorEntry) + currentId = parentId + } + + if (cycleDetected) { + appendRoot(selfEntry.sessionId) + continue + } + + appendRoot(chain[0].sessionId) + + for (let index = 1; index < chain.length; index += 1) { + appendChild(chain[index - 1].sessionId, chain[index].sessionId) + } + } + + const rootEntries = rootEntryIds + .map(sessionId => displayEntries.get(sessionId)) + .filter((entry): entry is ActiveSessionTreeEntry => entry !== undefined) + + const childrenByParent = new Map() + + for (const [parentId, childIds] of childrenByParentIds.entries()) { + const children = childIds + .map(sessionId => displayEntries.get(sessionId)) + .filter((entry): entry is ActiveSessionTreeEntry => entry !== undefined) + + if (children.length > 0) { + childrenByParent.set(parentId, children) } } From bb0c0821c3fb7c488b39b34cc391dc9e39ad0bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E5=B0=8F=E9=9F=B5?= Date: Thu, 14 May 2026 15:00:10 +0800 Subject: [PATCH 02/36] =?UTF-8?q?fix(sidebar):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E4=BC=9A=E8=AF=9D=E7=A5=96=E5=85=88=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保存子会话目录信息,并从活动树后代向上推导祖先会话的拉取目标。 - 让仅由子会话推断出的祖先节点也能被正确获取完整会话数据,避免侧边栏显示缺少标题或目录。 --- .../chat/sidebar/activeSessionTargets.test.ts | 76 +++++++++++++++++++ .../chat/sidebar/activeSessionTargets.ts | 35 +++++++++ src/store/childSessionStore.test.ts | 33 ++++++++ src/store/childSessionStore.ts | 10 ++- 4 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 src/features/chat/sidebar/activeSessionTargets.test.ts create mode 100644 src/features/chat/sidebar/activeSessionTargets.ts create mode 100644 src/store/childSessionStore.test.ts diff --git a/src/features/chat/sidebar/activeSessionTargets.test.ts b/src/features/chat/sidebar/activeSessionTargets.test.ts new file mode 100644 index 00000000..736a04a2 --- /dev/null +++ b/src/features/chat/sidebar/activeSessionTargets.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { buildActiveTreeSessionTargets } from './activeSessionTargets' +import type { ActiveSessionTree } from './activeSessionTree' + +describe('buildActiveTreeSessionTargets', () => { + it('propagates a child directory upward so inferred ancestors become hydratable', () => { + const tree: ActiveSessionTree = { + rootEntries: [ + { + sessionId: 'root-1', + activitySource: 'descendant', + title: 'Root', + }, + ], + childrenByParent: new Map([ + [ + 'root-1', + [ + { + sessionId: 'parent-1', + activitySource: 'descendant', + title: 'Parent', + }, + ], + ], + [ + 'parent-1', + [ + { + sessionId: 'child-1', + activitySource: 'self', + status: { type: 'busy' }, + directory: '/workspace/demo', + }, + ], + ], + ]), + } + + expect(buildActiveTreeSessionTargets(tree)).toEqual([ + { sessionId: 'child-1', directory: '/workspace/demo' }, + { sessionId: 'parent-1', directory: '/workspace/demo' }, + { sessionId: 'root-1', directory: '/workspace/demo' }, + ]) + }) + + it('keeps a known ancestor directory instead of overwriting it from descendants', () => { + const tree: ActiveSessionTree = { + rootEntries: [ + { + sessionId: 'root-1', + activitySource: 'descendant', + directory: '/workspace/root', + }, + ], + childrenByParent: new Map([ + [ + 'root-1', + [ + { + sessionId: 'child-1', + activitySource: 'self', + status: { type: 'busy' }, + directory: '/workspace/child', + }, + ], + ], + ]), + } + + expect(buildActiveTreeSessionTargets(tree)).toEqual([ + { sessionId: 'child-1', directory: '/workspace/child' }, + { sessionId: 'root-1', directory: '/workspace/root' }, + ]) + }) +}) diff --git a/src/features/chat/sidebar/activeSessionTargets.ts b/src/features/chat/sidebar/activeSessionTargets.ts new file mode 100644 index 00000000..5d978ede --- /dev/null +++ b/src/features/chat/sidebar/activeSessionTargets.ts @@ -0,0 +1,35 @@ +import type { ActiveSessionTree, ActiveSessionTreeEntry } from './activeSessionTree' + +export interface ActiveSessionFetchTarget { + sessionId: string + directory: string +} + +export function buildActiveTreeSessionTargets(activeSessionTree: ActiveSessionTree): ActiveSessionFetchTarget[] { + const targets = new Map() + + const visit = (entry: ActiveSessionTreeEntry): string | undefined => { + const children = activeSessionTree.childrenByParent.get(entry.sessionId) ?? [] + + let inheritedDirectory = entry.directory + + for (const child of children) { + const childDirectory = visit(child) + if (!inheritedDirectory && childDirectory) { + inheritedDirectory = childDirectory + } + } + + if (inheritedDirectory) { + targets.set(entry.sessionId, inheritedDirectory) + } + + return inheritedDirectory + } + + for (const rootEntry of activeSessionTree.rootEntries) { + visit(rootEntry) + } + + return Array.from(targets, ([sessionId, directory]) => ({ sessionId, directory })) +} diff --git a/src/store/childSessionStore.test.ts b/src/store/childSessionStore.test.ts new file mode 100644 index 00000000..ad9b8b4c --- /dev/null +++ b/src/store/childSessionStore.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { ApiSession } from '../api/types' +import { childSessionStore } from './childSessionStore' + +function makeSession(overrides: Partial = {}): ApiSession { + return { + id: 'child-1', + parentID: 'parent-1', + title: 'Child Session', + directory: '/workspace/demo', + time: { created: 123 }, + ...overrides, + } as ApiSession +} + +describe('childSessionStore', () => { + afterEach(() => { + childSessionStore.clearAll() + }) + + it('stores directory metadata from registered child sessions', () => { + childSessionStore.registerChildSession(makeSession()) + + expect(childSessionStore.getSessionInfo('child-1')).toMatchObject({ + id: 'child-1', + parentID: 'parent-1', + title: 'Child Session', + directory: '/workspace/demo', + status: 'running', + createdAt: 123, + }) + }) +}) diff --git a/src/store/childSessionStore.ts b/src/store/childSessionStore.ts index d3ec934f..46dca32c 100644 --- a/src/store/childSessionStore.ts +++ b/src/store/childSessionStore.ts @@ -18,6 +18,7 @@ export interface ChildSessionInfo { id: string parentID: string title: string + directory?: string agent?: string // 子 agent 名称 status: 'running' | 'idle' | 'error' createdAt: number @@ -42,11 +43,15 @@ class ChildSessionStore { subscribe(fn: Subscriber): () => void { this.subscribers.add(fn) - return () => this.subscribers.delete(fn) + return () => { + this.subscribers.delete(fn) + } } private notify() { - this.subscribers.forEach(fn => fn()) + this.subscribers.forEach(fn => { + fn() + }) } // ============================================ @@ -72,6 +77,7 @@ class ChildSessionStore { id: session.id, parentID: session.parentID, title: session.title || i18n.t('chat:permissionDialog.subtaskFallback'), + directory: session.directory, status: 'running', createdAt: session.time.created, }) From ad8cf1822077b712b31401912a316fb6a085b45c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E5=B0=8F=E9=9F=B5?= Date: Thu, 14 May 2026 15:09:16 +0800 Subject: [PATCH 03/36] =?UTF-8?q?fix(sidebar):=20=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=8F=AF=E9=80=89=E4=B8=AD=E7=9A=84=E6=B4=BB=E5=8A=A8=E7=A5=96?= =?UTF-8?q?=E5=85=88=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将活动会话树接入侧边栏渲染,并为后代活动的祖先行提供中性状态展示。 - 在已有元数据足够时允许祖先行被点击选择,避免用户无法打开仍有活跃子会话的祖先会话。 --- .../chat/sidebar/ActiveSessionItem.test.tsx | 127 ++++++++++++++++ .../chat/sidebar/ActiveSessionItem.tsx | 46 ++++-- src/features/chat/sidebar/SidePanel.tsx | 143 ++++++++++++------ 3 files changed, 258 insertions(+), 58 deletions(-) create mode 100644 src/features/chat/sidebar/ActiveSessionItem.test.tsx diff --git a/src/features/chat/sidebar/ActiveSessionItem.test.tsx b/src/features/chat/sidebar/ActiveSessionItem.test.tsx new file mode 100644 index 00000000..c5c834e7 --- /dev/null +++ b/src/features/chat/sidebar/ActiveSessionItem.test.tsx @@ -0,0 +1,127 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { ApiSession } from '../../../api' +import type { ActiveSessionTreeEntry } from './activeSessionTree' +import { ActiveSessionItem } from './ActiveSessionItem' + +function createResolvedSession(overrides: Partial = {}): ApiSession { + return { + id: 'session-1', + title: 'Active session title', + directory: '/workspace/project', + ...overrides, + } as ApiSession +} + +function renderItem(entry: ActiveSessionTreeEntry, resolvedSession?: ApiSession) { + const onSelect = vi.fn() + const view = render( + , + ) + + return { + ...view, + onSelect, + } +} + +describe('ActiveSessionItem', () => { + it('shows a neutral descendant-only label without self-active status labels or pulse animation', () => { + const descendantOnlyEntry = { + sessionId: 'session-descendant', + title: 'Ancestor row', + directory: '/workspace/project', + activitySource: 'descendant', + } satisfies ActiveSessionTreeEntry + + const { container } = renderItem(descendantOnlyEntry, createResolvedSession({ id: 'session-descendant', title: 'Ancestor row' })) + + expect(screen.getByText('Ancestor row')).toBeInTheDocument() + expect(screen.getByText('Child session active')).toBeInTheDocument() + expect(screen.queryByText('Working')).not.toBeInTheDocument() + expect(screen.queryByText('Retrying')).not.toBeInTheDocument() + expect(screen.queryByText('Awaiting Permission')).not.toBeInTheDocument() + expect(screen.queryByText('Awaiting Answer')).not.toBeInTheDocument() + expect(container.querySelector('.animate-ping')).not.toBeInTheDocument() + }) + + it('keeps the working label and pulse animation for self-active busy entries', () => { + const busyEntry = { + sessionId: 'session-working', + activitySource: 'self', + status: { type: 'busy' }, + title: 'Working session', + directory: '/workspace/project', + } satisfies ActiveSessionTreeEntry + + const { container } = renderItem(busyEntry, createResolvedSession({ id: 'session-working', title: 'Working session' })) + + expect(screen.getByText('Working session')).toBeInTheDocument() + expect(screen.getByText('Working')).toBeInTheDocument() + expect(container.querySelector('.animate-ping')).toBeInTheDocument() + }) + + it('allows descendant-only rows to be selected without a resolved session when entry metadata is sufficient', () => { + const descendantOnlyEntry = { + sessionId: 'session-descendant', + title: 'Ancestor row', + directory: '/workspace/project', + activitySource: 'descendant', + } satisfies ActiveSessionTreeEntry + + const onSelect = vi.fn() + render() + + const button = screen.getByRole('button', { name: /ancestor row/i }) + expect(button).toBeEnabled() + + fireEvent.click(button) + + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'session-descendant', + title: 'Ancestor row', + directory: '/workspace/project', + }), + ) + }) + + it('keeps the pending permission label for self-active entries waiting on user input', () => { + const pendingEntry = { + sessionId: 'session-permission', + activitySource: 'self', + status: { type: 'busy' }, + title: 'Permission session', + directory: '/workspace/project', + pendingAction: { + type: 'permission', + description: 'write /workspace/project/file.ts', + }, + } satisfies ActiveSessionTreeEntry + + renderItem(pendingEntry, createResolvedSession({ id: 'session-permission', title: 'Permission session' })) + + expect(screen.getByText('Awaiting Permission')).toBeInTheDocument() + expect(screen.getByText('write /workspace/project/file.ts')).toBeInTheDocument() + }) + + it('keeps the retry label and attempt count for self-active retry entries', () => { + const retryEntry = { + sessionId: 'session-retry', + activitySource: 'self', + status: { type: 'retry', attempt: 3, next: Date.now() + 1000, message: 'Temporary failure' }, + title: 'Retry session', + directory: '/workspace/project', + } satisfies ActiveSessionTreeEntry + + renderItem(retryEntry, createResolvedSession({ id: 'session-retry', title: 'Retry session' })) + + expect(screen.getByText('Retrying')).toBeInTheDocument() + expect(screen.getByText('attempt 3')).toBeInTheDocument() + }) +}) diff --git a/src/features/chat/sidebar/ActiveSessionItem.tsx b/src/features/chat/sidebar/ActiveSessionItem.tsx index bd3204e6..6e8bd083 100644 --- a/src/features/chat/sidebar/ActiveSessionItem.tsx +++ b/src/features/chat/sidebar/ActiveSessionItem.tsx @@ -1,9 +1,9 @@ import { useTranslation } from 'react-i18next' -import type { ActiveSessionEntry } from '../../../store/activeSessionStore' import type { ApiSession } from '../../../api' +import type { ActiveSessionTreeEntry } from './activeSessionTree' interface ActiveSessionItemProps { - entry: ActiveSessionEntry + entry: ActiveSessionTreeEntry /** 从 sessions 列表或 API 拉取到的完整 session 对象 */ resolvedSession?: ApiSession isSelected: boolean @@ -12,16 +12,26 @@ interface ActiveSessionItemProps { export function ActiveSessionItem({ entry, resolvedSession, isSelected, onSelect }: ActiveSessionItemProps) { const { t } = useTranslation(['chat', 'common']) - const isRetry = entry.status.type === 'retry' - const pending = entry.pendingAction + const isSelfActive = entry.activitySource === 'self' + const isRetry = isSelfActive && entry.status.type === 'retry' + const pending = isSelfActive ? entry.pendingAction : undefined + const selectableSession = + resolvedSession ?? + (entry.directory + ? ({ + id: entry.sessionId, + title: entry.title ?? entry.sessionId, + directory: entry.directory, + } as ApiSession) + : undefined) // 标题优先从 resolvedSession 取,然后 fallback 到 entry.title(sessionMeta),最后截取 ID const displayTitle = resolvedSession?.title || entry.title || entry.sessionId.slice(0, 12) + '...' // 目录优先从 resolvedSession 取 const directory = resolvedSession?.directory || entry.directory // 状态显示:permission > question > retry > working - const statusConfig = - pending?.type === 'permission' + const statusConfig = isSelfActive + ? pending?.type === 'permission' ? { label: t('activeSession.awaitingPermission'), color: 'text-warning-100', @@ -33,13 +43,17 @@ export function ActiveSessionItem({ entry, resolvedSession, isSelected, onSelect : isRetry ? { label: t('activeSession.retrying'), color: 'text-warning-100', dotColor: 'bg-warning-100', pulse: false } : { label: t('activeSession.working'), color: 'text-success-100', dotColor: 'bg-success-100', pulse: true } + : { + label: t('activeSession.activeBelow'), + color: 'text-text-400', + dotColor: 'bg-text-400', + pulse: false, + } const handleClick = () => { - if (resolvedSession) { - onSelect(resolvedSession) + if (selectableSession) { + onSelect(selectableSession) } - // 如果没有 resolvedSession(极端情况:API 拉取失败),不做任何事 - // 用户可以等 session 数据加载完,或从 Recents tab 找到 } // 拖拽到主信息流进行分屏 / 替换会话 @@ -58,13 +72,15 @@ export function ActiveSessionItem({ entry, resolvedSession, isSelected, onSelect } return ( -
{/* Content */}
@@ -91,7 +107,7 @@ export function ActiveSessionItem({ entry, resolvedSession, isSelected, onSelect {pending.description} )} - {isRetry && entry.status.type === 'retry' && ( + {isSelfActive && isRetry && entry.status.type === 'retry' && ( <> · @@ -109,6 +125,6 @@ export function ActiveSessionItem({ entry, resolvedSession, isSelected, onSelect )}
- + ) } diff --git a/src/features/chat/sidebar/SidePanel.tsx b/src/features/chat/sidebar/SidePanel.tsx index d5b4dfdd..10053350 100644 --- a/src/features/chat/sidebar/SidePanel.tsx +++ b/src/features/chat/sidebar/SidePanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, useEffect, useRef, type ReactNode } from 'react' +import { useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { SessionList } from '../../sessions' import { FolderRecentList } from './FolderRecentList' @@ -7,7 +7,8 @@ import { ConfirmDialog } from '../../../components/ui/ConfirmDialog' import { ActiveSessionItem } from './ActiveSessionItem' import { NotificationItem } from './NotificationItem' import { SidebarFooter } from './SidebarFooter' -import { buildActiveSessionTree } from './activeSessionTree' +import { buildActiveSessionTree, type ActiveSessionTreeEntry } from './activeSessionTree' +import { buildActiveTreeSessionTargets } from './activeSessionTargets' import { getParentPath } from './sidebarUtils' import { SidebarIcon, @@ -75,6 +76,27 @@ interface ProjectItem { sectionKind?: 'project' | 'workspace' } +let childSessionStoreVersion = 0 + +function subscribeToChildSessionStoreVersion(onStoreChange: () => void) { + return childSessionStore.subscribe(() => { + childSessionStoreVersion += 1 + onStoreChange() + }) +} + +function getChildSessionStoreVersion() { + return childSessionStoreVersion +} + +function useChildSessionStoreVersion() { + return useSyncExternalStore( + subscribeToChildSessionStoreVersion, + getChildSessionStoreVersion, + getChildSessionStoreVersion, + ) +} + function getSelectionRange(visibleIds: string[], anchorId: string, targetId: string) { const startIndex = visibleIds.indexOf(anchorId) const endIndex = visibleIds.indexOf(targetId) @@ -261,6 +283,7 @@ export function SidePanel({ // Active sessions const busySessions = useBusySessions() const busyCount = useBusyCount() + const childSessionMetadataVersion = useChildSessionStoreVersion() // Notification history const notifications = useNotifications() const unreadNotificationCount = useUnreadNotificationCount() @@ -291,51 +314,47 @@ export function SidePanel({ return map }, [sessions, fetchedSessions]) - // 异步拉取不在 lookup 中的 active/notification/selected session - useEffect(() => { - const allNeeded = [ - ...busySessions.map(e => ({ sessionId: e.sessionId, directory: e.directory })), - ...notifications.map(e => ({ sessionId: e.sessionId, directory: e.directory })), - ] - if (selectedSessionId && !sessionLookup.has(selectedSessionId)) { - allNeeded.push({ sessionId: selectedSessionId, directory: currentDirectory || '' }) - } - const missing = allNeeded.filter(entry => !sessionLookup.has(entry.sessionId)) - if (missing.length === 0) return - - let cancelled = false - const fetchMissing = async () => { - const results: Record = {} - await Promise.allSettled( - missing.map(async entry => { - try { - const session = await getSession(entry.sessionId, entry.directory) - if (!cancelled) results[session.id] = session - } catch { - /* ignore */ - } - }), - ) - if (!cancelled && Object.keys(results).length > 0) { - setFetchedSessions(prev => ({ ...prev, ...results })) - } - } - fetchMissing() - return () => { - cancelled = true - } - }, [busySessions, notifications, sessionLookup, selectedSessionId, currentDirectory]) - // ---- 子 session 展示数据 ---- const rootSessionIds = useMemo(() => new Set(sessions.map(s => s.id)), [sessions]) + const getChildSessionInfo = useCallback( + (sessionId: string) => { + childSessionMetadataVersion + return childSessionStore.getSessionInfo(sessionId) + }, + [childSessionMetadataVersion], + ) + const findParentId = useCallback( (id: string) => { const s = sessionLookup.get(id) if (s?.parentID) return s.parentID - return childSessionStore.getSessionInfo(id)?.parentID + return getChildSessionInfo(id)?.parentID }, - [sessionLookup], + [sessionLookup, getChildSessionInfo], + ) + + const resolveActiveTreeEntry = useCallback( + (sessionId: string) => { + const resolvedSession = sessionLookup.get(sessionId) + if (resolvedSession) { + return { + title: resolvedSession.title, + directory: resolvedSession.directory, + } + } + + const childSessionInfo = getChildSessionInfo(sessionId) + if (childSessionInfo) { + return { + title: childSessionInfo.title, + directory: childSessionInfo.directory, + } + } + + return undefined + }, + [sessionLookup, getChildSessionInfo], ) // 开关开 → 拉 /children 全量:选中的 root 或选中子 session 时保持其父展开 @@ -387,10 +406,48 @@ export function SidePanel({ ]) const activeSessionTree = useMemo( - () => buildActiveSessionTree(busySessions, findParentId), - [busySessions, findParentId], + () => buildActiveSessionTree(busySessions, findParentId, resolveActiveTreeEntry), + [busySessions, findParentId, resolveActiveTreeEntry], ) + const activeTreeSessionTargets = useMemo(() => buildActiveTreeSessionTargets(activeSessionTree), [activeSessionTree]) + + // 异步拉取不在 lookup 中的 active/notification/selected/active-tree-ancestor session + useEffect(() => { + const allNeeded = [ + ...busySessions.map(e => ({ sessionId: e.sessionId, directory: e.directory })), + ...notifications.map(e => ({ sessionId: e.sessionId, directory: e.directory })), + ...activeTreeSessionTargets, + ] + if (selectedSessionId && !sessionLookup.has(selectedSessionId)) { + allNeeded.push({ sessionId: selectedSessionId, directory: currentDirectory || '' }) + } + const missing = allNeeded.filter(entry => !sessionLookup.has(entry.sessionId)) + if (missing.length === 0) return + + let cancelled = false + const fetchMissing = async () => { + const results: Record = {} + await Promise.allSettled( + missing.map(async entry => { + try { + const session = await getSession(entry.sessionId, entry.directory) + if (!cancelled) results[session.id] = session + } catch { + /* ignore */ + } + }), + ) + if (!cancelled && Object.keys(results).length > 0) { + setFetchedSessions(prev => ({ ...prev, ...results })) + } + } + fetchMissing() + return () => { + cancelled = true + } + }, [busySessions, notifications, activeTreeSessionTargets, sessionLookup, selectedSessionId, currentDirectory]) + const buildProjectGroups = useCallback( (directories: typeof savedDirectories): ProjectItem[] => { const savedNameByPath = new Map( @@ -644,14 +701,14 @@ export function SidePanel({ ) const renderActiveSessionNode = useCallback( - function renderActiveSessionNode(entry: (typeof busySessions)[number], level = 0): ReactNode { + function renderActiveSessionNode(entry: ActiveSessionTreeEntry, level = 0): ReactNode { const resolvedSession = sessionLookup.get(entry.sessionId) const childEntries = activeSessionTree.childrenByParent.get(entry.sessionId) ?? [] return (
0 ? { marginLeft: level * 12 } : undefined}> [0]['entry']} resolvedSession={resolvedSession} isSelected={entry.sessionId === selectedSessionId} onSelect={handleSelectActive} From 86b8e466ee7475c3b7f25d65dbd89a56b49709e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E5=B0=8F=E9=9F=B5?= Date: Thu, 14 May 2026 15:10:24 +0800 Subject: [PATCH 04/36] =?UTF-8?q?fix(sidebar):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E6=8C=89=E9=92=AE=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为侧边栏内部操作按钮显式声明按钮类型,避免默认提交行为影响交互。 - 顺手清理项目移除处理中的回调块结构,让批量操作逻辑更稳定易读。 --- src/features/chat/sidebar/SidePanel.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/features/chat/sidebar/SidePanel.tsx b/src/features/chat/sidebar/SidePanel.tsx index 10053350..ca87d577 100644 --- a/src/features/chat/sidebar/SidePanel.tsx +++ b/src/features/chat/sidebar/SidePanel.tsx @@ -655,7 +655,9 @@ export function SidePanel({ const handleRemoveProject = useCallback( (projectId: string) => { - getProjectDirectoriesToRemove(projectId).forEach(directory => removeDirectory(directory)) + getProjectDirectoriesToRemove(projectId).forEach(directory => { + removeDirectory(directory) + }) }, [getProjectDirectoriesToRemove, removeDirectory], ) @@ -812,7 +814,9 @@ export function SidePanel({ const handleBatchRemoveProjects = useCallback(() => { if (selectedProjectIds.size === 0) return for (const projectId of selectedProjectIds) { - getProjectDirectoriesToRemove(projectId).forEach(directory => removeDirectory(directory)) + getProjectDirectoriesToRemove(projectId).forEach(directory => { + removeDirectory(directory) + }) } setSelectedProjectIds(new Set()) projectSelectionAnchorIdRef.current = null @@ -879,6 +883,7 @@ export function SidePanel({ style={{ justifyContent: showLabels ? 'flex-end' : 'center', paddingRight: showLabels ? 8 : 0 }} > )} ) } diff --git a/src/features/chat/sidebar/SidePanel.tsx b/src/features/chat/sidebar/SidePanel.tsx index 1354638c..165950f4 100644 --- a/src/features/chat/sidebar/SidePanel.tsx +++ b/src/features/chat/sidebar/SidePanel.tsx @@ -7,7 +7,8 @@ import { ConfirmDialog } from '../../../components/ui/ConfirmDialog' import { ActiveSessionItem } from './ActiveSessionItem' import { NotificationItem } from './NotificationItem' import { SidebarFooter } from './SidebarFooter' -import { buildActiveSessionTree } from './activeSessionTree' +import { buildActiveSessionTree, type ActiveSessionTreeEntry } from './activeSessionTree' +import { buildActiveTreeSessionTargets } from './activeSessionTargets' import { getParentPath } from './sidebarUtils' import { SidebarIcon, @@ -296,51 +297,47 @@ export function SidePanel({ return map }, [sessions, fetchedSessions]) - // 异步拉取不在 lookup 中的 active/notification/selected session - useEffect(() => { - const allNeeded = [ - ...busySessions.map(e => ({ sessionId: e.sessionId, directory: e.directory })), - ...notifications.map(e => ({ sessionId: e.sessionId, directory: e.directory })), - ] - if (selectedSessionId && !sessionLookup.has(selectedSessionId)) { - allNeeded.push({ sessionId: selectedSessionId, directory: currentDirectory || '' }) - } - const missing = allNeeded.filter(entry => !sessionLookup.has(entry.sessionId)) - if (missing.length === 0) return - - let cancelled = false - const fetchMissing = async () => { - const results: Record = {} - await Promise.allSettled( - missing.map(async entry => { - try { - const session = await getSession(entry.sessionId, entry.directory) - if (!cancelled) results[session.id] = session - } catch { - /* ignore */ - } - }), - ) - if (!cancelled && Object.keys(results).length > 0) { - setFetchedSessions(prev => ({ ...prev, ...results })) - } - } - fetchMissing() - return () => { - cancelled = true - } - }, [busySessions, notifications, sessionLookup, selectedSessionId, currentDirectory]) - // ---- 子 session 展示数据 ---- const rootSessionIds = useMemo(() => new Set(sessions.map(s => s.id)), [sessions]) + const getChildSessionInfo = useCallback( + (sessionId: string) => { + childSessionVersion + return childSessionStore.getSessionInfo(sessionId) + }, + [childSessionVersion], + ) + const findParentId = useCallback( (id: string) => { const s = sessionLookup.get(id) if (s?.parentID) return s.parentID - return childSessionStore.getSessionInfo(id)?.parentID + return getChildSessionInfo(id)?.parentID }, - [sessionLookup, childSessionVersion], + [sessionLookup, getChildSessionInfo], + ) + + const resolveActiveTreeEntry = useCallback( + (sessionId: string) => { + const resolvedSession = sessionLookup.get(sessionId) + if (resolvedSession) { + return { + title: resolvedSession.title, + directory: resolvedSession.directory, + } + } + + const childSessionInfo = getChildSessionInfo(sessionId) + if (childSessionInfo) { + return { + title: childSessionInfo.title, + directory: childSessionInfo.directory, + } + } + + return undefined + }, + [sessionLookup, getChildSessionInfo], ) // 开关开 → 拉 /children 全量:选中的 root 或选中子 session 时保持其父展开 @@ -392,10 +389,48 @@ export function SidePanel({ ]) const activeSessionTree = useMemo( - () => buildActiveSessionTree(busySessions, findParentId), - [busySessions, findParentId], + () => buildActiveSessionTree(busySessions, findParentId, resolveActiveTreeEntry), + [busySessions, findParentId, resolveActiveTreeEntry], ) + const activeTreeSessionTargets = useMemo(() => buildActiveTreeSessionTargets(activeSessionTree), [activeSessionTree]) + + // 异步拉取不在 lookup 中的 active/notification/selected/active-tree-ancestor session + useEffect(() => { + const allNeeded = [ + ...busySessions.map(e => ({ sessionId: e.sessionId, directory: e.directory })), + ...notifications.map(e => ({ sessionId: e.sessionId, directory: e.directory })), + ...activeTreeSessionTargets, + ] + if (selectedSessionId && !sessionLookup.has(selectedSessionId)) { + allNeeded.push({ sessionId: selectedSessionId, directory: currentDirectory || '' }) + } + const missing = allNeeded.filter(entry => !sessionLookup.has(entry.sessionId)) + if (missing.length === 0) return + + let cancelled = false + const fetchMissing = async () => { + const results: Record = {} + await Promise.allSettled( + missing.map(async entry => { + try { + const session = await getSession(entry.sessionId, entry.directory) + if (!cancelled) results[session.id] = session + } catch { + /* ignore */ + } + }), + ) + if (!cancelled && Object.keys(results).length > 0) { + setFetchedSessions(prev => ({ ...prev, ...results })) + } + } + fetchMissing() + return () => { + cancelled = true + } + }, [busySessions, notifications, activeTreeSessionTargets, sessionLookup, selectedSessionId, currentDirectory]) + const buildProjectGroups = useCallback( (directories: typeof savedDirectories): ProjectItem[] => { const savedNameByPath = new Map( @@ -649,14 +684,14 @@ export function SidePanel({ ) const renderActiveSessionNode = useCallback( - function renderActiveSessionNode(entry: (typeof busySessions)[number], level = 0): ReactNode { + function renderActiveSessionNode(entry: ActiveSessionTreeEntry, level = 0): ReactNode { const resolvedSession = sessionLookup.get(entry.sessionId) const childEntries = activeSessionTree.childrenByParent.get(entry.sessionId) ?? [] return (
0 ? { marginLeft: level * 12 } : undefined}> [0]['entry']} resolvedSession={resolvedSession} isSelected={entry.sessionId === selectedSessionId} onSelect={handleSelectActive} From 605a4d97038b29edb3e07cfc8d3a9f6f8a7483bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E5=B0=8F=E9=9F=B5?= Date: Thu, 14 May 2026 15:10:24 +0800 Subject: [PATCH 11/36] =?UTF-8?q?fix(sidebar):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E6=8C=89=E9=92=AE=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为侧边栏内部操作按钮显式声明按钮类型,避免默认提交行为影响交互。 - 顺手清理项目移除处理中的回调块结构,让批量操作逻辑更稳定易读。 --- src/features/chat/sidebar/SidePanel.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/features/chat/sidebar/SidePanel.tsx b/src/features/chat/sidebar/SidePanel.tsx index 165950f4..41af32cc 100644 --- a/src/features/chat/sidebar/SidePanel.tsx +++ b/src/features/chat/sidebar/SidePanel.tsx @@ -638,7 +638,9 @@ export function SidePanel({ const handleRemoveProject = useCallback( (projectId: string) => { - getProjectDirectoriesToRemove(projectId).forEach(directory => removeDirectory(directory)) + getProjectDirectoriesToRemove(projectId).forEach(directory => { + removeDirectory(directory) + }) }, [getProjectDirectoriesToRemove, removeDirectory], ) @@ -795,7 +797,9 @@ export function SidePanel({ const handleBatchRemoveProjects = useCallback(() => { if (selectedProjectIds.size === 0) return for (const projectId of selectedProjectIds) { - getProjectDirectoriesToRemove(projectId).forEach(directory => removeDirectory(directory)) + getProjectDirectoriesToRemove(projectId).forEach(directory => { + removeDirectory(directory) + }) } setSelectedProjectIds(new Set()) projectSelectionAnchorIdRef.current = null @@ -862,6 +866,7 @@ export function SidePanel({ style={{ justifyContent: showLabels ? 'flex-end' : 'center', paddingRight: showLabels ? 8 : 0 }} > )} + + +
{floatingMenu} From b574d36690a45a98ab7053857da42b5e9f1bda0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E5=B0=8F=E9=9F=B5?= Date: Thu, 21 May 2026 00:55:57 +0800 Subject: [PATCH 25/36] =?UTF-8?q?fix(hooks):=20=E4=BF=9D=E6=8A=A4=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E5=BF=AB=E6=8D=B7=E9=94=AE=E7=9A=84=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在全局快捷键处理里先校验事件目标类型,再访问 DOM 属性与 `closest` - 增加 document 级 keydown 回归测试,避免非 HTMLElement 目标导致快捷键崩溃 --- src/hooks/useKeybindings.test.tsx | 34 +++++++++++++++++++++++++++++++ src/hooks/useKeybindings.ts | 9 +++++--- 2 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useKeybindings.test.tsx diff --git a/src/hooks/useKeybindings.test.tsx b/src/hooks/useKeybindings.test.tsx new file mode 100644 index 00000000..4a25f0e0 --- /dev/null +++ b/src/hooks/useKeybindings.test.tsx @@ -0,0 +1,34 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useGlobalKeybindings } from './useKeybindings' +import { keybindingStore } from '../store/keybindingStore' + +describe('useGlobalKeybindings', () => { + beforeEach(() => { + keybindingStore.resetAll() + }) + + it('opens settings from a document-level keydown target without crashing', () => { + const openSettings = vi.fn() + + renderHook(() => + useGlobalKeybindings({ + openSettings, + }), + ) + + expect(() => { + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: ',', + altKey: true, + bubbles: true, + }), + ) + }) + }).not.toThrow() + + expect(openSettings).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/useKeybindings.ts b/src/hooks/useKeybindings.ts index 06e2004f..083b1a05 100644 --- a/src/hooks/useKeybindings.ts +++ b/src/hooks/useKeybindings.ts @@ -72,9 +72,12 @@ export function useGlobalKeybindings(handlers: KeybindingHandlers, enabled = tru const handleKeyDown = (e: KeyboardEvent) => { // 忽略在输入框中的快捷键 (除了特定的如 Escape) - const target = e.target as HTMLElement - const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable - if (target.closest('.xterm')) return + const target = e.target + const isInput = + target instanceof HTMLElement && + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) + + if (target instanceof Element && target.closest('.xterm')) return // 检查是否有模态框/对话框/下拉菜单打开 // 如果有,只允许特定的快捷键通过 From 96dc94dfeb3c206d9a5010b7381d4f95d3f37d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E5=B0=8F=E9=9F=B5?= Date: Thu, 21 May 2026 00:57:28 +0800 Subject: [PATCH 26/36] =?UTF-8?q?feat(theme):=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E6=A8=A1=E5=9E=8B=E6=A0=87=E7=AD=BE=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=81=8F=E5=A5=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加 `modelLabelFormat` 主题状态、localStorage 持久化与备份导入支持 - 通过 `useTheme` 暴露读取和更新接口,并补充 themeStore 持久化测试 --- src/hooks/useTheme.ts | 14 +++++++++- src/store/themeStore.test.ts | 54 ++++++++++++++++++++++++++++++++++++ src/store/themeStore.ts | 27 +++++++++++++++++- 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/store/themeStore.test.ts diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index 3a8b1849..97309c33 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -3,7 +3,13 @@ import { flushSync } from 'react-dom' import { THEME_SWITCH_DISABLE_MS } from '../constants' import { themeStore, type ColorMode } from '../store/themeStore' import type { StepFinishDisplay, CustomCSSSnippet } from '../store/themeStore' -import type { ReasoningDisplayMode, DiffStyle, ToolCardStyle, CompletedAtFormat } from '../store/themeStore' +import type { + ReasoningDisplayMode, + DiffStyle, + ToolCardStyle, + CompletedAtFormat, + ModelLabelFormat, +} from '../store/themeStore' // 保持向后兼容的类型别名 export type ThemeMode = ColorMode @@ -158,6 +164,10 @@ export function useTheme() { themeStore.setCompletedAtFormat(format) }, []) + const setModelLabelFormat = useCallback((format: ModelLabelFormat) => { + themeStore.setModelLabelFormat(format) + }, []) + // ---- Reasoning Display Mode ---- const setReasoningDisplayMode = useCallback((mode: ReasoningDisplayMode) => { @@ -250,6 +260,8 @@ export function useTheme() { setStepFinishDisplay, completedAtFormat: state.completedAtFormat, setCompletedAtFormat, + modelLabelFormat: state.modelLabelFormat, + setModelLabelFormat, // 思考内容显示样式 reasoningDisplayMode: state.reasoningDisplayMode, diff --git a/src/store/themeStore.test.ts b/src/store/themeStore.test.ts new file mode 100644 index 00000000..34c1fdb8 --- /dev/null +++ b/src/store/themeStore.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const STORAGE_KEY_MODEL_LABEL_FORMAT = 'model-label-format' + +describe('themeStore modelLabelFormat', () => { + beforeEach(() => { + localStorage.clear() + vi.resetModules() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('defaults modelLabelFormat to code', async () => { + const { themeStore } = await import('./themeStore') + + expect(themeStore.getState().modelLabelFormat).toBe('code') + expect(themeStore.modelLabelFormat).toBe('code') + }) + + it('restores persisted modelLabelFormat name', async () => { + localStorage.setItem(STORAGE_KEY_MODEL_LABEL_FORMAT, 'name') + + const { themeStore } = await import('./themeStore') + + expect(themeStore.getState().modelLabelFormat).toBe('name') + }) + + it('falls back to code when persisted modelLabelFormat is invalid', async () => { + localStorage.setItem(STORAGE_KEY_MODEL_LABEL_FORMAT, 'invalid-value') + + const { themeStore } = await import('./themeStore') + + expect(themeStore.getState().modelLabelFormat).toBe('code') + }) + + it('persists and emits when setting modelLabelFormat', async () => { + const { themeStore } = await import('./themeStore') + const listener = vi.fn() + const unsubscribe = themeStore.subscribe(listener) + + themeStore.setModelLabelFormat('name') + + expect(themeStore.getState().modelLabelFormat).toBe('name') + expect(localStorage.getItem(STORAGE_KEY_MODEL_LABEL_FORMAT)).toBe('name') + expect(listener).toHaveBeenCalledTimes(1) + + themeStore.setModelLabelFormat('name') + + expect(listener).toHaveBeenCalledTimes(1) + unsubscribe() + }) +}) diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index e51e5b77..0d251583 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -76,6 +76,8 @@ export interface StepFinishDisplay { export type CompletedAtFormat = 'time' | 'dateTime' +export type ModelLabelFormat = 'code' | 'name' + export type ReasoningDisplayMode = 'capsule' | 'italic' | 'markdown' /** @@ -105,6 +107,7 @@ const DEFAULT_STEP_FINISH_DISPLAY: StepFinishDisplay = { } const DEFAULT_COMPLETED_AT_FORMAT: CompletedAtFormat = 'time' +const DEFAULT_MODEL_LABEL_FORMAT: ModelLabelFormat = 'code' const DEFAULT_REASONING_DISPLAY_MODE: ReasoningDisplayMode = 'capsule' const DEFAULT_DIFF_STYLE: DiffStyle = 'markers' @@ -140,6 +143,8 @@ export interface ThemeState { stepFinishDisplay: StepFinishDisplay /** 完成时刻显示格式 */ completedAtFormat: CompletedAtFormat + /** 模型标签显示格式 */ + modelLabelFormat: ModelLabelFormat /** 思考内容展示样式 */ reasoningDisplayMode: ReasoningDisplayMode /** 宽模式 */ @@ -184,6 +189,7 @@ const STORAGE_KEY_ACTIVE_CUSTOM_CSS_SNIPPET_ID = 'theme-active-custom-css-snippe const STORAGE_KEY_COLLAPSE_USER_MESSAGES = 'collapse-user-messages' const STORAGE_KEY_STEP_FINISH_DISPLAY = 'step-finish-display' const STORAGE_KEY_COMPLETED_AT_FORMAT = 'completed-at-format' +const STORAGE_KEY_MODEL_LABEL_FORMAT = 'model-label-format' const STORAGE_KEY_REASONING_DISPLAY_MODE = 'reasoning-display-mode' const STORAGE_KEY_WIDE_MODE = 'chat-wide-mode' const STORAGE_KEY_DIFF_STYLE = 'diff-style' @@ -266,6 +272,10 @@ class ThemeStore { const completedAtFormat: CompletedAtFormat = savedCompletedAtFormat === 'dateTime' ? 'dateTime' : DEFAULT_COMPLETED_AT_FORMAT + const savedModelLabelFormat = localStorage.getItem(STORAGE_KEY_MODEL_LABEL_FORMAT) + const modelLabelFormat: ModelLabelFormat = + savedModelLabelFormat === 'name' ? 'name' : DEFAULT_MODEL_LABEL_FORMAT + const savedWideMode = localStorage.getItem(STORAGE_KEY_WIDE_MODE) === 'true' const savedDiffStyle = localStorage.getItem(STORAGE_KEY_DIFF_STYLE) as DiffStyle | null const diffStyle: DiffStyle = savedDiffStyle === 'changeBars' ? 'changeBars' : DEFAULT_DIFF_STYLE @@ -323,6 +333,7 @@ class ThemeStore { collapseUserMessages, stepFinishDisplay, completedAtFormat, + modelLabelFormat, reasoningDisplayMode, wideMode: savedWideMode, diffStyle, @@ -370,6 +381,9 @@ class ThemeStore { get completedAtFormat() { return this.state.completedAtFormat } + get modelLabelFormat() { + return this.state.modelLabelFormat + } get reasoningDisplayMode() { return this.state.reasoningDisplayMode } @@ -553,6 +567,13 @@ class ThemeStore { this.emit() } + setModelLabelFormat(format: ModelLabelFormat) { + if (this.state.modelLabelFormat === format) return + this.state = { ...this.state, modelLabelFormat: format } + localStorage.setItem(STORAGE_KEY_MODEL_LABEL_FORMAT, format) + this.emit() + } + setReasoningDisplayMode(mode: ReasoningDisplayMode) { if (this.state.reasoningDisplayMode === mode) return this.state = { ...this.state, reasoningDisplayMode: mode } @@ -848,7 +869,9 @@ class ThemeStore { } private emit() { - this.listeners.forEach(fn => fn()) + this.listeners.forEach(fn => { + fn() + }) } } @@ -879,6 +902,7 @@ function normalizeThemeBackup(raw: unknown): ThemeBackup { ? { ...DEFAULT_STEP_FINISH_DISPLAY, ...(parsed.stepFinishDisplay as Partial) } : DEFAULT_STEP_FINISH_DISPLAY, completedAtFormat: parsed?.completedAtFormat === 'dateTime' ? 'dateTime' : DEFAULT_COMPLETED_AT_FORMAT, + modelLabelFormat: parsed?.modelLabelFormat === 'name' ? 'name' : DEFAULT_MODEL_LABEL_FORMAT, reasoningDisplayMode: parsed?.reasoningDisplayMode === 'italic' || parsed?.reasoningDisplayMode === 'markdown' ? parsed.reasoningDisplayMode @@ -938,6 +962,7 @@ export function importThemeBackup(raw: unknown): void { localStorage.setItem(STORAGE_KEY_COLLAPSE_USER_MESSAGES, String(backup.collapseUserMessages)) localStorage.setItem(STORAGE_KEY_STEP_FINISH_DISPLAY, JSON.stringify(backup.stepFinishDisplay)) localStorage.setItem(STORAGE_KEY_COMPLETED_AT_FORMAT, backup.completedAtFormat) + localStorage.setItem(STORAGE_KEY_MODEL_LABEL_FORMAT, backup.modelLabelFormat) localStorage.setItem(STORAGE_KEY_REASONING_DISPLAY_MODE, backup.reasoningDisplayMode) localStorage.setItem(STORAGE_KEY_WIDE_MODE, String(backup.wideMode)) localStorage.setItem(STORAGE_KEY_DIFF_STYLE, backup.diffStyle) From 2425dc317fd7e4b8de60f6bdf239fe040a2f8ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E5=B0=8F=E9=9F=B5?= Date: Thu, 21 May 2026 00:59:15 +0800 Subject: [PATCH 27/36] =?UTF-8?q?feat(settings):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=A0=87=E7=AD=BE=E6=A0=BC=E5=BC=8F=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 step finish 的模型信息设置下新增模型代码/模型名称显示格式切换 - 补充中英文设置文案和组件测试,确保设置项只在启用模型信息时展示 --- .../settings/components/ChatSettings.test.tsx | 172 ++++++++++++++++++ .../settings/components/ChatSettings.tsx | 54 ++++-- src/locales/en/settings.json | 6 +- src/locales/zh-CN/settings.json | 6 +- 4 files changed, 219 insertions(+), 19 deletions(-) create mode 100644 src/features/settings/components/ChatSettings.test.tsx diff --git a/src/features/settings/components/ChatSettings.test.tsx b/src/features/settings/components/ChatSettings.test.tsx new file mode 100644 index 00000000..abd82090 --- /dev/null +++ b/src/features/settings/components/ChatSettings.test.tsx @@ -0,0 +1,172 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChatSettings } from './ChatSettings' + +const { + useTranslationMock, + usePathModeMock, + useIsMobileMock, + setModelLabelFormatMock, + setStepFinishDisplayMock, + setCompletedAtFormatMock, + setCollapseUserMessagesMock, + setReasoningDisplayModeMock, +} = vi.hoisted(() => ({ + useTranslationMock: vi.fn(), + usePathModeMock: vi.fn(), + useIsMobileMock: vi.fn(), + setModelLabelFormatMock: vi.fn(), + setStepFinishDisplayMock: vi.fn(), + setCompletedAtFormatMock: vi.fn(), + setCollapseUserMessagesMock: vi.fn(), + setReasoningDisplayModeMock: vi.fn(), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: useTranslationMock, +})) + +vi.mock('../../../hooks', () => ({ + usePathMode: usePathModeMock, + useIsMobile: useIsMobileMock, +})) + +vi.mock('../../../store/themeStore', () => ({ + themeStore: { + get stepFinishDisplay() { + return stepFinishDisplayValue + }, + get completedAtFormat() { + return 'time' + }, + get modelLabelFormat() { + return modelLabelFormatValue + }, + get collapseUserMessages() { + return true + }, + get reasoningDisplayMode() { + return 'capsule' as const + }, + setStepFinishDisplay: setStepFinishDisplayMock, + setCompletedAtFormat: setCompletedAtFormatMock, + setModelLabelFormat: setModelLabelFormatMock, + setCollapseUserMessages: setCollapseUserMessagesMock, + setReasoningDisplayMode: setReasoningDisplayModeMock, + }, +})) + +let stepFinishDisplayValue = { + agent: false, + model: false, + tokens: true, + cache: true, + cost: true, + duration: true, + turnDuration: true, + completedAt: false, +} + +let modelLabelFormatValue: 'code' | 'name' = 'code' + +describe('ChatSettings', () => { + beforeEach(() => { + useTranslationMock.mockReturnValue({ + t: (key: string) => key, + }) + + usePathModeMock.mockReturnValue({ + pathMode: 'auto' as const, + setPathMode: vi.fn(), + effectiveStyle: 'unix' as const, + detectedStyle: null, + isAutoMode: true, + }) + + useIsMobileMock.mockReturnValue(false) + + stepFinishDisplayValue = { + agent: false, + model: false, + tokens: true, + cache: true, + cost: true, + duration: true, + turnDuration: true, + completedAt: false, + } + + modelLabelFormatValue = 'code' + + setModelLabelFormatMock.mockReset() + setStepFinishDisplayMock.mockReset() + setCompletedAtFormatMock.mockReset() + setCollapseUserMessagesMock.mockReset() + setReasoningDisplayModeMock.mockReset() + }) + + it('hides the model label format control when stepFinishDisplay.model is false', () => { + render() + + expect(screen.queryByRole('tab', { name: 'chat.modelLabelCode' })).not.toBeInTheDocument() + expect(screen.queryByRole('tab', { name: 'chat.modelLabelName' })).not.toBeInTheDocument() + }) + + it('shows the model label format control when stepFinishDisplay.model is true', () => { + stepFinishDisplayValue = { + ...stepFinishDisplayValue, + model: true, + } + + render() + + expect(screen.getByRole('tab', { name: 'chat.modelLabelCode' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'chat.modelLabelName' })).toBeInTheDocument() + }) + + it('reflects the selected model label format and updates both state and store on interaction', () => { + stepFinishDisplayValue = { + ...stepFinishDisplayValue, + model: true, + } + + render() + + const codeTab = screen.getByRole('tab', { name: 'chat.modelLabelCode' }) + const nameTab = screen.getByRole('tab', { name: 'chat.modelLabelName' }) + + expect(codeTab).toHaveAttribute('aria-selected', 'true') + expect(nameTab).toHaveAttribute('aria-selected', 'false') + + fireEvent.click(nameTab) + + expect(setModelLabelFormatMock).toHaveBeenCalledWith('name') + + fireEvent.click(codeTab) + + expect(setModelLabelFormatMock).toHaveBeenCalledWith('code') + }) + + it('shows model label format directly below the Model row and before completed-at format when both toggles are on', () => { + stepFinishDisplayValue = { + ...stepFinishDisplayValue, + model: true, + completedAt: true, + } + + render() + + const section = screen.getByRole('heading', { name: 'chat.stepFinishInfo' }).closest('section') + expect(section).not.toBeNull() + + const html = section!.innerHTML + const modelDescIndex = html.indexOf('chat.showModel') + const modelLabelFormatIndex = html.indexOf('chat.modelLabelFormat') + const tokensDescIndex = html.indexOf('chat.showTokenUsage') + const completedAtFormatIndex = html.indexOf('chat.completedAtFormat') + + expect(modelLabelFormatIndex).toBeGreaterThan(modelDescIndex) + expect(modelLabelFormatIndex).toBeLessThan(tokensDescIndex) + expect(completedAtFormatIndex).toBeGreaterThan(modelLabelFormatIndex) + }) +}) diff --git a/src/features/settings/components/ChatSettings.tsx b/src/features/settings/components/ChatSettings.tsx index 57a86774..57a8d8d8 100644 --- a/src/features/settings/components/ChatSettings.tsx +++ b/src/features/settings/components/ChatSettings.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react' +import { Fragment, useState } from 'react' import { useTranslation } from 'react-i18next' import { PathAutoIcon, PathUnixIcon, PathWindowsIcon } from '../../../components/Icons' import { usePathMode, useIsMobile } from '../../../hooks' -import { themeStore, type ReasoningDisplayMode, type CompletedAtFormat } from '../../../store/themeStore' +import { themeStore, type ReasoningDisplayMode, type CompletedAtFormat, type ModelLabelFormat } from '../../../store/themeStore' import { Toggle, SegmentedControl, SettingRow, SettingsSection } from './SettingsUI' import type { PathMode } from '../../../utils/directoryUtils' @@ -12,6 +12,7 @@ export function ChatSettings() { const [collapseUserMessages, setCollapseUserMessages] = useState(themeStore.collapseUserMessages) const [stepFinishDisplay, setStepFinishDisplay] = useState(themeStore.stepFinishDisplay) const [completedAtFormat, setCompletedAtFormat] = useState(themeStore.completedAtFormat) + const [modelLabelFormat, setModelLabelFormat] = useState(themeStore.modelLabelFormat) const [reasoningDisplayMode, setReasoningDisplayMode] = useState(themeStore.reasoningDisplayMode) const isMobile = useIsMobile() void isMobile @@ -92,25 +93,44 @@ export function ChatSettings() { { key: 'completedAt', label: t('chat.completedAt'), desc: t('chat.showCompletedAt') }, ] as const ).map(({ key, label, desc }) => ( - { - const next = { [key]: !stepFinishDisplay[key] } - setStepFinishDisplay(prev => ({ ...prev, ...next })) - themeStore.setStepFinishDisplay(next) - }} - > - { + + { const next = { [key]: !stepFinishDisplay[key] } setStepFinishDisplay(prev => ({ ...prev, ...next })) themeStore.setStepFinishDisplay(next) }} - /> - + > + { + const next = { [key]: !stepFinishDisplay[key] } + setStepFinishDisplay(prev => ({ ...prev, ...next })) + themeStore.setStepFinishDisplay(next) + }} + /> + + {key === 'model' && stepFinishDisplay.model && ( +
+

{t('chat.modelLabelFormat')}

+

{t('chat.modelLabelFormatDesc')}

+ { + const next = v as ModelLabelFormat + setModelLabelFormat(next) + themeStore.setModelLabelFormat(next) + }} + /> +
+ )} + ))} {stepFinishDisplay.completedAt && ( diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 7b28e63e..0fe4707e 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -102,7 +102,11 @@ "agent": "Agent", "showAgent": "Show agent name", "model": "Model", - "showModel": "Show model name", + "showModel": "Show model info", + "modelLabelFormat": "Model label format", + "modelLabelFormatDesc": "Choose whether step finish info shows the model code or model name", + "modelLabelCode": "Model Code", + "modelLabelName": "Model Name", "tokens": "Tokens", "showTokenUsage": "Show token usage", "cache": "Cache", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index 67c79873..28e22a49 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -102,7 +102,11 @@ "agent": "Agent", "showAgent": "显示 Agent 名称", "model": "Model", - "showModel": "显示模型名称", + "showModel": "显示模型信息", + "modelLabelFormat": "Model 名称格式", + "modelLabelFormatDesc": "选择显示模型代码还是模型名称", + "modelLabelCode": "Model 代码", + "modelLabelName": "Model 名称", "tokens": "Token", "showTokenUsage": "显示 Token 用量", "cache": "Cache", From 851feed8587d670ddd67b7f69fb89b3d34aaebe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E5=B0=8F=E9=9F=B5?= Date: Thu, 21 May 2026 01:00:18 +0800 Subject: [PATCH 28/36] =?UTF-8?q?feat(message):=20=E6=8C=89=E5=81=8F?= =?UTF-8?q?=E5=A5=BD=E6=98=BE=E7=A4=BA=20step=20finish=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根据主题偏好在 step finish 信息中显示模型代码或解析后的模型名称 - 按 provider 和 model id 匹配模型,重复 id 时优先使用对应 provider 的名称并保留回退逻辑 --- src/features/message/MessageRenderer.test.tsx | 240 +++++++++++++++++- src/features/message/MessageRenderer.tsx | 16 +- 2 files changed, 244 insertions(+), 12 deletions(-) diff --git a/src/features/message/MessageRenderer.test.tsx b/src/features/message/MessageRenderer.test.tsx index d5f90d1d..f349521b 100644 --- a/src/features/message/MessageRenderer.test.tsx +++ b/src/features/message/MessageRenderer.test.tsx @@ -1,25 +1,25 @@ import type { ReactNode } from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { MessageRenderer } from './MessageRenderer' import type { Message } from '../../types/message' +const { useModelsMock, useThemeMock } = vi.hoisted(() => ({ + useModelsMock: vi.fn(), + useThemeMock: vi.fn(), +})) + vi.mock('motion/mini', () => ({ animate: () => Promise.resolve(), })) vi.mock('../../hooks', () => ({ useDelayedRender: (show: boolean) => show, + useModels: useModelsMock, })) vi.mock('../../hooks/useTheme', () => ({ - useTheme: () => ({ - collapseUserMessages: false, - stepFinishDisplay: { turnDuration: false }, - descriptiveToolSteps: false, - inlineToolRequests: false, - immersiveMode: false, - }), + useTheme: useThemeMock, })) vi.mock('../../components/ui', () => ({ @@ -34,13 +34,37 @@ vi.mock('./parts', () => ({ FilePartView: () => null, AgentPartView: () => null, SyntheticTextPartView: () => null, - StepFinishPartView: () => null, + StepFinishPartView: ({ modelLabel }: { modelLabel?: string }) => ( +
{modelLabel ? `step-finish-model:${modelLabel}` : 'step-finish-model:undefined'}
+ ), SubtaskPartView: () => null, RetryPartView: () => null, CompactionPartView: () =>
History compacted
, MessageErrorView: () => null, })) +function createThemeOverrides(overrides?: Record) { + return { + collapseUserMessages: false, + stepFinishDisplay: { + agent: false, + model: false, + tokens: false, + cache: false, + cost: false, + duration: false, + turnDuration: false, + completedAt: false, + }, + completedAtFormat: 'absolute', + modelLabelFormat: 'code', + descriptiveToolSteps: false, + inlineToolRequests: false, + immersiveMode: false, + ...overrides, + } +} + function createAssistantMessage(): Message { return { info: { @@ -75,6 +99,42 @@ function createAssistantMessage(): Message { } } +function createStepFinishPart() { + return { + id: 'step-finish-1', + sessionID: 'session-1', + messageID: 'assistant-1', + type: 'step-finish' as const, + reason: 'stop', + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + } +} + +function createToolPart() { + return { + id: 'tool-1', + sessionID: 'session-1', + messageID: 'assistant-1', + type: 'tool' as const, + callID: 'call-1', + tool: 'bash', + state: { + status: 'completed' as const, + input: { command: 'pwd' }, + output: '/workspace', + title: 'Ran bash', + metadata: {}, + time: { start: 1, end: 2 }, + }, + } +} + function createUserMessage(): Message { return { info: { @@ -91,6 +151,14 @@ function createUserMessage(): Message { } describe('MessageRenderer assistant fork', () => { + beforeEach(() => { + useModelsMock.mockReset() + useThemeMock.mockReset() + + useModelsMock.mockReturnValue({ models: [], isLoading: false, error: null, refetch: vi.fn() }) + useThemeMock.mockImplementation(() => createThemeOverrides()) + }) + it('passes the explicit fork target id when forking an assistant message', async () => { const onFork = vi.fn() const message = createAssistantMessage() @@ -139,4 +207,158 @@ describe('MessageRenderer assistant fork', () => { expect(screen.getByText('History compacted')).toBeInTheDocument() }) + + it('uses raw assistantInfo.modelID for step-finish model label in code mode', () => { + const message = createAssistantMessage() + message.parts = [createStepFinishPart()] + + useThemeMock.mockImplementation(() => + createThemeOverrides({ + stepFinishDisplay: { + agent: false, + model: true, + tokens: false, + cache: false, + cost: false, + duration: false, + turnDuration: false, + completedAt: false, + }, + modelLabelFormat: 'code', + }), + ) + useModelsMock.mockReturnValue({ + models: [{ id: 'model-1', providerId: 'provider-1', name: 'Resolved Name' }], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + + render() + + expect(screen.getByText('step-finish-model:model-1')).toBeInTheDocument() + }) + + it('uses resolved model.name for step-finish model label in name mode', () => { + const message = createAssistantMessage() + message.parts = [createStepFinishPart()] + + useThemeMock.mockImplementation(() => + createThemeOverrides({ + stepFinishDisplay: { + agent: false, + model: true, + tokens: false, + cache: false, + cost: false, + duration: false, + turnDuration: false, + completedAt: false, + }, + modelLabelFormat: 'name', + }), + ) + useModelsMock.mockReturnValue({ + models: [{ id: 'model-1', providerId: 'provider-1', name: 'Resolved Name' }], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + + render() + + expect(screen.getByText('step-finish-model:Resolved Name')).toBeInTheDocument() + }) + + it('passes the resolved model label through the grouped tool footer step-finish path', () => { + const message = createAssistantMessage() + message.parts = [createToolPart(), createStepFinishPart()] + + useThemeMock.mockImplementation(() => + createThemeOverrides({ + stepFinishDisplay: { + agent: false, + model: true, + tokens: false, + cache: false, + cost: false, + duration: false, + turnDuration: false, + completedAt: false, + }, + modelLabelFormat: 'name', + }), + ) + useModelsMock.mockReturnValue({ + models: [{ id: 'model-1', providerId: 'provider-1', name: 'Grouped Tool Model' }], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + + render() + + expect(screen.getByText('step-finish-model:Grouped Tool Model')).toBeInTheDocument() + }) + + it('uses the provider-specific model name when duplicate model ids exist in name mode', () => { + const message = createAssistantMessage() + message.parts = [createStepFinishPart()] + + useThemeMock.mockImplementation(() => + createThemeOverrides({ + stepFinishDisplay: { + agent: false, + model: true, + tokens: false, + cache: false, + cost: false, + duration: false, + turnDuration: false, + completedAt: false, + }, + modelLabelFormat: 'name', + }), + ) + useModelsMock.mockReturnValue({ + models: [ + { id: 'model-1', providerId: 'provider-2', name: 'Wrong Provider Name' }, + { id: 'model-1', providerId: 'provider-1', name: 'Correct Provider Name' }, + ], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + + render() + + expect(screen.getByText('step-finish-model:Correct Provider Name')).toBeInTheDocument() + expect(screen.queryByText('step-finish-model:Wrong Provider Name')).toBeNull() + }) + + it('falls back to assistantInfo.modelID when name mode has an empty model list', () => { + const message = createAssistantMessage() + message.parts = [createStepFinishPart()] + + useThemeMock.mockImplementation(() => + createThemeOverrides({ + stepFinishDisplay: { + agent: false, + model: true, + tokens: false, + cache: false, + cost: false, + duration: false, + turnDuration: false, + completedAt: false, + }, + modelLabelFormat: 'name', + }), + ) + useModelsMock.mockReturnValue({ models: [], isLoading: false, error: null, refetch: vi.fn() }) + + render() + + expect(screen.getByText('step-finish-model:model-1')).toBeInTheDocument() + }) }) diff --git a/src/features/message/MessageRenderer.tsx b/src/features/message/MessageRenderer.tsx index 0db992f9..ef8ef113 100644 --- a/src/features/message/MessageRenderer.tsx +++ b/src/features/message/MessageRenderer.tsx @@ -4,7 +4,7 @@ import { diffLines } from 'diff' import { animate } from 'motion/mini' import { ChevronDownIcon, ChevronRightIcon, SplitIcon, SpinnerIcon, UndoIcon } from '../../components/Icons' import { CopyButton, SmoothHeight } from '../../components/ui' -import { useDelayedRender } from '../../hooks' +import { useDelayedRender, useModels } from '../../hooks' import { useTheme } from '../../hooks/useTheme' import { useInlineToolRequests, @@ -378,7 +378,8 @@ const AssistantMessageView = memo(function AssistantMessageView({ }) { const { t } = useTranslation('message') const { parts, isStreaming, info } = message - const { stepFinishDisplay, completedAtFormat } = useTheme() + const { models } = useModels() + const { stepFinishDisplay, completedAtFormat, modelLabelFormat } = useTheme() const wrapperRef = useEntryGrowAnimation(info.time.created) @@ -427,7 +428,16 @@ const AssistantMessageView = memo(function AssistantMessageView({ // agent / model(仅 assistant 消息) const assistantInfo = info.role === 'assistant' ? (info as AssistantMessageInfo) : null const agent = assistantInfo?.agent || undefined - const modelLabel = assistantInfo?.modelID || undefined + const modelLabel = useMemo(() => { + if (!assistantInfo?.modelID) return undefined + if (modelLabelFormat === 'code') return assistantInfo.modelID + + const matchedModel = models.find( + model => model.providerId === assistantInfo.providerID && model.id === assistantInfo.modelID, + ) + + return matchedModel?.name || assistantInfo.modelID + }, [assistantInfo, modelLabelFormat, models]) const hasStepFinishPart = parts.some(part => part.type === 'step-finish') const showTurnDurationFooter = From 410d941070324839d3617d3073e8dfa696ebd576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E5=B0=8F=E9=9F=B5?= Date: Thu, 21 May 2026 11:01:41 +0800 Subject: [PATCH 29/36] =?UTF-8?q?feat(theme):=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E6=A8=A1=E5=9E=8B=E5=8F=98=E4=BD=93=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=81=8F=E5=A5=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 showModelVariant 主题状态、读取器与更新方法,并使用 localStorage 持久化。 - 将模型变体显示偏好纳入主题备份导入流程,避免设置迁移时丢失该选项。 - 添加单元测试覆盖默认值、严格 true 解析、持久化与订阅通知行为。 --- src/store/themeStore.test.ts | 50 ++++++++++++++++++++++++++++++++++++ src/store/themeStore.ts | 19 ++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/store/themeStore.test.ts b/src/store/themeStore.test.ts index 34c1fdb8..837c8999 100644 --- a/src/store/themeStore.test.ts +++ b/src/store/themeStore.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const STORAGE_KEY_MODEL_LABEL_FORMAT = 'model-label-format' +const STORAGE_KEY_SHOW_MODEL_VARIANT = 'show-model-variant' describe('themeStore modelLabelFormat', () => { beforeEach(() => { @@ -52,3 +53,52 @@ describe('themeStore modelLabelFormat', () => { unsubscribe() }) }) + +describe('themeStore showModelVariant', () => { + beforeEach(() => { + localStorage.clear() + vi.resetModules() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('defaults showModelVariant to false when storage is missing', async () => { + const { themeStore } = await import('./themeStore') + + expect(themeStore.getState().showModelVariant).toBe(false) + expect(themeStore.showModelVariant).toBe(false) + }) + + it('restores persisted showModelVariant using strict true parsing', async () => { + localStorage.setItem(STORAGE_KEY_SHOW_MODEL_VARIANT, 'true') + + let module = await import('./themeStore') + expect(module.themeStore.getState().showModelVariant).toBe(true) + + localStorage.clear() + localStorage.setItem(STORAGE_KEY_SHOW_MODEL_VARIANT, '1') + vi.resetModules() + module = await import('./themeStore') + + expect(module.themeStore.getState().showModelVariant).toBe(false) + }) + + it('persists and emits when setting showModelVariant', async () => { + const { themeStore } = await import('./themeStore') + const listener = vi.fn() + const unsubscribe = themeStore.subscribe(listener) + + themeStore.setShowModelVariant(true) + + expect(themeStore.getState().showModelVariant).toBe(true) + expect(localStorage.getItem(STORAGE_KEY_SHOW_MODEL_VARIANT)).toBe('true') + expect(listener).toHaveBeenCalledTimes(1) + + themeStore.setShowModelVariant(true) + + expect(listener).toHaveBeenCalledTimes(1) + unsubscribe() + }) +}) diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 0d251583..f17856d7 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -145,6 +145,8 @@ export interface ThemeState { completedAtFormat: CompletedAtFormat /** 模型标签显示格式 */ modelLabelFormat: ModelLabelFormat + /** 是否显示模型 variant 信息 */ + showModelVariant: boolean /** 思考内容展示样式 */ reasoningDisplayMode: ReasoningDisplayMode /** 宽模式 */ @@ -190,6 +192,7 @@ const STORAGE_KEY_COLLAPSE_USER_MESSAGES = 'collapse-user-messages' const STORAGE_KEY_STEP_FINISH_DISPLAY = 'step-finish-display' const STORAGE_KEY_COMPLETED_AT_FORMAT = 'completed-at-format' const STORAGE_KEY_MODEL_LABEL_FORMAT = 'model-label-format' +const STORAGE_KEY_SHOW_MODEL_VARIANT = 'show-model-variant' const STORAGE_KEY_REASONING_DISPLAY_MODE = 'reasoning-display-mode' const STORAGE_KEY_WIDE_MODE = 'chat-wide-mode' const STORAGE_KEY_DIFF_STYLE = 'diff-style' @@ -276,6 +279,9 @@ class ThemeStore { const modelLabelFormat: ModelLabelFormat = savedModelLabelFormat === 'name' ? 'name' : DEFAULT_MODEL_LABEL_FORMAT + const savedShowModelVariant = localStorage.getItem(STORAGE_KEY_SHOW_MODEL_VARIANT) + const showModelVariant = savedShowModelVariant === 'true' + const savedWideMode = localStorage.getItem(STORAGE_KEY_WIDE_MODE) === 'true' const savedDiffStyle = localStorage.getItem(STORAGE_KEY_DIFF_STYLE) as DiffStyle | null const diffStyle: DiffStyle = savedDiffStyle === 'changeBars' ? 'changeBars' : DEFAULT_DIFF_STYLE @@ -334,6 +340,7 @@ class ThemeStore { stepFinishDisplay, completedAtFormat, modelLabelFormat, + showModelVariant, reasoningDisplayMode, wideMode: savedWideMode, diffStyle, @@ -384,6 +391,9 @@ class ThemeStore { get modelLabelFormat() { return this.state.modelLabelFormat } + get showModelVariant() { + return this.state.showModelVariant + } get reasoningDisplayMode() { return this.state.reasoningDisplayMode } @@ -574,6 +584,13 @@ class ThemeStore { this.emit() } + setShowModelVariant(enabled: boolean) { + if (this.state.showModelVariant === enabled) return + this.state = { ...this.state, showModelVariant: enabled } + localStorage.setItem(STORAGE_KEY_SHOW_MODEL_VARIANT, String(enabled)) + this.emit() + } + setReasoningDisplayMode(mode: ReasoningDisplayMode) { if (this.state.reasoningDisplayMode === mode) return this.state = { ...this.state, reasoningDisplayMode: mode } @@ -903,6 +920,7 @@ function normalizeThemeBackup(raw: unknown): ThemeBackup { : DEFAULT_STEP_FINISH_DISPLAY, completedAtFormat: parsed?.completedAtFormat === 'dateTime' ? 'dateTime' : DEFAULT_COMPLETED_AT_FORMAT, modelLabelFormat: parsed?.modelLabelFormat === 'name' ? 'name' : DEFAULT_MODEL_LABEL_FORMAT, + showModelVariant: parsed?.showModelVariant === true, reasoningDisplayMode: parsed?.reasoningDisplayMode === 'italic' || parsed?.reasoningDisplayMode === 'markdown' ? parsed.reasoningDisplayMode @@ -963,6 +981,7 @@ export function importThemeBackup(raw: unknown): void { localStorage.setItem(STORAGE_KEY_STEP_FINISH_DISPLAY, JSON.stringify(backup.stepFinishDisplay)) localStorage.setItem(STORAGE_KEY_COMPLETED_AT_FORMAT, backup.completedAtFormat) localStorage.setItem(STORAGE_KEY_MODEL_LABEL_FORMAT, backup.modelLabelFormat) + localStorage.setItem(STORAGE_KEY_SHOW_MODEL_VARIANT, String(backup.showModelVariant)) localStorage.setItem(STORAGE_KEY_REASONING_DISPLAY_MODE, backup.reasoningDisplayMode) localStorage.setItem(STORAGE_KEY_WIDE_MODE, String(backup.wideMode)) localStorage.setItem(STORAGE_KEY_DIFF_STYLE, backup.diffStyle) From e909120d95fe30c22f392175b023d326ead7db58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E5=B0=8F=E9=9F=B5?= Date: Thu, 21 May 2026 11:02:41 +0800 Subject: [PATCH 30/36] =?UTF-8?q?feat(settings):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=8F=98=E4=BD=93=E6=98=BE=E7=A4=BA=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在聊天设置中新增模型变体显示开关,并通过 useTheme 暴露对应状态。 - 补充中英文设置文案,使该选项只在模型信息展示启用时出现。 - 添加设置页测试覆盖显示顺序、可见性与交互更新。 --- .../settings/components/ChatSettings.test.tsx | 50 ++++++++++++++++++- .../settings/components/ChatSettings.tsx | 21 ++++++++ src/hooks/useTheme.ts | 6 +++ src/locales/en/settings.json | 2 + src/locales/zh-CN/settings.json | 2 + 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/features/settings/components/ChatSettings.test.tsx b/src/features/settings/components/ChatSettings.test.tsx index abd82090..c7a0f015 100644 --- a/src/features/settings/components/ChatSettings.test.tsx +++ b/src/features/settings/components/ChatSettings.test.tsx @@ -11,6 +11,7 @@ const { setCompletedAtFormatMock, setCollapseUserMessagesMock, setReasoningDisplayModeMock, + setShowModelVariantMock, } = vi.hoisted(() => ({ useTranslationMock: vi.fn(), usePathModeMock: vi.fn(), @@ -20,6 +21,7 @@ const { setCompletedAtFormatMock: vi.fn(), setCollapseUserMessagesMock: vi.fn(), setReasoningDisplayModeMock: vi.fn(), + setShowModelVariantMock: vi.fn(), })) vi.mock('react-i18next', () => ({ @@ -48,11 +50,15 @@ vi.mock('../../../store/themeStore', () => ({ get reasoningDisplayMode() { return 'capsule' as const }, + get showModelVariant() { + return showModelVariantValue + }, setStepFinishDisplay: setStepFinishDisplayMock, setCompletedAtFormat: setCompletedAtFormatMock, setModelLabelFormat: setModelLabelFormatMock, setCollapseUserMessages: setCollapseUserMessagesMock, setReasoningDisplayMode: setReasoningDisplayModeMock, + setShowModelVariant: setShowModelVariantMock, }, })) @@ -69,6 +75,8 @@ let stepFinishDisplayValue = { let modelLabelFormatValue: 'code' | 'name' = 'code' +let showModelVariantValue = false + describe('ChatSettings', () => { beforeEach(() => { useTranslationMock.mockReturnValue({ @@ -97,12 +105,14 @@ describe('ChatSettings', () => { } modelLabelFormatValue = 'code' + showModelVariantValue = false setModelLabelFormatMock.mockReset() setStepFinishDisplayMock.mockReset() setCompletedAtFormatMock.mockReset() setCollapseUserMessagesMock.mockReset() setReasoningDisplayModeMock.mockReset() + setShowModelVariantMock.mockReset() }) it('hides the model label format control when stepFinishDisplay.model is false', () => { @@ -162,11 +172,47 @@ describe('ChatSettings', () => { const html = section!.innerHTML const modelDescIndex = html.indexOf('chat.showModel') const modelLabelFormatIndex = html.indexOf('chat.modelLabelFormat') + const showModelVariantIndex = html.indexOf('chat.showModelVariant') const tokensDescIndex = html.indexOf('chat.showTokenUsage') const completedAtFormatIndex = html.indexOf('chat.completedAtFormat') expect(modelLabelFormatIndex).toBeGreaterThan(modelDescIndex) - expect(modelLabelFormatIndex).toBeLessThan(tokensDescIndex) - expect(completedAtFormatIndex).toBeGreaterThan(modelLabelFormatIndex) + expect(showModelVariantIndex).toBeGreaterThan(modelLabelFormatIndex) + expect(showModelVariantIndex).toBeLessThan(tokensDescIndex) + expect(completedAtFormatIndex).toBeGreaterThan(showModelVariantIndex) + }) + + it('hides the show model variant toggle when stepFinishDisplay.model is false', () => { + render() + + expect(screen.queryByRole('switch', { name: 'chat.showModelVariant' })).not.toBeInTheDocument() + }) + + it('shows the show model variant toggle when stepFinishDisplay.model is true', () => { + stepFinishDisplayValue = { + ...stepFinishDisplayValue, + model: true, + } + + render() + + expect(screen.getByRole('switch', { name: 'chat.showModelVariant' })).toBeInTheDocument() + }) + + it('reflects the show model variant state and updates both state and store on interaction', () => { + stepFinishDisplayValue = { + ...stepFinishDisplayValue, + model: true, + } + showModelVariantValue = false + + render() + + const toggle = screen.getByRole('switch', { name: 'chat.showModelVariant' }) + expect(toggle).toHaveAttribute('aria-checked', 'false') + + fireEvent.click(toggle) + + expect(setShowModelVariantMock).toHaveBeenCalledWith(true) }) }) diff --git a/src/features/settings/components/ChatSettings.tsx b/src/features/settings/components/ChatSettings.tsx index 57a8d8d8..d43edb9f 100644 --- a/src/features/settings/components/ChatSettings.tsx +++ b/src/features/settings/components/ChatSettings.tsx @@ -13,6 +13,7 @@ export function ChatSettings() { const [stepFinishDisplay, setStepFinishDisplay] = useState(themeStore.stepFinishDisplay) const [completedAtFormat, setCompletedAtFormat] = useState(themeStore.completedAtFormat) const [modelLabelFormat, setModelLabelFormat] = useState(themeStore.modelLabelFormat) + const [showModelVariant, setShowModelVariant] = useState(themeStore.showModelVariant) const [reasoningDisplayMode, setReasoningDisplayMode] = useState(themeStore.reasoningDisplayMode) const isMobile = useIsMobile() void isMobile @@ -128,6 +129,26 @@ export function ChatSettings() { themeStore.setModelLabelFormat(next) }} /> + { + const next = !showModelVariant + setShowModelVariant(next) + themeStore.setShowModelVariant(next) + }} + > + { + const next = !showModelVariant + setShowModelVariant(next) + themeStore.setShowModelVariant(next) + }} + /> + )} diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index 97309c33..0bf5dd36 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -168,6 +168,10 @@ export function useTheme() { themeStore.setModelLabelFormat(format) }, []) + const setShowModelVariant = useCallback((enabled: boolean) => { + themeStore.setShowModelVariant(enabled) + }, []) + // ---- Reasoning Display Mode ---- const setReasoningDisplayMode = useCallback((mode: ReasoningDisplayMode) => { @@ -262,6 +266,8 @@ export function useTheme() { setCompletedAtFormat, modelLabelFormat: state.modelLabelFormat, setModelLabelFormat, + showModelVariant: state.showModelVariant, + setShowModelVariant, // 思考内容显示样式 reasoningDisplayMode: state.reasoningDisplayMode, diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 0fe4707e..0bce4cfc 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -103,6 +103,8 @@ "showAgent": "Show agent name", "model": "Model", "showModel": "Show model info", + "showModelVariant": "Model variant", + "showModelVariantDesc": "Show the requested model variant", "modelLabelFormat": "Model label format", "modelLabelFormatDesc": "Choose whether step finish info shows the model code or model name", "modelLabelCode": "Model Code", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index 28e22a49..c0f92df2 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -103,6 +103,8 @@ "showAgent": "显示 Agent 名称", "model": "Model", "showModel": "显示模型信息", + "showModelVariant": "Model 变体", + "showModelVariantDesc": "显示请求的模型变体", "modelLabelFormat": "Model 名称格式", "modelLabelFormatDesc": "选择显示模型代码还是模型名称", "modelLabelCode": "Model 代码", From e2fd61b8c111944266d82885b355c7b145c1ff7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E5=B0=8F=E9=9F=B5?= Date: Thu, 21 May 2026 11:16:09 +0800 Subject: [PATCH 31/36] =?UTF-8?q?feat(message):=20=E6=8C=89=E5=81=8F?= =?UTF-8?q?=E5=A5=BD=E6=98=BE=E7=A4=BA=E6=A8=A1=E5=9E=8B=E5=8F=98=E4=BD=93?= =?UTF-8?q?=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为消息渲染器传入父级用户消息,并在启用偏好时追加请求模型变体标签。 - 新增消息 ID 映射,避免依赖消息相邻关系查找 assistant 的父消息。 - 收紧消息可见性与渲染细节,避免 observer 过期依赖、按钮默认行为和不稳定 key 影响新展示路径。 - 添加 ChatArea 与 MessageRenderer 测试,覆盖父消息映射和变体标签展示。 --- src/features/chat/ChatArea.test.ts | 28 +++- src/features/chat/ChatArea.tsx | 22 ++- src/features/message/MessageRenderer.test.tsx | 128 +++++++++++++++++- src/features/message/MessageRenderer.tsx | 43 ++++-- 4 files changed, 204 insertions(+), 17 deletions(-) diff --git a/src/features/chat/ChatArea.test.ts b/src/features/chat/ChatArea.test.ts index 62725688..77476f52 100644 --- a/src/features/chat/ChatArea.test.ts +++ b/src/features/chat/ChatArea.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' -import { buildTurnDurationMap } from './ChatArea' +import { buildMessageIdMap, buildTurnDurationMap } from './ChatArea' import { buildVisibleMessageEntries, getVisibleMessageForkTargetId } from './chatAreaVisibility' import type { Message, MessageError, Part, ToolPart, ReasoningPart } from '../../types/message' +import { isAssistantMessage } from '../../types/message' function createUserMessage(id: string, created: number): Message { return { @@ -157,3 +158,28 @@ describe('buildTurnDurationMap', () => { expect(durationMap.has('assistant-1')).toBe(false) }) }) + +describe('buildMessageIdMap', () => { + it('resolves assistant parent user messages without relying on adjacency', () => { + const parentUser = createUserMessage('user-1', 1000) + const otherUser = createUserMessage('user-2', 2000) + const assistant = createAssistantMessage('assistant-1', [], 2001) + expect(isAssistantMessage(assistant.info)).toBe(true) + if (!isAssistantMessage(assistant.info)) throw new Error('Expected assistant message') + + const messageIdMap = buildMessageIdMap([parentUser, otherUser, assistant]) + + expect(messageIdMap.get(assistant.info.parentID)).toBe(parentUser) + }) + + it('returns undefined safely when an assistant parent message is missing', () => { + const assistant = createAssistantMessage('assistant-missing-parent', [], 2001) + expect(isAssistantMessage(assistant.info)).toBe(true) + if (!isAssistantMessage(assistant.info)) throw new Error('Expected assistant message') + assistant.info.parentID = 'missing-user' + + const messageIdMap = buildMessageIdMap([assistant]) + + expect(messageIdMap.get(assistant.info.parentID)).toBeUndefined() + }) +}) diff --git a/src/features/chat/ChatArea.tsx b/src/features/chat/ChatArea.tsx index 999045f6..3e1d7855 100644 --- a/src/features/chat/ChatArea.tsx +++ b/src/features/chat/ChatArea.tsx @@ -96,6 +96,10 @@ export function buildTurnDurationMap(messages: Message[], visibleMessages: Messa return map } +export function buildMessageIdMap(messages: Message[]): Map { + return new Map(messages.map(message => [message.info.id, message])) +} + interface ChatAreaProps { messages: Message[] sessionId?: string | null @@ -169,6 +173,8 @@ export const ChatArea = memo( // ---- Data ---- const visibleMessageEntries = useMemo(() => buildVisibleMessageEntries(messages), [messages]) const visibleMessages = useMemo(() => visibleMessageEntries.map(e => e.message), [visibleMessageEntries]) + const visibleMessageCount = visibleMessages.length + const messageIdMap = useMemo(() => buildMessageIdMap(messages), [messages]) const forkTargetIdMap = useMemo( () => new Map(visibleMessageEntries.map(entry => [entry.message.info.id, getVisibleMessageForkTargetId(entry)])), @@ -282,7 +288,7 @@ export const ChatArea = memo( observer.observe(sentinel) return () => observer.disconnect() - }, [sessionId, visibleMessages]) + }, [sessionId]) // column-reverse 下 prepend 在负方向远端,scrollTop 不变,视口自然不跳。 // 不需要手动补偿。 @@ -299,6 +305,10 @@ export const ChatArea = memo( useEffect(() => { const root = scrollRef.current if (!root) return + if (visibleMessageCount === 0) { + onVisibleIdsChangeRef.current?.([]) + return + } const visibleIds = new Set() const observer = new IntersectionObserver( @@ -326,10 +336,12 @@ export const ChatArea = memo( // Observe all current message elements const elements = root.querySelectorAll('[data-message-id]') - elements.forEach(el => observer.observe(el)) + elements.forEach(el => { + observer.observe(el) + }) return () => observer.disconnect() - }, [visibleMessages]) + }, [visibleMessageCount]) // ============================================ // Imperative Handle @@ -415,6 +427,9 @@ export const ChatArea = memo( > ({ useModelsMock: vi.fn(), @@ -58,6 +58,7 @@ function createThemeOverrides(overrides?: Record) { }, completedAtFormat: 'absolute', modelLabelFormat: 'code', + showModelVariant: false, descriptiveToolSteps: false, inlineToolRequests: false, immersiveMode: false, @@ -150,6 +151,14 @@ function createUserMessage(): Message { } } +function createUserMessageWithVariant(variant?: string): Message { + const message = createUserMessage() + if (variant !== undefined) { + ;(message.info as UserMessageInfo).model.variant = variant + } + return message +} + describe('MessageRenderer assistant fork', () => { beforeEach(() => { useModelsMock.mockReset() @@ -239,6 +248,33 @@ describe('MessageRenderer assistant fork', () => { expect(screen.getByText('step-finish-model:model-1')).toBeInTheDocument() }) + it('keeps the existing model label unchanged when showModelVariant is disabled', () => { + const message = createAssistantMessage() + message.parts = [createStepFinishPart()] + + useThemeMock.mockImplementation(() => + createThemeOverrides({ + stepFinishDisplay: { + agent: false, + model: true, + tokens: false, + cache: false, + cost: false, + duration: false, + turnDuration: false, + completedAt: false, + }, + modelLabelFormat: 'code', + showModelVariant: false, + }), + ) + + render() + + expect(screen.getByText('step-finish-model:model-1')).toBeInTheDocument() + expect(screen.queryByText('step-finish-model:model-1 · X High')).toBeNull() + }) + it('uses resolved model.name for step-finish model label in name mode', () => { const message = createAssistantMessage() message.parts = [createStepFinishPart()] @@ -270,6 +306,64 @@ describe('MessageRenderer assistant fork', () => { expect(screen.getByText('step-finish-model:Resolved Name')).toBeInTheDocument() }) + it('appends the formatted requested variant in code mode when enabled', () => { + const message = createAssistantMessage() + message.parts = [createStepFinishPart()] + + useThemeMock.mockImplementation(() => + createThemeOverrides({ + stepFinishDisplay: { + agent: false, + model: true, + tokens: false, + cache: false, + cost: false, + duration: false, + turnDuration: false, + completedAt: false, + }, + modelLabelFormat: 'code', + showModelVariant: true, + }), + ) + + render() + + expect(screen.getByText('step-finish-model:model-1 · X High')).toBeInTheDocument() + }) + + it('appends the formatted requested variant in name mode when enabled', () => { + const message = createAssistantMessage() + message.parts = [createStepFinishPart()] + + useThemeMock.mockImplementation(() => + createThemeOverrides({ + stepFinishDisplay: { + agent: false, + model: true, + tokens: false, + cache: false, + cost: false, + duration: false, + turnDuration: false, + completedAt: false, + }, + modelLabelFormat: 'name', + showModelVariant: true, + }), + ) + useModelsMock.mockReturnValue({ + models: [{ id: 'model-1', providerId: 'provider-1', name: 'Resolved Name' }], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + + render() + + expect(screen.getByText('step-finish-model:Resolved Name · Xhigh')).toBeInTheDocument() + }) + it('passes the resolved model label through the grouped tool footer step-finish path', () => { const message = createAssistantMessage() message.parts = [createToolPart(), createStepFinishPart()] @@ -287,6 +381,7 @@ describe('MessageRenderer assistant fork', () => { completedAt: false, }, modelLabelFormat: 'name', + showModelVariant: true, }), ) useModelsMock.mockReturnValue({ @@ -296,9 +391,9 @@ describe('MessageRenderer assistant fork', () => { refetch: vi.fn(), }) - render() + render() - expect(screen.getByText('step-finish-model:Grouped Tool Model')).toBeInTheDocument() + expect(screen.getByText('step-finish-model:Grouped Tool Model · X High')).toBeInTheDocument() }) it('uses the provider-specific model name when duplicate model ids exist in name mode', () => { @@ -361,4 +456,31 @@ describe('MessageRenderer assistant fork', () => { expect(screen.getByText('step-finish-model:model-1')).toBeInTheDocument() }) + + it('leaves the model label unchanged when the parent message has no variant', () => { + const message = createAssistantMessage() + message.parts = [createStepFinishPart()] + + useThemeMock.mockImplementation(() => + createThemeOverrides({ + stepFinishDisplay: { + agent: false, + model: true, + tokens: false, + cache: false, + cost: false, + duration: false, + turnDuration: false, + completedAt: false, + }, + modelLabelFormat: 'code', + showModelVariant: true, + }), + ) + + render() + + expect(screen.getByText('step-finish-model:model-1')).toBeInTheDocument() + expect(screen.queryByText(/step-finish-model:model-1 ·/)).toBeNull() + }) }) diff --git a/src/features/message/MessageRenderer.tsx b/src/features/message/MessageRenderer.tsx index ef8ef113..6d98e146 100644 --- a/src/features/message/MessageRenderer.tsx +++ b/src/features/message/MessageRenderer.tsx @@ -41,6 +41,7 @@ import { formatDuration, formatCompletedAt, formatDetailedDateTime } from '../.. interface MessageRendererProps { message: Message + parentMessage?: Message allowStreamingLayoutAnimation?: boolean /** 回合总时长(毫秒),仅在回合最后一条 assistant 消息上有值 */ turnDuration?: number @@ -53,6 +54,7 @@ interface MessageRendererProps { export const MessageRenderer = memo(function MessageRenderer({ message, + parentMessage, allowStreamingLayoutAnimation = true, turnDuration, onUndo, @@ -79,6 +81,7 @@ export const MessageRenderer = memo(function MessageRenderer({ return ( {showCollapse && (