Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/SIDE_PANE_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
5 changes: 0 additions & 5 deletions modules/renderer/sideChatSurfaceOwner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion modules/ui-system/side-pane/side-pane-dormancy.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* - 隐藏超过 hiddenMs(默认 5 分钟);
* - 属于别的对话的标签(scopeMode 'topic')离开当前对话超过 otherTopicMs,来回切换时不会马上重建;
* - 同时挂着的视图超过 maxLiveViews 时,最久没显示的先休眠。
* 不休眠:正在显示的、类型声明 dormancy 'keep' 的、视图报告自己正忙的(网页在加载或放声音、辅助对话在发送……),
* 不休眠:正在显示的、类型声明 dormancy 'keep' 的、视图报告自己正忙的(网页在加载或放声音……),
* 忙的到期后隔 busyRetryMs 再看一次。
* 'limit-only' 的类型(浏览器)不按隐藏时长休眠,只在超过上限时参与淘汰:销毁网页会丢掉表单、登录和后退栈,
* 代价远高于重建其他视图(对照 ZCode browserTabResidencyPolicy.ts 只按数量淘汰)。
Expand Down
4 changes: 2 additions & 2 deletions modules/ui-system/side-pane/side-pane-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
* @property {() => void} [resume] 重新可见时调用
* @property {() => Promise<void> | void} [dispose] 视图释放时调用,在 view scope 释放之前;
* 资源都挂在 scope 上的 provider 可以不提供。视图休眠也会调它,要跨休眠保留的东西挂在 occurrence.signal 上
* @property {() => boolean} [isBusy] 返回 true 时不休眠(网页在加载或放声音、辅助对话在发送或有没保存的编辑)
* @property {() => boolean} [isBusy] 返回 true 时不休眠(网页在加载或放声音、命令还在跑)
* @property {() => unknown} [captureState] 休眠前调用,返回值(滚动位置之类)在重新挂载时作为 restoredState 交回
*/

Expand Down Expand Up @@ -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 让重复打开落到同一个标签上
Expand Down
5 changes: 4 additions & 1 deletion modules/ui-system/side-pane/terminalSideProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
25 changes: 23 additions & 2 deletions tests/side-chat-draft-restore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) },
Expand All @@ -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 };
Expand Down Expand Up @@ -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']);
});
8 changes: 4 additions & 4 deletions tests/side-chat-surface-owner.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -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');
Expand Down
1 change: 0 additions & 1 deletion tests/side-pane-dormancy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 19 additions & 1 deletion tests/side-pane-terminal-lifecycle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);

Expand Down
5 changes: 5 additions & 0 deletions tests/side-pane-visibility-animation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading