From b16b0d10e712fbc931b3d03882feb0a2c4dafd1b Mon Sep 17 00:00:00 2001 From: mesalogo Date: Tue, 15 Sep 2026 23:21:51 +0800 Subject: [PATCH] fix: update workbar tab titles when switching language Derive application and default terminal titles from the current locale while preserving renamed and unmarked legacy terminal titles. Add locale-switch and layout-restoration regression coverage. File-level scope also includes existing browser popup selection and close handling in the sidebar, its regression test, and workbar documentation updates. Validation: 74 focused tests passed; typecheck and lint passed. Full test runs timed out with failures in settings navigation, Agent packaging, and database migration tests. Electron verification remains outstanding. Release note: Workbar application and new default terminal titles now follow the interface language, including after restoring a layout; custom and legacy terminal names are preserved. --- docs/features/assistant-workbar/progress.md | 32 ++++ .../assistant-workbar/terminal-tabs-prd.md | 14 +- .../src/RightAssistantSidebar.resize.test.tsx | 160 ++++++++++++++++-- src/renderer/src/RightAssistantSidebar.tsx | 61 ++++--- src/shared/workbar-contracts.ts | 1 + 5 files changed, 236 insertions(+), 32 deletions(-) diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 389a1cef..4c04340f 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,37 @@ # 工作栏实现与验证进度 +## 2026-09-15 会话置顶与浮动操作菜单 + +已核对当前源码差异:置顶经专用 IPC 写入 SQLite,schema 36 迁移保留已有会话;会话操作 +改为 body Portal 菜单。规则分别见[会话置顶](./execution-history-storage.md#会话置顶)和 +[菜单界面](../application-tool-navigation/ui-design.md#会话置顶与操作菜单)。 + +本次任务提供的已验证聚焦测试结果为 backend **135 项通过**、renderer **283 项通过**。 +本轮仅更新文档,未重新运行测试;任务未提供这两组的完整命令或原始日志路径。 +已核对 `assistant-database.test.ts` 与 `App.test.tsx` 中相关回归,覆盖持久化与重开、 +schema 35 升级、最近 100 条之外的置顶、自动保存隔离、草稿先保存、通道会话、失败保留 +状态、旧刷新竞态,以及 Portal、窗口边界、键盘与外部关闭。 + +原生 Electron 视觉与完整应用交互尚未验证,不能据组件测试声明实际菜单排版、原生浏览器 +遮挡或跨主题/窗口尺寸视觉验收通过;本轮也未运行全量测试、类型检查、lint 或构建。 + +## 2026-09-15 工作栏标签标题国际化修复 + +实现与兼容规则见[多终端页签 PRD 第 10 节](./terminal-tabs-prd.md#10-实例session-与持久化)。 +任务中心、工作区、成果和带默认编号的终端标题由 Sidebar 按当前语言派生;重命名清除默认 +编号。浏览器标题保持原行为,未修改工作栏壳层。本次只涉及桌面标题显示与布局描述,不改变 +本机或远程 PTY、Runtime、Agent 协议或桌面到 Agent 的执行路径。 + +聚焦验证: + +- `npx vitest run src/renderer/src/RightAssistantSidebar.resize.test.tsx src/shared/workbar-contracts.test.ts src/renderer/src/WorkbarShell.test.tsx`:3 个文件、74 项通过。 +- Sidebar 新增 6 项行为回归,覆盖中英切换且不改写布局、默认终端编号持久化与跨语言恢复、 + 重命名后恢复,以及中文默认名、英文默认名和自定义名三种无标记旧终端。测试使用真实 + Sidebar、WorkbarShell 和布局 Schema,终端面板使用测试替身,未启动 Shell。 +- `npx eslint src/renderer/src/RightAssistantSidebar.tsx src/renderer/src/RightAssistantSidebar.resize.test.tsx src/shared/workbar-contracts.ts`:通过。 +- 首轮测试有一处新增浏览器文案断言错误,修正为现有“未绑定会话”后通过。按用户要求未运行 + 全量测试、全量 typecheck、全量 lint 或构建;本轮未进行 Electron 实机验证。 + ## 2026-09-15 Desktop 0.13.6 发布准备 用户批准两项性能修复的中英文更新说明,指定 Desktop 0.13.6,不发布 Agent 或 diff --git a/docs/features/assistant-workbar/terminal-tabs-prd.md b/docs/features/assistant-workbar/terminal-tabs-prd.md index de3656a2..6a9180c4 100644 --- a/docs/features/assistant-workbar/terminal-tabs-prd.md +++ b/docs/features/assistant-workbar/terminal-tabs-prd.md @@ -90,7 +90,8 @@ SSH 连接池,但尚不存在工作栏应用实例模型、终端 IPC、终端 工作栏 Tab 是应用的一次打开实例。每个实例具有独立 ID、标题、应用类型和状态。Tab 行只 显示已经打开的实例,不把全部能力同时渲染为 Tab。 -实例持久化字段只有 `id`、`appId`、`title` 和可选 `targetRef`。`targetRef` 使用公开引用: +实例持久化字段为 `id`、`appId`、`title`,以及可选的 `targetRef`、`defaultTitleNumber`。 +默认标题编号的保存和兼容规则见第 10 节。`targetRef` 使用公开引用: 终端接受 `local` 或 `projectId`,浏览器接受 `conversationId`。Main 签发的 Browser Tab ID、 Terminal Session ID、元素引用、凭据、路径和运行状态不进入工作栏布局。 @@ -179,8 +180,12 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或 - 每个实例使用独立 `tab` 和 `tabpanel`。 - “+”固定在 Tab 行末尾,但位于 `tablist` 之外。 - Tab 过多时实例区域单行横向滚动,“+”保持可见;不换行、不自动隐藏实例。 +- 标签全部可见时隐藏左右滚动按钮,“+”紧随最后一个标签;溢出时两端显示左右箭头,点击滚动约一屏,边界方向禁用。“+”固定在右箭头外侧。切换或新增激活标签时将其滚入可见区域。未激活标签使用灰蓝底色和边框,激活标签保留亮底与强调色顶线。 - 终端 Tab 默认使用递增标题,例如“终端 1”“终端 2”;状态栏持续显示项目、Host、 工作目录和 Shell。用户可以重命名终端。 +- 任务中心、工作区、成果及新建终端的默认标题随界面语言切换,恢复布局时也使用当前语言。 + 终端自定义名称保持原文;旧布局中的终端名称保持原值,即使看起来像“终端 1”。浏览器 + 标题沿用现有行为,不在本次标题国际化范围内。 - 当前 Tab 同时使用连续内容表面、选中边框、顶部强调线、图标和较高字重,不能只靠文字颜色。 - Tab、关闭按钮和“+”入口沿用共享控件圆角;终端面板外框和终端顶部工具栏按钮使用直角, 保持紧凑的停靠式终端界面。 @@ -345,6 +350,7 @@ type WorkbarTabInstance = { id: string appId: WorkbarAppDefinition['id'] title: string + defaultTitleNumber?: number targetRef?: | { type: 'local' } | { type: 'project'; projectId: string } @@ -373,6 +379,12 @@ type WorkbarLayoutPreferences = { Shell。 - 运行期间切换项目不会改变已经创建的终端目标。用户需要新目标时通过“+”创建新终端。 +新建终端保存正整数 `defaultTitleNumber`,沿用同一目标已有终端数量加一的编号规则。 +Sidebar 在渲染时按当前语言派生应用和默认终端标题,再交给 Tab 壳层与终端面板;切换语言 +不改写持久化标题。用户重命名时清除此字段并保存输入的 `title`。缺少标记的旧终端直接显示 +原 `title`,不通过名称模式推断默认标题。布局存储键保持 `goodbuddy.workbar-layout.v1`; +旧版本使用严格 Schema,不能读取带新增字段的布局,本变更不提供降级兼容。 + ## 11. 生命周期与关闭语义 | 用户或系统动作 | Tab | Shell Session | diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index 0daef01a..f63a18d5 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalSnapshot } from '../../shared/terminal-contracts' import { workbarLayoutPreferencesSchema } from '../../shared/workbar-contracts' import { DEFAULT_WORKBAR_INSTANCES } from './WorkbarShell' +import i18n from './i18n' import type { AssistantProject, AssistantTask, @@ -31,22 +32,34 @@ import { } from './RightAssistantSidebar' vi.mock('./TerminalPanel', () => ({ - TerminalPanel: ({ onSessionChange }: { + TerminalPanel: ({ onSessionChange, onRename, title }: { onSessionChange?: (snapshot: TerminalSnapshot) => void + onRename: (title: string) => void + title: string }) => ( - + <> + onRename(event.target.value)} + value={title} + /> + + ) })) -afterEach(cleanup) +afterEach(async () => { + cleanup() + await i18n.changeLanguage('zh-CN') +}) const firstBrowserTabId = '00000000-0000-4000-8000-000000000401' as BrowserTabId @@ -224,6 +237,100 @@ function renderSidebar(options: Parameters[0] = {}): HTML }) } +describe('RightAssistantSidebar tab titles', () => { + it('updates application titles in place when the locale changes without rewriting the layout', async () => { + renderSidebar() + const initialTabs = screen.getAllByRole('tab') + const savedLayout = localStorage.getItem('goodbuddy.workbar-layout.v1') + expect(initialTabs.map((tab) => tab.textContent)).toEqual([ + '任务中心', '工作区', '浏览器 · 未绑定会话', '成果' + ]) + + await act(async () => { await i18n.changeLanguage('en-US') }) + expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ + 'Task center', 'Files', '浏览器 · 未绑定会话', 'Results' + ]) + expect(screen.getAllByRole('tab')).toEqual(initialTabs) + expect(screen.getByRole('tab', { name: 'Task center' })).toHaveAttribute('aria-selected', 'true') + expect(localStorage.getItem('goodbuddy.workbar-layout.v1')).toBe(savedLayout) + + await act(async () => { await i18n.changeLanguage('zh-CN') }) + expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ + '任务中心', '工作区', '浏览器 · 未绑定会话', '成果' + ]) + }) + + it('persists default terminal numbers and restores titles using the current locale', async () => { + const view = render(sidebarElement()) + for (const number of [1, 2]) { + fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' })) + fireEvent.click(screen.getByText('终端', { selector: 'strong' }).closest('button')!) + expect(await screen.findByRole('tab', { name: `终端 ${number}` })).toBeInTheDocument() + } + const savedLayout = workbarLayoutPreferencesSchema.parse( + JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!) + ) + expect(savedLayout.instances.filter((instance) => instance.appId === 'terminal')) + .toEqual([1, 2].map((number) => expect.objectContaining({ + title: `终端 ${number}`, defaultTitleNumber: number, + targetRef: { type: 'project', projectId: currentProject.id } + }))) + view.unmount() + await i18n.changeLanguage('en-US') + render(sidebarElement()) + expect(screen.getByRole('tab', { name: 'Terminal 1' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Terminal 2' })).toHaveAttribute('aria-selected', 'true') + expect(await screen.findByRole('textbox', { name: 'Test terminal name' })).toHaveValue('Terminal 2') + for (const name of ['Task center', 'Files', 'Results']) { + expect(screen.getByRole('tab', { name })).toBeInTheDocument() + } + await act(async () => { await i18n.changeLanguage('zh-CN') }) + expect(screen.getByRole('tab', { name: '终端 2' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('textbox', { name: 'Test terminal name' })).toHaveValue('终端 2') + }) + + it('clears the default marker on rename and preserves a default-looking custom name across locale changes and restore', async () => { + const view = render(sidebarElement()) + fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' })) + fireEvent.click(screen.getByText('终端', { selector: 'strong' }).closest('button')!) + const input = await screen.findByRole('textbox', { name: 'Test terminal name' }) + fireEvent.change(input, { target: { value: 'Terminal 99' } }) + const savedLayout = workbarLayoutPreferencesSchema.parse( + JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!) + ) + const terminal = savedLayout.instances.find((instance) => instance.appId === 'terminal')! + expect(terminal.title).toBe('Terminal 99') + expect(terminal).not.toHaveProperty('defaultTitleNumber') + await act(async () => { await i18n.changeLanguage('en-US') }) + expect(screen.getByRole('tab', { name: 'Terminal 99' })).toBeInTheDocument() + view.unmount() + await i18n.changeLanguage('zh-CN') + render(sidebarElement()) + expect(screen.getByRole('tab', { name: 'Terminal 99' })).toHaveAttribute('aria-selected', 'true') + expect(await screen.findByRole('textbox', { name: 'Test terminal name' })).toHaveValue('Terminal 99') + }) + + it.each(['终端 1', 'Terminal 2', '部署日志'])('preserves an unmarked legacy terminal title: %s', async (title) => { + const terminalId = '10000000-0000-4000-8000-000000000009' + localStorage.setItem('goodbuddy.workbar-layout.v1', JSON.stringify({ + instances: [...DEFAULT_WORKBAR_INSTANCES, { + id: terminalId, appId: 'terminal', title, targetRef: { type: 'local' } + }], + activeInstanceId: terminalId, expanded: true, dock: 'right', widthRatio: 0.3 + })) + renderSidebar() + expect(await screen.findByRole('textbox', { name: 'Test terminal name' })).toHaveValue(title) + await act(async () => { await i18n.changeLanguage('en-US') }) + expect(screen.getByRole('tab', { name: title })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('textbox', { name: 'Test terminal name' })).toHaveValue(title) + const savedLayout = workbarLayoutPreferencesSchema.parse( + JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!) + ) + expect(savedLayout.instances.find((instance) => instance.id === terminalId)) + .toEqual({ id: terminalId, appId: 'terminal', title, targetRef: { type: 'local' } }) + }) +}) + describe('RightAssistantSidebar resizing', () => { it('resizes with pointer capture and preserves compact pane minima', () => { const sidebar = renderSidebar() @@ -1172,6 +1279,37 @@ describe('RightAssistantSidebar resizing', () => { expect(screen.queryByRole('tab', { name: '浏览器 1 · conversation-a' })).not.toBeInTheDocument() }) + it('selects a visible opener popup across conversation switches and removes its script-closed instance', async () => { + const conversationId = 'popup-owner' + const view = render(sidebarElement({ tab: 'browser', activeConversationId: conversationId })) + await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce()) + const popup: BrowserLiveState = { + ...browserSummary(conversationId, secondBrowserTabId, false), + workbarInstanceId: secondBrowserTabId, openerTabId: firstBrowserTabId, + sessionActive: true, url: 'https://popup.example/' + } + view.rerender(sidebarElement({ + tab: 'browser', activeConversationId: 'another-conversation', + browserStates: { [conversationId]: { [secondBrowserTabId]: popup } } + })) + await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://popup.example/')) + expect(browserApi.createTab).toHaveBeenCalledOnce() + const stopped: BrowserLiveState = { ...popup, status: 'stopped', sessionActive: false } + act(() => { + for (const listener of browserStateListeners) listener(stopped) + view.rerender(sidebarElement({ + tab: 'browser', activeConversationId: 'another-conversation', + browserStates: { [conversationId]: { [secondBrowserTabId]: stopped } } + })) + }) + await waitFor(() => { + const saved = JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!) + expect(saved.instances.some((instance: { id: string }) => instance.id === secondBrowserTabId)).toBe(false) + }) + expect(browserApi.createTab).toHaveBeenCalledOnce() + expect(browserApi.closeTab).not.toHaveBeenCalled() + }) + it('restores a browser by stable ownership and releases only its viewport lease', async () => { const conversationId = 'conversation-restored' const workbarInstanceId = '10000000-0000-4000-8000-000000000003' diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index cea03181..7e93c76a 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -701,32 +701,18 @@ export function RightAssistantSidebar({ }), [t, tabs] ) - const defaultInstances = useMemo( - () => - DEFAULT_WORKBAR_INSTANCES.map((instance) => ({ - ...instance, - title: - localizedAppDefinitions.find( - (definition) => definition.id === instance.appId - )?.label ?? instance.title - })), - [localizedAppDefinitions] - ) const [workbarInstances, setWorkbarInstances] = useState< WorkbarTabInstance[] >(() => { - const instances = (initialLayout?.instances ?? defaultInstances).map( + const instances = (initialLayout?.instances ?? DEFAULT_WORKBAR_INSTANCES).map( (instance) => { - if (instance.appId === 'terminal') { + if (instance.appId !== 'browser') { return instance } const label = localizedAppDefinitions.find( (definition) => definition.id === instance.appId )?.label ?? instance.title - if (instance.appId !== 'browser') { - return { ...instance, title: label } - } const conversationId = instance.targetRef?.type === 'conversation' ? instance.targetRef.conversationId @@ -751,6 +737,26 @@ export function RightAssistantSidebar({ ) return instances }) + const localizedWorkbarInstances = useMemo( + () => workbarInstances.map((instance) => { + if ( + instance.appId === 'browser' || + (instance.appId === 'terminal' && instance.defaultTitleNumber === undefined) + ) { + return instance + } + const label = localizedAppDefinitions.find( + (definition) => definition.id === instance.appId + )?.label ?? instance.title + return { + ...instance, + title: instance.appId === 'terminal' + ? `${label} ${instance.defaultTitleNumber}` + : label + } + }), + [localizedAppDefinitions, workbarInstances] + ) const [activeWorkbarInstanceId, setActiveWorkbarInstanceId] = useState( () => @@ -1119,6 +1125,9 @@ export function RightAssistantSidebar({ : request.appId === 'browser' ? browserTitle() : definition.label, + ...(request.appId === 'terminal' + ? { defaultTitleNumber: sameTargetTerminals + 1 } + : {}), ...(request.targetRef || conversationTarget ? { targetRef: request.targetRef ?? conversationTarget } : {}) @@ -1206,6 +1215,12 @@ export function RightAssistantSidebar({ useEffect(() => window.goodbuddy.browser?.onState?.((state) => { if (state.status !== 'stopped') return + if (state.openerTabId && state.workbarInstanceId) { + setWorkbarInstances((current) => current.filter((instance) => instance.id !== state.workbarInstanceId)) + const openerInstanceId = Object.entries(browserTabIdsRef.current) + .find(([, tabId]) => tabId === state.openerTabId)?.[0] + setActiveWorkbarInstanceId((current) => current === state.workbarInstanceId ? openerInstanceId ?? null : current) + } const next = { ...browserTabIdsRef.current } let changed = false for (const [instanceId, tabId] of Object.entries(next)) { @@ -1259,7 +1274,13 @@ export function RightAssistantSidebar({ .filter((candidate) => !current.some((instance) => instance.id === candidate.id)) .slice(0, Math.max(0, WORKBAR_LIMITS.maximumOpenInstances - current.length)) ]) - const requested = additions.find((instance) => + const popup = additions.findLast((instance) => liveTabs.some((state) => + state.workbarInstanceId === instance.id && + state.openerTabId && + activeWorkbarInstanceId && + state.openerTabId === nextBindings[activeWorkbarInstanceId] + )) + const requested = popup ?? additions.find((instance) => instance.targetRef?.type === 'conversation' && instance.targetRef.conversationId === activeConversationId ) @@ -1267,7 +1288,7 @@ export function RightAssistantSidebar({ setActiveWorkbarInstanceId(requested.id) } } - }, [activeConversationId, browserStates, conversationTitles, tab, t, workbarInstances]) + }, [activeConversationId, activeWorkbarInstanceId, browserStates, conversationTitles, tab, t, workbarInstances]) const removeWorkbarInstance = useCallback( (instanceId: string): void => { @@ -1778,7 +1799,7 @@ export function RightAssistantSidebar({ current.map((candidate) => candidate.id === instance.id - ? { ...candidate, title } + ? { ...candidate, title, defaultTitleNumber: undefined } : candidate ) ) diff --git a/src/shared/workbar-contracts.ts b/src/shared/workbar-contracts.ts index aecdc244..28a8a900 100644 --- a/src/shared/workbar-contracts.ts +++ b/src/shared/workbar-contracts.ts @@ -199,6 +199,7 @@ export const workbarTabInstanceSchema = z id: z.string().uuid(), appId: workbarAppIdSchema, title: boundedUtf8TextSchema(WORKBAR_LIMITS.maximumTitleBytes), + defaultTitleNumber: z.number().int().positive().optional(), targetRef: workbarTargetRefSchema.optional() }) .strict()