diff --git a/docs/SIDE_PANE_ARCHITECTURE.md b/docs/SIDE_PANE_ARCHITECTURE.md index d5b340949..703fc9213 100644 --- a/docs/SIDE_PANE_ARCHITECTURE.md +++ b/docs/SIDE_PANE_ARCHITECTURE.md @@ -192,7 +192,7 @@ openTab ─ 创建 occurrence ─ openView(scope) ─ 发布可见性 ─ mountT export function defineTerminalTabType(deps) { // 实现第一次用到时才加载;openTerminalTab 是要转发的方法 const provider = createLazyProvider(async () => (await import('../terminalSideProvider.js')) - .createTerminalSideProvider(deps), ['openTerminalTab'], { label: '终端', notify: deps.notify }); + .createTerminalSideProvider(deps), ['openTerminalTab'], { label: '终端', notify: (message, type) => deps.uiHelper?.showToastNotification?.(message, type) }); return Object.freeze({ kind: 'terminal', label: '终端', icon: 'terminal', searchHint: '终端', persist: false, @@ -276,7 +276,7 @@ provider 只能修改自己的视图,跨模块动作通过组合者注入的 `sideChatSurfaceOwner.js` 组合独立渲染器、操作与输入提交,并提供原有 handle API。`side-chat/` 下的 `shell`、`composer-state`、`model-picker`、`references`、`message-actions`、`scrolling`、`persistence` 和 `draft-cache` 分别负责视图、状态投影、模型选择、引用卡片、回答动作、贴底、历史/输入持久化和跨卸载缓存。各模块通过组合者的 store、读取函数与回调连接,不互相引用;计时器、观察者和宿主监听由所属 owner 清理。 -组合入口保留发送/取消操作的结算顺序与原 handle 方法,当前约 560 行,因此保留在一个文件内;其余新模块均低于 500 行。 +组合入口保留发送/取消操作的结算顺序与原 handle 方法,当前约 770 行,因此保留在一个文件内;其余新模块均低于 500 行。 ## 11. Git 与代码查看器 provider diff --git a/modules/renderer/sideChatSurfaceOwner.js b/modules/renderer/sideChatSurfaceOwner.js index 8622b7b61..5d4083591 100644 --- a/modules/renderer/sideChatSurfaceOwner.js +++ b/modules/renderer/sideChatSurfaceOwner.js @@ -615,11 +615,6 @@ export async function mountSideChatSurface(container, { || hasUnsavedChanges || attachmentsOwner.count > 0 || messageEditor.isEditing() || form.hasAttribute('aria-busy')); }, - setVisible(visible) { - if (visible && !isDisposed && isHistoryLoaded) { - textarea.focus(); - } - }, focus() { if (!isDisposed && isHistoryLoaded) { textarea.focus(); diff --git a/modules/ui-system/side-pane/side-pane-dormancy.js b/modules/ui-system/side-pane/side-pane-dormancy.js index 1f04eeb81..68875a1f5 100644 --- a/modules/ui-system/side-pane/side-pane-dormancy.js +++ b/modules/ui-system/side-pane/side-pane-dormancy.js @@ -3,7 +3,7 @@ * - 隐藏超过 hiddenMs(默认 5 分钟); * - 属于别的对话的标签(scopeMode 'topic')离开当前对话超过 otherTopicMs,来回切换时不会马上重建; * - 同时挂着的视图超过 maxLiveViews 时,最久没显示的先休眠。 - * 不休眠:正在显示的、类型声明 dormancy 'keep' 的、视图报告自己正忙的(网页在加载或放声音、辅助对话在发送……), + * 不休眠:正在显示的、类型声明 dormancy 'keep' 的、视图报告自己正忙的(网页在加载或放声音……), * 忙的到期后隔 busyRetryMs 再看一次。 * 'limit-only' 的类型(浏览器)不按隐藏时长休眠,只在超过上限时参与淘汰:销毁网页会丢掉表单、登录和后退栈, * 代价远高于重建其他视图(对照 ZCode browserTabResidencyPolicy.ts 只按数量淘汰)。 diff --git a/modules/ui-system/side-pane/side-pane-types.js b/modules/ui-system/side-pane/side-pane-types.js index 85cde3a18..cd37f3dfa 100644 --- a/modules/ui-system/side-pane/side-pane-types.js +++ b/modules/ui-system/side-pane/side-pane-types.js @@ -60,7 +60,7 @@ * @property {() => void} [resume] 重新可见时调用 * @property {() => Promise | void} [dispose] 视图释放时调用,在 view scope 释放之前; * 资源都挂在 scope 上的 provider 可以不提供。视图休眠也会调它,要跨休眠保留的东西挂在 occurrence.signal 上 - * @property {() => boolean} [isBusy] 返回 true 时不休眠(网页在加载或放声音、辅助对话在发送或有没保存的编辑) + * @property {() => boolean} [isBusy] 返回 true 时不休眠(网页在加载或放声音、命令还在跑) * @property {() => unknown} [captureState] 休眠前调用,返回值(滚动位置之类)在重新挂载时作为 restoredState 交回 */ @@ -97,7 +97,7 @@ * none(默认)释放视图、保留标签,再显示时重新挂载;detach 对控制器来说和 none 一样,只是声明 * provider 自己把会话和画面挂在 occurrence 上、休眠时收进暂存区、重新挂载时接回去(终端),会话不动; * limit-only 不按隐藏时长休眠,只在挂着的视图超过上限时参与淘汰(浏览器); - * keep 不休眠,也不占视图上限的名额。只是暂时不能拆(有没发出去的输入)的用 handle.isBusy(),不用 keep + * keep 不休眠,也不占视图上限的名额(辅助对话:输入框、引用和滚动位置一直留在视图里)。只是偶尔忙一阵的用 handle.isBusy() * @property {SidePaneOpenTabEntry | null} [entry] 有时出现在新标签页里 * @property {(payload: object, tabs: readonly SidePaneTab[]) => SidePaneTab} [toTab] * openTab(payload) 先经过它变成标签;可以返回已有标签的 id 让重复打开落到同一个标签上 diff --git a/modules/ui-system/side-pane/terminalSideProvider.js b/modules/ui-system/side-pane/terminalSideProvider.js index 1e2eddd01..4658922f5 100644 --- a/modules/ui-system/side-pane/terminalSideProvider.js +++ b/modules/ui-system/side-pane/terminalSideProvider.js @@ -495,8 +495,11 @@ export function createTerminalSideProvider({ term.focus(); }); own.listen(restartBtn, 'click', () => { + // 要弹确认框时不抢焦点:应用确认框是异步的,这里聚焦终端会把焦点从确认框拉回来, + // 用户敲的字会进到背后的 shell。确认框关掉后它自己把焦点还给重启按钮 + const asks = Boolean(session.restartConfirm || (session.sessionId && !session.exited)); session.restart(); - term.focus(); + if (!asks) term.focus(); }); own.listen(clearBtn, 'click', () => { term.clear(); diff --git a/tests/side-chat-draft-restore.test.mjs b/tests/side-chat-draft-restore.test.mjs index f75649098..00af0e7b5 100644 --- a/tests/side-chat-draft-restore.test.mjs +++ b/tests/side-chat-draft-restore.test.mjs @@ -36,7 +36,7 @@ async function fixture(t, legacyInput = {}) { async deleteSideChatChild(...args) { deletions.push(args); return call('side-chat:delete-child', ...args); }, getChatHistory: async (_agent, topic) => JSON.parse(await fs.readFile(path.join(directory, 'agent', 'topics', topic, 'history.json'), 'utf8')) }; - function mountController() { + function mountController({ dormancy = null } = {}) { let controller; const wiring = createSideChatWiring({ doc, win: dom.window, chatAPI, chatRepository: { getHistory: (agent, _type, topic) => chatAPI.getChatHistory(agent, topic), saveHistory: async () => ({ success: true }) }, @@ -51,7 +51,7 @@ async function fixture(t, legacyInput = {}) { } }); controller = createSidePaneController({ root: doc.getElementById('pane'), tabListElement: doc.getElementById('tabs'), - contentContainer: doc.getElementById('content'), + contentContainer: doc.getElementById('content'), dormancy, tabTypes: [defineChatTabType({ provider: wiring.provider, onClosed: wiring.onTabClosed })] }); controller.setParent(descriptor.parent); const session = { controller, wiring }; @@ -144,3 +144,24 @@ test('automatic empty-child cleanup also removes its empty browser draft without assert.equal(f.drafts.read(f.descriptor).input, null); assert.equal(f.deletions.length, 1); }); + +// 辅助对话按 keep 常驻(同 ZCode 对非浏览器面板 forceMount):隐藏再久,视图、输入框和引用都还在 +test('a side chat hidden behind another tab stays mounted with its draft', async t => { + const f = await fixture(t, { composerStorage: 'local' }); + f.drafts.save(f.descriptor, { draft: 'half typed', references: [{ id: 'ref-1', text: 'first selection' }] }); + const { controller, wiring } = f.mountController({ dormancy: { hiddenMs: 30 } }); + controller.registerTabType({ kind: 'note', label: 'Note', provider: { mountTab: () => ({ dispose() {} }) } }); + await wiring.restoreSessions('agent', 'parent'); + const tab = controller.getSnapshot().tabs.find(item => item.kind === 'chat'); + controller.activateTab(tab.id); + controller.setVisible(true); + await new Promise(r => setTimeout(r, 20)); + const handle = controller.getTabHandle(tab.id); + handle.setDraft('half typed, then more'); + await controller.openTab({ id: 'note:1', kind: 'note', title: 'Note', closable: true, scopeMode: 'global' }); + await new Promise(r => setTimeout(r, 120)); + assert.equal(controller.getTabHandle(tab.id), handle, 'still the same mounted view'); + assert.deepEqual(controller.getViewResidency().dormant, []); + assert.equal(handle.getDraft(), 'half typed, then more'); + assert.deepEqual(handle.getReferences().map(ref => ref.id), ['ref-1']); +}); diff --git a/tests/side-chat-surface-owner.test.mjs b/tests/side-chat-surface-owner.test.mjs index 6a367969d..79fa5e33f 100644 --- a/tests/side-chat-surface-owner.test.mjs +++ b/tests/side-chat-surface-owner.test.mjs @@ -340,7 +340,7 @@ test('side chat message context menu offers per-role actions and deletes through repository: { ...base.repository, async saveHistory() { writes += 1; return { success: true }; } }, createRenderer(options) { const owned = base.createRenderer(options); - owned.renderer.removeMessageById = id => removed.push(id); + owned.renderer.removeMessageById = (id, save) => removed.push([id, save]); return owned; }, uiHelper: { @@ -379,7 +379,7 @@ test('side chat message context menu offers per-role actions and deletes through assert.ok(!assistantActions.includes('edit-again')); doc.querySelector('[data-side-chat-action="delete"]').click(); await waitFor(() => removed.length === 1); - assert.deepEqual(removed, ['a1']); + assert.deepEqual(removed, [['a1', false]], 'the side action saves first; renderer removal must not start another save'); assert.equal(writes, 1, 'one save per deletion'); openMenu('.message-item.assistant'); @@ -414,7 +414,7 @@ test('side chat edits a message in place and regenerates an answer with the side }, createRenderer(options) { const owned = base.createRenderer(options); - owned.renderer.removeMessageById = id => removed.push(id); + owned.renderer.removeMessageById = (id, save) => removed.push([id, save]); owned.renderer.updateMessageContent = (id, text) => rerendered.push([id, text]); return owned; }, @@ -464,7 +464,7 @@ test('side chat edits a message in place and regenerates an answer with the side menuAction('.message-item.assistant', 'regenerate').click(); const sent = await waitFor(() => caps.getSentRequest()); assert.deepEqual(saved.at(-1), ['topic-child-edit', []]); - assert.deepEqual(removed, ['u1', 'a1']); + assert.deepEqual(removed, [['u1', false], ['a1', false]], 'regenerate saves the trimmed history itself; renderer removal must not save again'); assert.equal(sent.content, 'first question'); assert.equal(sent.attachments.length, 1); assert.equal(sent.attachments[0].localPath, 'file:///a.txt'); diff --git a/tests/side-pane-dormancy.test.mjs b/tests/side-pane-dormancy.test.mjs index a8b0a98f7..dd6dafef0 100644 --- a/tests/side-pane-dormancy.test.mjs +++ b/tests/side-pane-dormancy.test.mjs @@ -157,7 +157,6 @@ test('busy views and keep-type views do not sleep', async () => { await h.controller.openTab(tab('probe:busy')); await h.controller.openTab(tab('probe:keep', { kind: 'pinned' })); await h.controller.openTab(tab('probe:front')); - h.busy.add('probe:busy'); await h.advance(20); assert.equal(h.live('probe:busy').length, 1); assert.equal(h.live('probe:keep').length, 1); diff --git a/tests/side-pane-terminal-lifecycle.test.mjs b/tests/side-pane-terminal-lifecycle.test.mjs index 144475972..89f9d88a8 100644 --- a/tests/side-pane-terminal-lifecycle.test.mjs +++ b/tests/side-pane-terminal-lifecycle.test.mjs @@ -155,6 +155,24 @@ test('restart asks with the app confirm dialog when there is one, and a second c } finally { answer.resolve(false); await g.cleanup(); } }); +test('the restart confirm dialog keeps focus instead of the terminal behind it', async () => { + const answer = Promise.withResolvers(); + let dialogButton = null; + const g = fixture({ uiHelper: { showConfirmDialog: () => { + dialogButton = g.doc.createElement('button'); + g.doc.body.appendChild(dialogButton); + dialogButton.focus(); // 应用确认框同步聚焦自己的按钮 + return answer.promise; + } } }); + try { + await g.provider.openTerminalTab(); + await until(() => g.status().dataset.state === 'connected'); + g.retry().click(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(g.doc.activeElement, dialogButton, 'typing goes to the dialog, not the shell'); + } finally { answer.resolve(false); await g.cleanup(); } +}); + test('restarting a shell that already exited does not ask about aborting commands', async () => { const h = fixture(); try { @@ -273,7 +291,7 @@ test('a live terminal view holds its buttons and size observer through the view const handle = await h.provider.openTerminalTab(); await until(() => handle.getSessionId() === 'view:1'); const live = terminalTab().resources; - assert.ok(live.byType.listener > 0, 'toolbar controls listen through the view scope'); + assert.equal(live.byType.listener, 3, 'workspace jump, clear and restart listen through the view scope'); assert.equal(live.byType.observer, 1); assert.equal(observers[0].targets.size, 1); diff --git a/tests/side-pane-visibility-animation.test.mjs b/tests/side-pane-visibility-animation.test.mjs index f63ea6009..4ecc38c5e 100644 --- a/tests/side-pane-visibility-animation.test.mjs +++ b/tests/side-pane-visibility-animation.test.mjs @@ -27,6 +27,9 @@ test('reopening while the close animation runs ends expanded, not collapsed', as await frame(); transitionEnd(); + // active / collapsed 决定实际布局(side-pane-shell.css),可见性判断也靠它们 + assert.ok(root.classList.contains('active')); + assert.ok(!root.classList.contains('collapsed')); assert.equal(root.getAttribute('aria-hidden'), null); assert.equal(visibility.isAnimating(), false); visibility.dispose(); @@ -56,6 +59,8 @@ test('closing while the open animation runs ends collapsed', async () => { await frame(); transitionEnd(); + assert.ok(root.classList.contains('collapsed')); + assert.ok(!root.classList.contains('active')); assert.equal(root.getAttribute('aria-hidden'), 'true'); assert.equal(visibility.isAnimating(), false); visibility.dispose();