From 3c5220b8a73e872a3de940764056d04a51179115 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:22:17 +0000 Subject: [PATCH 001/143] Side pane: find tabs by id without building CSS selectors Code viewer tab ids carry the file path. On Windows the backslashes are read as CSS escapes, so the selector never matched and keyboard focus fell to after cycling to, closing, or leaving the menu of such a tab; a path containing a quote made the selector throw and the mount fail. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../side-pane/side-pane-controller.js | 3 +- .../side-pane/side-pane-tab-strip.js | 6 +- .../side-pane/side-pane-tab-utils.js | 13 ++++ tests/side-pane-tab-id-lookup.test.mjs | 70 +++++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 tests/side-pane-tab-id-lookup.test.mjs diff --git a/modules/ui-system/side-pane/side-pane-controller.js b/modules/ui-system/side-pane/side-pane-controller.js index d90d60ad6..35dcb3306 100644 --- a/modules/ui-system/side-pane/side-pane-controller.js +++ b/modules/ui-system/side-pane/side-pane-controller.js @@ -15,6 +15,7 @@ import { createSidePaneShortcuts } from './side-pane-shortcuts.js'; import { createSidePaneLayoutStore, parseLayout, rememberBounded, serializeLayout } from './side-pane-persistence.js'; import { createSidePaneRootScope, createTabOccurrence } from './side-pane-occurrence.js'; import { selectDormantViews } from './side-pane-dormancy.js'; +import { findByTabId } from './side-pane-tab-utils.js'; /** @typedef {import('./side-pane-types.js').SidePaneTab} SidePaneTab */ /** @typedef {import('./side-pane-types.js').SidePaneTabType} SidePaneTabType */ @@ -477,7 +478,7 @@ export function createSidePaneController({ const pending = { promise: null, viewElement: null, canceled: false, onClosed }; const mounting = (async () => { - let view = contentContainer?.querySelector(`[data-tab-id="${tabId}"]`); + let view = findByTabId(contentContainer, tabId); if (!view && contentContainer) { view = doc.createElement('section'); view.className = 'side-pane-view'; diff --git a/modules/ui-system/side-pane/side-pane-tab-strip.js b/modules/ui-system/side-pane/side-pane-tab-strip.js index 964f5fe82..0721a4ad1 100644 --- a/modules/ui-system/side-pane/side-pane-tab-strip.js +++ b/modules/ui-system/side-pane/side-pane-tab-strip.js @@ -2,7 +2,7 @@ 'use strict'; import { createTabSortable } from './side-pane-tab-dnd.js'; -import { getTabIconName, resolveTabsOverflow } from './side-pane-tab-utils.js'; +import { findByTabId, getTabIconName, resolveTabsOverflow } from './side-pane-tab-utils.js'; // 标签标题悬停提示、面板切换动画、可拖拽排序的标签 const TAB_TOOLTIP_DELAY_MS = 1500; @@ -133,7 +133,7 @@ export function createSidePaneTabStrip({ function syncStatus() { const current = statusTabId ? getStatus() : null; if (!current) return; - const btn = tabListElement.querySelector(`.side-pane-tab[data-tab-id="${statusTabId}"]`); + const btn = findByTabId(tabListElement, statusTabId, '.side-pane-tab[data-tab-id]'); const dot = btn?.querySelector('.side-pane-tab-status'); if (!dot) return; const { status = 'unknown', text = '' } = current; @@ -294,7 +294,7 @@ export function createSidePaneTabStrip({ syncStatus, hideTooltip, focusTab(tabId) { - tabListElement.querySelector(`[role="tab"][data-tab-id="${tabId}"]`)?.focus?.(); + findByTabId(tabListElement, tabId, '[role="tab"][data-tab-id]')?.focus?.(); }, dispose() { disposed = true; diff --git a/modules/ui-system/side-pane/side-pane-tab-utils.js b/modules/ui-system/side-pane/side-pane-tab-utils.js index 0da0acbcd..0c908b81c 100644 --- a/modules/ui-system/side-pane/side-pane-tab-utils.js +++ b/modules/ui-system/side-pane/side-pane-tab-utils.js @@ -145,3 +145,16 @@ export function moveIdBefore(ids, activeId, overId) { next.splice(to, 0, activeId); return next; } + +/** + * 按 data-tab-id 找元素。标签 id 里可能有 Windows 路径的反斜杠或引号(code-viewer:C:\proj\a.js), + * 拼进属性选择器会匹配不到甚至抛 SyntaxError,所以逐个比对属性值。 + */ +export function findByTabId(container, tabId, selector = '[data-tab-id]') { + if (!container || tabId == null) return null; + const want = String(tabId); + for (const el of container.querySelectorAll(selector)) { + if (el.getAttribute('data-tab-id') === want) return el; + } + return null; +} diff --git a/tests/side-pane-tab-id-lookup.test.mjs b/tests/side-pane-tab-id-lookup.test.mjs new file mode 100644 index 000000000..080a54146 --- /dev/null +++ b/tests/side-pane-tab-id-lookup.test.mjs @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { createSidePaneController } from '../modules/ui-system/side-pane/side-pane-controller.js'; +import { findByTabId } from '../modules/ui-system/side-pane/side-pane-tab-utils.js'; + +// 代码查看器的标签 id 是 code-viewer:<文件路径>,Windows 路径带反斜杠,类 Unix 路径可以带引号 +const WINDOWS_ID = 'code-viewer:C:\\Users\\roxy\\proj\\a.js'; +const QUOTED_ID = 'code-viewer:/tmp/we"ird.js'; + +function createPaneDom() { + const dom = new JSDOM(` +
+
+ + `, { pretendToBeVisual: true }); + const doc = dom.window.document; + const root = doc.getElementById('vcpSidePane'); + return { + dom, + doc, + root, + options: { + root, + resizerHandle: doc.getElementById('resizerRight'), + tabListElement: root.querySelector('.side-pane-tabs'), + contentContainer: root.querySelector('.side-pane-content-container') + } + }; +} + +test('findByTabId matches ids with backslashes and quotes literally', () => { + const { dom, doc } = createPaneDom(); + for (const id of [WINDOWS_ID, QUOTED_ID]) { + const el = doc.createElement('button'); + el.setAttribute('data-tab-id', id); + doc.body.append(el); + assert.equal(findByTabId(doc.body, id), el); + } + assert.equal(findByTabId(doc.body, 'code-viewer:C:Usersroxyproja.js'), null); + dom.window.close(); +}); + +for (const id of [WINDOWS_ID, QUOTED_ID]) { + test(`a tab whose id is ${JSON.stringify(id)} mounts and takes keyboard focus when cycled to`, async () => { + const { dom, doc, root, options } = createPaneDom(); + const controller = createSidePaneController({ + ...options, + providers: { + probe: { mountTab(tab, view) { view.textContent = tab.title; return { focus() {}, dispose() {} }; } } + } + }); + try { + await controller.openTab({ id: 'other', kind: 'probe', title: 'Other', closable: true, scopeMode: 'global' }); + await controller.openTab({ id, kind: 'probe', title: 'a.js', closable: true, scopeMode: 'global' }); + assert.equal(findByTabId(root.querySelector('.side-pane-content-container'), id)?.textContent, 'a.js'); + + controller.activateTab('other'); + controller.cycleTab(1); + assert.equal(controller.getSnapshot().activeTabId, id); + assert.equal(doc.activeElement?.getAttribute('data-tab-id'), id, 'focus lands on the tab, not on '); + } finally { + await controller.dispose(); + dom.window.close(); + } + }); +} From 2200d12803ea515c9badd8727602ab052cfadaca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:22:28 +0000 Subject: [PATCH 002/143] UI system gate: scope the tool presentation styles and drop the stray literal colour check:ui-system was red on the side pane branch. tool-presentation.css had 101 selectors outside .vcp-ui-scope, an !important on the reduced-motion rule and two fixed 11px sizes; components.css (from upstream #234) fell back to a hard-coded indigo because --vcp-ui-primary is not a token. Every tool presentation selector now sits under .vcp-ui-scope (the chat messages container already carries it, and the file is alone in its layer, so the uniform specificity bump changes no cascade). The reduced-motion rule reuses the shimmer selector instead of !important, the 11px sizes use --vcp-ui-text-xs, and the pinned window button uses the accent token. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UCGGgmHsCToooXTgSXpmq9 --- styles/ui-system/components.css | 4 +- styles/ui-system/tool-presentation.css | 196 ++++++++++++------------- 2 files changed, 100 insertions(+), 100 deletions(-) diff --git a/styles/ui-system/components.css b/styles/ui-system/components.css index 418b7df8b..ddff52098 100644 --- a/styles/ui-system/components.css +++ b/styles/ui-system/components.css @@ -541,8 +541,8 @@ } :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned { - color: var(--vcp-ui-primary, #6366f1); - background: var(--vcp-ui-primary-bg, rgba(99, 102, 241, 0.16)); + color: var(--vcp-ui-accent); + background: color-mix(in srgb, var(--vcp-ui-accent) 16%, transparent); } :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned .vcp-ui-icon, diff --git a/styles/ui-system/tool-presentation.css b/styles/ui-system/tool-presentation.css index 436a2e39c..be8a90316 100644 --- a/styles/ui-system/tool-presentation.css +++ b/styles/ui-system/tool-presentation.css @@ -1,12 +1,12 @@ /* Optional presentation; Legacy is untouched. Theme and typography stay owned * by the existing appearance profile, including role wallpapers. */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="compact"], -html[data-ui-mode="next"] [data-vcp-tool-presentation="grouped"] { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="compact"], +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="grouped"] { --vcp-tool-row-height: 38px; } -html[data-ui-mode="next"][data-vcp-density="compact"] [data-vcp-tool-presentation] { --vcp-tool-row-height: 32px; } -html[data-ui-mode="next"][data-vcp-density="relaxed"] [data-vcp-tool-presentation] { --vcp-tool-row-height: 44px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented { +html[data-ui-mode="next"][data-vcp-density="compact"] .vcp-ui-scope [data-vcp-tool-presentation] { --vcp-tool-row-height: 32px; } +html[data-ui-mode="next"][data-vcp-density="relaxed"] .vcp-ui-scope [data-vcp-tool-presentation] { --vcp-tool-row-height: 44px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented { --vcp-tool-presented-font-family: var(--vcp-tool-card-font-family, var(--vcp-chat-font-family)); --vcp-tool-presented-font-size: var(--vcp-ui-text-caption, 13px); --vcp-tool-presented-white-space: normal; @@ -32,11 +32,11 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented { color: var(--vcp-ui-text-primary); font-family: var(--vcp-tool-card-font-family, inherit); } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented::before, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented::after { display: none; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-summary, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-header, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-header { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented::before, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented::after { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-summary, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-header, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-header { display: flex; gap: 5px; padding: 0; @@ -47,7 +47,7 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vc min-width: 0; flex-wrap: nowrap; } -html[data-ui-mode="next"] .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-tool-process-toggle { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { display: flex; width: 100%; min-width: 0; @@ -65,13 +65,13 @@ html[data-ui-mode="next"] .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-t cursor: pointer; text-align: left; } -html[data-ui-mode="next"] .vcp-tool-result-header > .vcp-tool-row-toggle { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-result-header > .vcp-tool-row-toggle { width: auto; flex: 1; } -html[data-ui-mode="next"] .vcp-tool-row-toggle:hover, html[data-ui-mode="next"] .vcp-tool-process-toggle:hover { background: var(--vcp-ui-bg-2); } -html[data-ui-mode="next"] .vcp-tool-row-toggle:focus-visible, html[data-ui-mode="next"] .vcp-tool-process-toggle:focus-visible, html[data-ui-mode="next"] .vcp-tool-process-artifact:focus-visible { outline: 2px solid var(--vcp-ui-accent); outline-offset: -2px; } -html[data-ui-mode="next"] .vcp-tool-row-chevron { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle:hover, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle:hover { background: var(--vcp-ui-bg-2); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle:focus-visible, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle:focus-visible, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-artifact:focus-visible { outline: 2px solid var(--vcp-ui-accent); outline-offset: -2px; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-chevron { width: 7px; height: 7px; flex: 0 0 7px; @@ -79,44 +79,44 @@ html[data-ui-mode="next"] .vcp-tool-row-chevron { border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); } -html[data-ui-mode="next"] [aria-expanded="true"] > .vcp-tool-row-chevron { transform: rotate(45deg); } -html[data-ui-mode="next"] .vcp-tool-row-title { overflow-wrap: anywhere; min-width: 0; color: var(--vcp-ui-text-primary); } -html[data-ui-mode="next"] .vcp-tool-row-resource { overflow-wrap: anywhere; min-width: 0; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] .vcp-tool-row-state { margin-left: auto; white-space: nowrap; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] [data-vcp-tool-state="success"] .vcp-tool-row-state { color: var(--vcp-ui-success); } -html[data-ui-mode="next"] [data-vcp-tool-state="failed"] .vcp-tool-row-state { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-state="waiting"] .vcp-tool-row-state, -html[data-ui-mode="next"] [data-vcp-tool-state="stopped"] .vcp-tool-row-state { color: var(--vcp-ui-warning, var(--vcp-ui-palette-amber)); } -html[data-ui-mode="next"] [data-vcp-tool-state="running"] .vcp-tool-row-state { color: var(--vcp-ui-accent); } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-delete-btn { flex: 0 0 24px; margin-right: 6px; opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:hover .vcp-tool-result-delete-btn, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:focus-within .vcp-tool-result-delete-btn { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="compact"] .vcp-role-divider, -html[data-ui-mode="next"] [data-vcp-tool-presentation="grouped"] .vcp-role-divider { margin: 4px 0; font-size: 11px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-details { max-height: 360px; overflow: auto; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-content { +html[data-ui-mode="next"] .vcp-ui-scope [aria-expanded="true"] > .vcp-tool-row-chevron { transform: rotate(45deg); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-title { overflow-wrap: anywhere; min-width: 0; color: var(--vcp-ui-text-primary); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-resource { overflow-wrap: anywhere; min-width: 0; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-state { margin-left: auto; white-space: nowrap; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="success"] .vcp-tool-row-state { color: var(--vcp-ui-success); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="failed"] .vcp-tool-row-state { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="waiting"] .vcp-tool-row-state, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="stopped"] .vcp-tool-row-state { color: var(--vcp-ui-warning, var(--vcp-ui-palette-amber)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="running"] .vcp-tool-row-state { color: var(--vcp-ui-accent); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-delete-btn { flex: 0 0 24px; margin-right: 6px; opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:hover .vcp-tool-result-delete-btn, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:focus-within .vcp-tool-result-delete-btn { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="compact"] .vcp-role-divider, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="grouped"] .vcp-role-divider { margin: 4px 0; font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-details { max-height: 360px; overflow: auto; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-content { margin: 0; padding: 6px 12px 12px 28px; background: transparent; border: 0; min-width: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-call-summary-content { display: none; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-call-summary-content { display: block; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented pre { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-call-summary-content { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-call-summary-content { display: block; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented pre { white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-height: 320px; overflow: auto; } -html[data-ui-mode="next"] .vcp-tool-process { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process { border: 1px solid var(--vcp-ui-border); border-radius: var(--vcp-ui-radius-md, 8px); margin: 10px 0; @@ -124,12 +124,12 @@ html[data-ui-mode="next"] .vcp-tool-process { overflow: hidden; background: var(--vcp-ui-bg-0); } -html[data-ui-mode="next"] .vcp-tool-process-toggle { background: var(--vcp-ui-bg-1); border-radius: 0; } -html[data-ui-mode="next"] .vcp-tool-process-title { min-width: 0; overflow-wrap: anywhere; } -html[data-ui-mode="next"] .vcp-tool-process-stats { margin-left: auto; font-size: var(--vcp-ui-text-sm, 12px); overflow-wrap: anywhere; } -html[data-ui-mode="next"] .vcp-tool-process-body[hidden] { display: none; } -html[data-ui-mode="next"] .vcp-tool-process-body > .vcp-tool-presented { margin: 0; border-top: 1px solid var(--vcp-ui-border); } -html[data-ui-mode="next"] .vcp-tool-process-artifact { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { background: var(--vcp-ui-bg-1); border-radius: 0; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-title { min-width: 0; overflow-wrap: anywhere; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-stats { margin-left: auto; font-size: var(--vcp-ui-text-sm, 12px); overflow-wrap: anywhere; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-body[hidden] { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-body > .vcp-tool-presented { margin: 0; border-top: 1px solid var(--vcp-ui-border); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-artifact { color: var(--vcp-ui-text-accent); background: transparent; border: 0; @@ -142,20 +142,20 @@ html[data-ui-mode="next"] .vcp-tool-process-artifact { cursor: pointer; } @media (max-width: 640px) { - html[data-ui-mode="next"] .vcp-tool-row-toggle { flex-wrap: wrap; gap: 6px; } - html[data-ui-mode="next"] .vcp-tool-row-state { margin-left: auto; } - html[data-ui-mode="next"] .vcp-tool-process-toggle { flex-wrap: wrap; } - html[data-ui-mode="next"] .vcp-tool-process-stats { margin-left: 16px; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle { flex-wrap: wrap; gap: 6px; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-state { margin-left: auto; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { flex-wrap: wrap; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-stats { margin-left: 16px; } } /* 单行合并 / 整轮折叠:无框单行,一次调用一行;紧跟的同名结果收进请求行,展开后才显示。 */ -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) { --vcp-tool-call-color: var(--vcp-ui-text-3, var(--vcp-ui-text-secondary)); --vcp-tool-call-hover: var(--vcp-ui-text-2, var(--vcp-ui-text-secondary)); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-role-divider { margin: 4px 0; font-size: 11px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented { margin: 2px 0; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-role-divider { margin: 4px 0; font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented { margin: 2px 0; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { width: auto; max-width: 100%; min-height: 24px; @@ -168,10 +168,10 @@ html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-t color: var(--vcp-tool-call-color); transition: color .1s; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):hover { background: transparent; color: var(--vcp-tool-call-hover); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { flex: none; color: inherit; white-space: nowrap; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-process-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-resource { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):hover { background: transparent; color: var(--vcp-tool-call-hover); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { flex: none; color: inherit; white-space: nowrap; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-process-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-resource { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -179,20 +179,20 @@ html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-t overflow-wrap: normal; font-size: inherit; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-state { flex: none; margin-left: 0; color: inherit; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon { display: inline-flex; flex: none; width: 16px; height: 16px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon svg { width: 100%; height: 100%; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-diff { flex: none; font-family: var(--vcp-ui-font-mono, ui-monospace, Consolas, monospace); font-size: 11px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-add { color: var(--vcp-ui-success); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-del { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-state { flex: none; margin-left: 0; color: inherit; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon { display: inline-flex; flex: none; width: 16px; height: 16px; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon svg { width: 100%; height: 100%; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-diff { flex: none; font-family: var(--vcp-ui-font-mono, ui-monospace, Consolas, monospace); font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-add { color: var(--vcp-ui-success); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-del { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } /* 并入请求行的结果:收起时不占位,展开后只留一个小标题“输出”。 - * 旧层给结果气泡写了 display:block !important,这里只能藏标题、去掉外边距,正文由上面的收起规则隐藏。 */ -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) { margin: 0; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) > .vcp-tool-result-header { display: none; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"] .vcp-tool-row-toggle { min-height: 20px; padding-left: 24px; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented > :is(.vcp-tool-details, .vcp-tool-result-collapsible-content, .vcp-tool-call-summary-content) { padding: 8px 0 4px 24px; } + * 旧层给结果气泡写了强制的 display:block(important 级),这里只能藏标题、去掉外边距,正文由上面的收起规则隐藏。 */ +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) { margin: 0; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) > .vcp-tool-result-header { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"] .vcp-tool-row-toggle { min-height: 20px; padding-left: 24px; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented > :is(.vcp-tool-details, .vcp-tool-result-collapsible-content, .vcp-tool-call-summary-content) { padding: 8px 0 4px 24px; } /* 等待结果时动作词的微光,只在回复生成中出现。 */ -html[data-ui-mode="next"] .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { +html[data-ui-mode="next"] .vcp-ui-scope .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { background: linear-gradient(90deg, var(--vcp-tool-call-color) 0%, var(--vcp-ui-text-primary) 50%, var(--vcp-tool-call-color) 100%); background-size: 200% 100%; -webkit-background-clip: text; @@ -202,33 +202,33 @@ html[data-ui-mode="next"] .message-item.streaming :is([data-vcp-tool-presentatio } @keyframes vcp-tool-call-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } } @media (prefers-reduced-motion: reduce) { - html[data-ui-mode="next"] [data-vcp-tool-pending="true"] .vcp-tool-row-title { animation: none !important; } + html[data-ui-mode="next"] .vcp-ui-scope .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { animation: none; } } /* 单行合并:动作词加粗,箭头在行尾、悬停或展开时出现;问题状态用虚线下划线,悬停看报错。 */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { font-size: var(--vcp-ui-text-body, 14px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { font-weight: 500; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) > .vcp-tool-row-chevron { order: 1; opacity: 0; transition: opacity .2s, transform .2s; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):is(:hover, [aria-expanded="true"]) > .vcp-tool-row-chevron { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] [data-vcp-tool-call-state] .vcp-tool-row-state { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] { margin: 2px 0; border: 0; border-radius: 0; background: transparent; overflow: visible; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] .vcp-tool-process-stats { margin-left: 0; font-size: inherit; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body { margin: 4px 0 4px 8px; padding-left: 14px; border-left: 1px solid var(--vcp-ui-border); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-artifact { border-top: 0; padding: 4px 0 4px 22px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { font-size: var(--vcp-ui-text-body, 14px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { font-weight: 500; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) > .vcp-tool-row-chevron { order: 1; opacity: 0; transition: opacity .2s, transform .2s; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):is(:hover, [aria-expanded="true"]) > .vcp-tool-row-chevron { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] [data-vcp-tool-call-state] .vcp-tool-row-state { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] { margin: 2px 0; border: 0; border-radius: 0; background: transparent; overflow: visible; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] .vcp-tool-process-stats { margin-left: 0; font-size: inherit; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body { margin: 4px 0 4px 8px; padding-left: 14px; border-left: 1px solid var(--vcp-ui-border); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-artifact { border-top: 0; padding: 4px 0 4px 22px; } /* 整轮折叠:图标悬停换成箭头,标题与摘要之间用小圆点分隔,失败时摘要显示红色报错首行。 */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle { gap: 0; font-size: var(--vcp-ui-text-caption, 13px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading { position: relative; display: inline-flex; flex: none; width: 16px; height: 16px; margin-right: 6px; align-items: center; justify-content: center; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-icon { width: 14px; height: 14px; transition: opacity .1s; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-chevron { position: absolute; inset: 0; margin: auto; opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-icon { opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-chevron { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-dot { flex: none; width: 2px; height: 2px; margin: 0 8px; border-radius: 1px; background: currentColor; opacity: .7; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle :is(.vcp-tool-row-state, .vcp-tool-row-diff) { margin-left: 10px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] [data-vcp-tool-call-state="failed"] .vcp-tool-row-toggle .vcp-tool-row-resource { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] { margin: 4px 0 10px; border: 0; border-radius: 0; background: transparent; overflow: visible; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle { gap: 0; font-size: var(--vcp-ui-text-caption, 13px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading { position: relative; display: inline-flex; flex: none; width: 16px; height: 16px; margin-right: 6px; align-items: center; justify-content: center; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-icon { width: 14px; height: 14px; transition: opacity .1s; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-chevron { position: absolute; inset: 0; margin: auto; opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-icon { opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-chevron { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-dot { flex: none; width: 2px; height: 2px; margin: 0 8px; border-radius: 1px; background: currentColor; opacity: .7; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle :is(.vcp-tool-row-state, .vcp-tool-row-diff) { margin-left: 10px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] [data-vcp-tool-call-state="failed"] .vcp-tool-row-toggle .vcp-tool-row-resource { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] { margin: 4px 0 10px; border: 0; border-radius: 0; background: transparent; overflow: visible; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle { width: 100%; min-height: 33px; padding: 0 0 8px; @@ -236,8 +236,8 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-proce border-bottom: .5px solid var(--vcp-ui-border); font-size: var(--vcp-ui-text-body, 14px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle > .vcp-tool-row-chevron { order: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] .vcp-tool-process-stats { margin-left: 0; color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body { padding-top: 8px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-artifact { border-top: 0; padding: 6px 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle > .vcp-tool-row-chevron { order: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] .vcp-tool-process-stats { margin-left: 0; color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body { padding-top: 8px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-artifact { border-top: 0; padding: 6px 0; } From 58c8cf11e9c1eb70e201cba8a120e26cc1d325e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:24:56 +0000 Subject: [PATCH 003/143] Side pane: a tab whose view fails to mount shows an error and a retry Before, a provider that threw during mount (a corrupt restored payload, a failed lazy import, a bug) left its tab selected over a blank pane, with the error only in the console. The view now says the tab could not open, shows the error, and offers a retry; showing the tab again still retries as before, and closing the tab removes the error view. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../contracts/generated/chat-event-graph.json | 27 ++++++--- .../side-pane/side-pane-controller.js | 56 ++++++++++++++++++- styles/ui-system/side-pane-shell.css | 40 +++++++++++++ tests/side-pane-controller.test.mjs | 50 +++++++++++++++-- tests/side-pane-leak.test.mjs | 5 +- 5 files changed, 161 insertions(+), 17 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 69770eda3..b0339c0ce 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -4304,17 +4304,22 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1023, + "line": 497, + "kind": "custom-event-listener" + }, + { + "file": "modules/ui-system/side-pane/side-pane-controller.js", + "line": 1076, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1029, + "line": 1082, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1043, + "line": 1096, "kind": "custom-event-listener" }, { @@ -5885,19 +5890,25 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1023, + "line": 497, + "kind": "custom-event-listener", + "match": "retry.addEventListener('click'" + }, + { + "file": "modules/ui-system/side-pane/side-pane-controller.js", + "line": 1076, "kind": "custom-event-listener", "match": "toggleNotificationsBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1029, + "line": 1082, "kind": "custom-event-listener", "match": "expandButton.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1043, + "line": 1096, "kind": "custom-event-listener", "match": "closeSidePaneBtn.addEventListener('click'" }, @@ -24914,7 +24925,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1063, + "line": 1116, "kind": "custom-event-listener" }, { @@ -25008,7 +25019,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1063, + "line": 1116, "kind": "custom-event-listener", "match": "win?.addEventListener?.('resize'" }, diff --git a/modules/ui-system/side-pane/side-pane-controller.js b/modules/ui-system/side-pane/side-pane-controller.js index 35dcb3306..fe00be598 100644 --- a/modules/ui-system/side-pane/side-pane-controller.js +++ b/modules/ui-system/side-pane/side-pane-controller.js @@ -471,6 +471,50 @@ export function createSidePaneController({ pendingTabMounts.delete(tabId); } + // 挂载失败的标签:tabId -> 显示出错提示的视图 + const failedMounts = new Map(); + + function clearMountFailure(tabId) { + failedMounts.get(tabId)?.remove?.(); + failedMounts.delete(tabId); + } + + function showMountFailure(tabId, view, error, mountOptions) { + view.replaceChildren(); + const box = doc.createElement('div'); + box.className = 'side-pane-mount-error'; + box.setAttribute('role', 'alert'); + const title = doc.createElement('div'); + title.className = 'side-pane-mount-error-title'; + title.textContent = '这个标签没能打开'; + const detail = doc.createElement('div'); + detail.className = 'side-pane-mount-error-detail'; + detail.textContent = error?.message || String(error || '未知错误'); + const retry = doc.createElement('button'); + retry.type = 'button'; + retry.className = 'side-pane-mount-error-retry'; + retry.textContent = '重试'; + retry.addEventListener('click', () => { + if (isDisposed || failedMounts.get(tabId) !== view) return; + const refocus = view.contains(doc.activeElement); + ensureTabMounted(tabId, mountOptions) + .then(entry => { + if (!entry || isDisposed) return; + syncViewPanels(); + if (refocus && state.activeTabId === tabId) entry.handle?.focus?.(); + }) + .catch(retryError => { + console.error(`[SidePaneController] Retrying the mount of tab "${tabId}" failed:`, retryError); + if (!isDisposed) syncViewPanels(); + if (refocus) failedMounts.get(tabId)?.querySelector?.('.side-pane-mount-error-retry')?.focus?.(); + }); + }); + box.append(title, detail, retry); + view.append(box); + failedMounts.set(tabId, view); + syncViewPanels(); + } + function ensureTabMounted(tabId, { provider, payload, kind = payload?.kind, ariaLabel = null, onClosed = null }) { const mounted = mountedTabMap.get(tabId); if (mounted) return Promise.resolve(mounted); @@ -478,6 +522,8 @@ export function createSidePaneController({ const pending = { promise: null, viewElement: null, canceled: false, onClosed }; const mounting = (async () => { + // 上次挂载失败留下的提示页不复用,换一个干净的视图重新挂 + clearMountFailure(tabId); let view = findByTabId(contentContainer, tabId); if (!view && contentContainer) { view = doc.createElement('section'); @@ -505,9 +551,13 @@ export function createSidePaneController({ }) : null; } catch (error) { - // 挂载失败不留空的视图壳,下次显示时重新挂 await tabOccurrence.closeView('mount-failed').catch(() => {}); - view.remove?.(); + // 标签还在、侧栏没拆:留一页出错提示和重试按钮,不让用户对着空白页 + if (!isDisposed && !pending.canceled && state.tabs.some(t => t.id === tabId)) { + showMountFailure(tabId, view, error, { provider, payload, kind, ariaLabel, onClosed }); + } else { + view.remove?.(); + } throw error; } if (isDisposed || pending.canceled || !state.tabs.some(t => t.id === tabId)) { @@ -601,6 +651,7 @@ export function createSidePaneController({ || entry?.viewElement?.contains(origin) || origin?.closest?.('[data-tab-id]')?.getAttribute('data-tab-id') === tab.id; entry?.viewElement?.remove(); + clearMountFailure(tab.id); if (mountedTabMap.get(tab.id) === entry) mountedTabMap.delete(tab.id); viewTimes.delete(tab.id); dormantTabs.delete(tab.id); @@ -1005,6 +1056,7 @@ export function createSidePaneController({ entry.viewElement?.remove?.(); }); mountedTabMap.clear(); + for (const tabId of [...failedMounts.keys()]) clearMountFailure(tabId); for (const tabId of pendingTabMounts.keys()) cancelPendingMount(tabId); tabRegistry.dispose(); await Promise.allSettled([...disposePromises, tabCloseOwner.dispose()]); diff --git a/styles/ui-system/side-pane-shell.css b/styles/ui-system/side-pane-shell.css index 08368e2c4..f6271343e 100644 --- a/styles/ui-system/side-pane-shell.css +++ b/styles/ui-system/side-pane-shell.css @@ -161,3 +161,43 @@ html #vcpSidePane .notifications-sidebar.active:where(.vcp-ui-scope, .vcp-ui-sco } + +/* 标签挂载失败:出错提示和重试按钮,代替空白页 */ +html .side-pane-mount-error:where(.vcp-ui-scope, .vcp-ui-scope *) { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + height: 100%; + padding: 24px; + text-align: center; +} + +html .side-pane-mount-error-title:where(.vcp-ui-scope, .vcp-ui-scope *) { + font-size: var(--vcp-ui-font-size); + font-weight: 600; +} + +html .side-pane-mount-error-detail:where(.vcp-ui-scope, .vcp-ui-scope *) { + max-width: 90%; + font-size: calc(var(--vcp-ui-font-size) - 2px); + opacity: 0.65; + white-space: pre-line; + word-break: break-all; +} + +html .side-pane-mount-error-retry:where(.vcp-ui-scope, .vcp-ui-scope *) { + margin-top: 6px; + padding: 5px 14px; + border: 1px solid var(--border-color, var(--vcp-ui-port-color-rgba-255-255-255-0-16)); + border-radius: 6px; + background: transparent; + color: inherit; + font-size: calc(var(--vcp-ui-font-size) - 2px); + cursor: pointer; +} + +html .side-pane-mount-error-retry:hover:where(.vcp-ui-scope, .vcp-ui-scope *) { + background: var(--vcp-ui-port-color-rgba-127-127-127-0-16); +} diff --git a/tests/side-pane-controller.test.mjs b/tests/side-pane-controller.test.mjs index ed9cf096d..be3a3c843 100644 --- a/tests/side-pane-controller.test.mjs +++ b/tests/side-pane-controller.test.mjs @@ -291,29 +291,69 @@ for (const action of ['select another tab', 'hide the pane', 'focus the main inp }); } -test('SidePaneController does not leave an empty view behind when a mount fails', async () => { +test('a failed mount shows an error with a retry instead of a blank pane', async () => { const { dom, root, options } = createPaneDom(); + let attempts = 0; const controller = createSidePaneController({ ...options, providers: { - broken: { - async mountTab() { throw new Error('mount failed'); } + flaky: { + async mountTab(tab, view) { + attempts++; + view.textContent = 'half drawn'; + if (attempts === 1) throw new Error('mount failed'); + view.textContent = 'ready'; + return { focus() {}, dispose() {} }; + } } } }); const originalError = console.error; console.error = () => {}; try { - await controller.openTab({ id: 'broken', kind: 'broken', title: 'Broken', closable: true, scopeMode: 'global' }).catch(() => {}); + await assert.rejects(controller.openTab({ id: 'flaky', kind: 'flaky', title: 'Flaky', closable: true, scopeMode: 'global' }), /mount failed/); + const views = root.querySelectorAll('.side-pane-view[data-tab-id="flaky"]'); + assert.equal(views.length, 1); + assert.equal(views[0].hidden, false, 'the failed tab stays the visible one'); + assert.equal(views[0].querySelector('[role="alert"]').textContent.includes('mount failed'), true); + assert.equal(views[0].textContent.includes('half drawn'), false, 'what the provider half drew is cleared'); + + views[0].querySelector('.side-pane-mount-error-retry').click(); + await new Promise(resolve => setTimeout(resolve, 0)); + assert.equal(attempts, 2); + const mounted = root.querySelectorAll('.side-pane-view[data-tab-id="flaky"]'); + assert.equal(mounted.length, 1); + assert.equal(mounted[0].textContent, 'ready'); + assert.equal(mounted[0].hidden, false); + assert.ok(controller.getTabHandle('flaky')); } finally { console.error = originalError; } - assert.equal(root.querySelector('.side-pane-view[data-tab-id="broken"]'), null); await controller.dispose(); dom.window.close(); }); +test('closing a tab whose mount failed removes its error view', async () => { + const { dom, root, options } = createPaneDom(); + const controller = createSidePaneController({ + ...options, + providers: { broken: { async mountTab() { throw new Error('mount failed'); } } } + }); + const originalError = console.error; + console.error = () => {}; + try { + await controller.openTab({ id: 'broken', kind: 'broken', title: 'Broken', closable: true, scopeMode: 'global' }).catch(() => {}); + assert.ok(root.querySelector('.side-pane-view[data-tab-id="broken"]')); + await controller.closeTab('broken'); + assert.equal(root.querySelector('.side-pane-view[data-tab-id="broken"]'), null); + } finally { + console.error = originalError; + } + await controller.dispose(); + dom.window.close(); +}); + test('SidePaneController still closes a tab whose dispose throws', async () => { const { dom, root, options } = createPaneDom(); const controller = createSidePaneController({ diff --git a/tests/side-pane-leak.test.mjs b/tests/side-pane-leak.test.mjs index 05872b158..001fb1f40 100644 --- a/tests/side-pane-leak.test.mjs +++ b/tests/side-pane-leak.test.mjs @@ -203,7 +203,7 @@ function probeFixture() { residency: controller.getViewResidency() }); return { - controller, mounts, measure, + controller, mounts, measure, root, hold() { let open; gate = { promise: new Promise(resolve => { open = resolve; }) }; @@ -255,7 +255,8 @@ test('a view whose mount throws releases what it had set up, and the tab can mou await settle(); const failed = h.mounts.at(-1); assert.equal(failed.released, 1, 'what the provider put on the view scope is released'); - assert.equal(h.measure().views, 0, 'no empty view shell is left'); + assert.equal(h.measure().views, 1, 'only the error page is left, not an empty shell'); + assert.ok(h.root.querySelector(`.side-pane-view[data-tab-id="${id}"] [role="alert"]`), 'the failed tab says it failed'); // 标签还在:再显示一次就重新挂上 h.controller.activateTab(id); From e046c6efe33043904478e4269779f607ae2e24af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:25:32 +0000 Subject: [PATCH 004/143] Renderer: a side pane that fails to start no longer takes the chat down with it initWorkspaceSidePane ran inside the main startup try block ahead of the filter manager and setupEventListeners, so any exception while building the side pane skipped the rest of startup and left the send button and the other main window bindings dead. It now runs in its own try/catch and reports the failure as a toast. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../contracts/generated/chat-event-graph.json | 4 +- renderer.js | 56 ++++++++++--------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index b0339c0ce..23efc5b40 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -30168,7 +30168,7 @@ "producers": [ { "file": "renderer.js", - "line": 1072, + "line": 1078, "kind": "custom-event-create" } ], @@ -30176,7 +30176,7 @@ "evidence": [ { "file": "renderer.js", - "line": 1072, + "line": 1078, "kind": "custom-event-create", "match": "new CustomEvent('vcp-renderer-ready'" } diff --git a/renderer.js b/renderer.js index dde25c02c..b2d4b6790 100755 --- a/renderer.js +++ b/renderer.js @@ -981,31 +981,37 @@ mainChatSettingsPresentationOwner.configureStartup({ } // 右侧工作区侧栏:通知 + 辅助对话 - initWorkspaceSidePane({ - document, - window, - elements: { - root: vcpSidePane, - resizerHandle: resizerRight, - tabList: sidePaneTabs, - contentContainer: sidePaneContentContainer, - toggleNotificationsBtn, - notificationsPanel: notificationsSidebar, - toggleChatBtn: toggleSidePaneChatBtn, - closeBtn: closeSidePaneBtn, - addBtn: addSidePaneChatBtn, - }, - chatAPI, - chatRepository, - chatManager, - uiHelper: uiHelperFunctions, - createRenderer: createOwnedInternalChatRenderer, - settingsRef: mainChatSettingsOwner.ref, - selectedItemRef: currentSelectedItemRef, - topicIdRef: currentTopicIdRef, - historyRef: mainHistoryRef, - subscriptions: ownedRendererSubscriptions, - }); + // 侧栏起不来只影响侧栏:后面的过滤器、事件绑定(发送按钮等)照常初始化 + try { + initWorkspaceSidePane({ + document, + window, + elements: { + root: vcpSidePane, + resizerHandle: resizerRight, + tabList: sidePaneTabs, + contentContainer: sidePaneContentContainer, + toggleNotificationsBtn, + notificationsPanel: notificationsSidebar, + toggleChatBtn: toggleSidePaneChatBtn, + closeBtn: closeSidePaneBtn, + addBtn: addSidePaneChatBtn, + }, + chatAPI, + chatRepository, + chatManager, + uiHelper: uiHelperFunctions, + createRenderer: createOwnedInternalChatRenderer, + settingsRef: mainChatSettingsOwner.ref, + selectedItemRef: currentSelectedItemRef, + topicIdRef: currentTopicIdRef, + historyRef: mainHistoryRef, + subscriptions: ownedRendererSubscriptions, + }); + } catch (error) { + console.error('[RENDERER_INIT] Failed to initialize the side pane:', error); + uiHelperFunctions?.showToastNotification?.(`侧栏初始化失败:${error?.message || '未知错误'}`, 'error'); + } // Initialize Filter Manager if (window.filterManager) { From 4bc631ecf9f4a8273210b093e15936ec6f9ae141 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:28:27 +0000 Subject: [PATCH 005/143] Side browser: no local file pages, and downloads need a real click A file:// page in the side browser runs with Electron's default file privileges, so its script could fetch other local files (SSH keys, VCPChat settings) and post them out. Like DeepSeek Harness (browser-guests.ts onBeforeRequest) and ZCode (isAllowedBrowserUrl), the side browser now only opens http, https and about pages, and its session cancels file: and other non-web subresource requests. Any page could also start downloads on its own, and each one opened the system browser. A download now reaches the system browser only right after a real input on that page, one per input, the same rule popups use. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../contracts/generated/chat-event-graph.json | 36 ++++++------ modules/ipc/browserHandlers.js | 56 +++++++++++++------ .../side-pane/browserSideProvider.js | 6 +- tests/browser-handlers.test.js | 49 ++++++++++++++-- 4 files changed, 103 insertions(+), 44 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 23efc5b40..89867b65b 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -1275,14 +1275,14 @@ "consumers": [ { "file": "modules/ipc/browserHandlers.js", - "line": 139, + "line": 158, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/browserHandlers.js", - "line": 139, + "line": 158, "kind": "event-listener", "match": "guest.on('before-input-event'" } @@ -1862,14 +1862,14 @@ "consumers": [ { "file": "modules/ipc/browserHandlers.js", - "line": 165, + "line": 184, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/browserHandlers.js", - "line": 165, + "line": 184, "kind": "event-listener", "match": "ipcMain.handle('browser:clear-data'" }, @@ -1893,14 +1893,14 @@ "consumers": [ { "file": "modules/ipc/browserHandlers.js", - "line": 154, + "line": 173, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/browserHandlers.js", - "line": 154, + "line": 173, "kind": "event-listener", "match": "ipcMain.handle('browser:open-external'" }, @@ -9976,14 +9976,14 @@ "consumers": [ { "file": "modules/ipc/browserHandlers.js", - "line": 119, + "line": 142, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/browserHandlers.js", - "line": 119, + "line": 142, "kind": "event-listener", "match": "host.on('did-attach-webview'" } @@ -15022,14 +15022,14 @@ "consumers": [ { "file": "modules/ipc/browserHandlers.js", - "line": 122, + "line": 144, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/browserHandlers.js", - "line": 122, + "line": 144, "kind": "event-listener", "match": "guest.on('input-event'" } @@ -31645,14 +31645,14 @@ "consumers": [ { "file": "modules/ipc/browserHandlers.js", - "line": 102, + "line": 125, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/browserHandlers.js", - "line": 102, + "line": 125, "kind": "event-listener", "match": "host.on('will-attach-webview'" } @@ -31664,14 +31664,14 @@ "consumers": [ { "file": "modules/ipc/browserHandlers.js", - "line": 88, + "line": 109, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/browserHandlers.js", - "line": 88, + "line": 109, "kind": "event-listener", "match": "ses.on('will-download'" } @@ -31688,7 +31688,7 @@ }, { "file": "modules/ipc/browserHandlers.js", - "line": 137, + "line": 156, "kind": "event-listener" }, { @@ -31706,7 +31706,7 @@ }, { "file": "modules/ipc/browserHandlers.js", - "line": 137, + "line": 156, "kind": "event-listener", "match": "guest.on('will-navigate'" }, @@ -31754,14 +31754,14 @@ "consumers": [ { "file": "modules/ipc/browserHandlers.js", - "line": 138, + "line": 157, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/browserHandlers.js", - "line": 138, + "line": 157, "kind": "event-listener", "match": "guest.on('will-redirect'" } diff --git a/modules/ipc/browserHandlers.js b/modules/ipc/browserHandlers.js index 1f3c23c74..a3240afce 100644 --- a/modules/ipc/browserHandlers.js +++ b/modules/ipc/browserHandlers.js @@ -1,11 +1,14 @@ // modules/ipc/browserHandlers.js // 侧栏「浏览器」标签的主进程部分。页面本身由渲染进程里的 承载,这里只负责把它关进笼子: // - 只允许固定的 persist 分区,并强制去掉 preload / Node 集成,开启沙箱与上下文隔离; -// - 只放行 http / https / file / about 地址,其它协议(data: 钓鱼页、vcp:// 等自定义协议)一律拦截; +// - 只放行 http / https / about 地址,其它协议(file: 本地页、data: 钓鱼页、vcp:// 等自定义协议)一律拦截; +// 子资源请求同样拦掉 file:——本地页面在 Electron 默认的 file 特权下能 fetch 任意本地文件再发出去 +// (和 DeepSeek Harness browser-guests.ts 的 onBeforeRequest、ZCode isAllowedBrowserUrl 一致),本地文件用代码查看器看; // - 网页里的 window.open / target=_blank 转成「在侧栏新开一个浏览器标签」,但必须紧跟一次真实点击或按键, // 一次输入只换一个标签,网页自己连开弹窗刷不出标签; -// - 弹窗只能开 http / https,本地 file 页面还可以开 file 页面; -// - 页面权限请求(摄像头、定位、通知等)默认拒绝,下载交给系统默认浏览器处理; +// - 弹窗只能开 http / https; +// - 页面权限请求(摄像头、定位、通知等)默认拒绝;下载交给系统默认浏览器处理,但和弹窗一样要紧跟一次真实输入, +// 网页不能自己连发下载把用户一次次弹到系统浏览器; // - 「在默认浏览器中打开」「清除浏览数据」两个命令只接受主窗口页面调用; // - 焦点在网页里时按键到不了主窗口,副屏快捷键在这里截下转给主窗口。 'use strict'; @@ -17,7 +20,9 @@ let getMainWindow = () => null; let ipcMain = defaultIpcMain; const BROWSER_PARTITION = 'persist:vcp-side-browser'; -const ALLOWED_GUEST_PROTOCOLS = new Set(['http:', 'https:', 'file:', 'about:']); +const ALLOWED_GUEST_PROTOCOLS = new Set(['http:', 'https:', 'about:']); +// 网页里的子资源请求只放行网络和页面自己生成的内容,file: 等本地协议一律取消 +const ALLOWED_REQUEST_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:', 'about:', 'data:', 'blob:']); const POPUP_PROTOCOLS = new Set(['http:', 'https:']); // 点击或按键之后这么久内允许开一个弹窗,和 Chromium 的瞬时用户激活差不多 const POPUP_ACTIVATION_MS = 3000; @@ -47,12 +52,25 @@ function isExternalUrl(raw) { return Boolean(url && EXTERNAL_PROTOCOLS.has(url.protocol)); } -// 弹窗地址:只认 http / https;本地 file 页面里的链接可以开另一个 file 页面 -function isAllowedPopupUrl(raw, openerUrl = '') { +// 弹窗地址:只认 http / https +function isAllowedPopupUrl(raw) { const url = parseUrl(raw); - if (!url) return false; - if (POPUP_PROTOCOLS.has(url.protocol)) return true; - return url.protocol === 'file:' && parseUrl(openerUrl)?.protocol === 'file:'; + return Boolean(url && POPUP_PROTOCOLS.has(url.protocol)); +} + +function isAllowedGuestRequest(raw) { + const url = parseUrl(raw); + return Boolean(url && ALLOWED_REQUEST_PROTOCOLS.has(url.protocol)); +} + +// 每个网页最近一次真实输入的时间:弹窗和下载各凭一次输入放行一次 +const lastActivation = new WeakMap(); + +function consumeActivation(guest) { + const activatedAt = guest ? lastActivation.get(guest) || 0 : 0; + if (!activatedAt || Date.now() - activatedAt > POPUP_ACTIVATION_MS) return false; + lastActivation.delete(guest); + return true; } /** @@ -85,10 +103,15 @@ function configureGuestSession(ses) { configuredSessions.add(ses); ses.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); ses.setPermissionCheckHandler(() => false); - ses.on('will-download', (event, item) => { + ses.webRequest?.onBeforeRequest?.((details, callback) => { + callback({ cancel: !isAllowedGuestRequest(details?.url) }); + }); + ses.on('will-download', (event, item, guest) => { const url = item.getURL(); event.preventDefault(); - if (isExternalUrl(url)) shell.openExternal(url); + if (isExternalUrl(url) && consumeActivation(guest)) { + Promise.resolve(shell.openExternal(url)).catch(() => {}); + } }); } @@ -117,16 +140,12 @@ function attachToWindow(mainWindow) { }); host.on('did-attach-webview', (_event, guest) => { - // 最近一次真实输入的时间;开过一个弹窗就作废,下一个弹窗要等下一次输入 - let activatedAt = 0; + // 最近一次真实输入;开过一个弹窗或交出一次下载就作废,下一个要等下一次输入 guest.on('input-event', (_event, input) => { - if (ACTIVATION_INPUTS.has(input?.type)) activatedAt = Date.now(); + if (ACTIVATION_INPUTS.has(input?.type)) lastActivation.set(guest, Date.now()); }); guest.setWindowOpenHandler(({ url }) => { - const activated = activatedAt > 0 && Date.now() - activatedAt <= POPUP_ACTIVATION_MS; - const openerUrl = typeof guest.getURL === 'function' ? guest.getURL() : ''; - if (activated && isAllowedPopupUrl(url, openerUrl) && !host.isDestroyed()) { - activatedAt = 0; + if (isAllowedPopupUrl(url) && !host.isDestroyed() && consumeActivation(guest)) { host.send('browser:open-tab', { url }); } return { action: 'deny' }; @@ -185,6 +204,7 @@ module.exports = { attachToWindow, initialize, dispose, + isAllowedGuestRequest, isAllowedGuestUrl, isAllowedPopupUrl, matchSidePaneShortcut, diff --git a/modules/ui-system/side-pane/browserSideProvider.js b/modules/ui-system/side-pane/browserSideProvider.js index 92102dc76..b159da1e5 100644 --- a/modules/ui-system/side-pane/browserSideProvider.js +++ b/modules/ui-system/side-pane/browserSideProvider.js @@ -13,9 +13,9 @@ import { createSidePaneRootScope } from './side-pane-occurrence.js'; export const BROWSER_PARTITION = 'persist:vcp-side-browser'; -const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'file:', 'about:']); +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'about:']); // 同 browserHandlers.js:不开 file: 本地页 const BLOCKED_ERROR_CODES = new Set([-3]); // ERR_ABORTED: a navigation replaced by another one -const INVALID_URL_MESSAGE = '仅支持 http、https、file、about 地址'; +const INVALID_URL_MESSAGE = '仅支持 http、https、about 地址'; // 网页弹窗最多把浏览器标签开到这么多,再多就只提示不开 export const MAX_POPUP_BROWSER_TABS = 12; @@ -255,7 +255,7 @@ export function createBrowserSideProvider({ const changed = Boolean(url) && url !== currentUrl; currentUrl = url || ''; // 关掉后从「最近关闭」重新打开时回到最后看的页面 - if (changed && /^https?:|^file:/i.test(currentUrl)) sidePaneController?.updateTab?.(tab.id, { payload: { url: currentUrl } }); + if (changed && /^https?:/i.test(currentUrl)) sidePaneController?.updateTab?.(tab.id, { payload: { url: currentUrl } }); if (doc.activeElement !== address) address.value = currentUrl === 'about:blank' ? '' : currentUrl; address.title = currentUrl; }; diff --git a/tests/browser-handlers.test.js b/tests/browser-handlers.test.js index 3cc15e131..56d97e604 100644 --- a/tests/browser-handlers.test.js +++ b/tests/browser-handlers.test.js @@ -14,6 +14,7 @@ guestSession.setPermissionRequestHandler = (fn) => { guestSession.requestHandler guestSession.setPermissionCheckHandler = (fn) => { guestSession.checkHandler = fn; }; guestSession.clearStorageData = async () => { guestSession.cleared += 1; }; guestSession.clearCache = async () => { guestSession.cleared += 1; }; +guestSession.webRequest = { onBeforeRequest: (fn) => { guestSession.beforeRequest = fn; } }; const originalLoad = Module._load; Module._load = function loadWithElectronMock(request, parent, isMain) { @@ -38,19 +39,19 @@ const mainPage = require('./helpers/trusted-main-sender.cjs').createTrustedMainS const foreignPage = { senderFrame: { url: 'https://evil.example/' } }; test('guest URL allowlist keeps custom protocols out', () => { - for (const ok of ['http://localhost:3000/', 'https://example.com', 'file:///tmp/a.html', 'about:blank']) { + for (const ok of ['http://localhost:3000/', 'https://example.com', 'about:blank']) { assert.equal(browserHandlers.isAllowedGuestUrl(ok), true, ok); } - for (const bad of ['data:text/html,hi', 'javascript:alert(1)', 'vcp://x', 'chrome://gpu', 'ftp://host/a', '', null, 'not a url']) { + for (const bad of ['file:///tmp/a.html', 'file:///C:/Users/me/.ssh/id_rsa', 'data:text/html,hi', 'javascript:alert(1)', 'vcp://x', 'chrome://gpu', 'ftp://host/a', '', null, 'not a url']) { assert.equal(browserHandlers.isAllowedGuestUrl(bad), false, String(bad)); } }); -test('popups only open web pages, and file pages only from a file page', () => { +test('popups only open web pages', () => { const allowed = browserHandlers.isAllowedPopupUrl; assert.equal(allowed('https://a.example/', 'https://b.example/'), true); - assert.equal(allowed('http://localhost:3000/', 'file:///tmp/a.html'), true); - assert.equal(allowed('file:///tmp/b.html', 'file:///tmp/a.html'), true); + assert.equal(allowed('http://localhost:3000/'), true); + assert.equal(allowed('file:///tmp/b.html', 'file:///tmp/a.html'), false); assert.equal(allowed('file:///C:/Windows/win.ini', 'https://evil.example/'), false); assert.equal(allowed('about:blank', 'https://a.example/'), false); assert.equal(allowed('data:text/html,

login

', 'https://a.example/'), false); @@ -185,3 +186,41 @@ test('side pane shortcut matcher ignores AltGr, repeats and extra modifiers', () assert.deepEqual(match({ type: 'keyDown', control: true, key: 'PageUp' }, { mac: false }), { action: 'cycle', delta: -1 }); assert.equal(match({ type: 'keyDown', control: true, shift: true, key: 'PageUp' }, { mac: false }), null); }); + +test('guest requests for local files are canceled, web and inline resources pass', () => { + browserHandlers.initialize(); + const decide = (url) => { + let result = null; + guestSession.beforeRequest({ url }, (value) => { result = value; }); + return result.cancel; + }; + for (const url of ['file:///etc/passwd', 'file:///C:/Users/me/AppData/Roaming/VCPChat/settings.json', 'vcp://x', 'chrome://gpu']) { + assert.equal(decide(url), true, url); + } + for (const url of ['https://a.example/app.js', 'http://localhost:3000/', 'wss://a.example/ws', 'data:image/png;base64,AA==', 'blob:https://a.example/1', 'about:blank']) { + assert.equal(decide(url), false, url); + } +}); + +test('downloads only reach the system browser right after a real input on that page', () => { + browserHandlers.initialize(); + const host = new EventEmitter(); + host.isDestroyed = () => false; + host.send = () => {}; + browserHandlers.attachToWindow({ webContents: host }); + const guest = new EventEmitter(); + guest.setWindowOpenHandler = () => {}; + host.emit('did-attach-webview', {}, guest); + const download = (url) => { + let prevented = false; + guestSession.emit('will-download', { preventDefault: () => { prevented = true; } }, { getURL: () => url }, guest); + assert.equal(prevented, true); + }; + opened.length = 0; + download('https://evil.example/a.exe'); + assert.deepEqual(opened, [], 'a download the page started by itself is dropped'); + guest.emit('input-event', {}, { type: 'mouseDown' }); + download('https://ok.example/file.zip'); + download('https://evil.example/b.exe'); + assert.deepEqual(opened, ['https://ok.example/file.zip'], 'one click hands over one download'); +}); From 7da3ba3086164e1ab06923df00d2a66b4e9c1a65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:30:02 +0000 Subject: [PATCH 006/143] Side pane tabs: arrow keys keep focus on the strip, Delete closes, visible focus ring - Arrow keys re-rendered the strip and then focused the removed button, while activateTab moved focus into the panel, so each press threw a keyboard user out of the tablist. Keyboard switches now activate without moving focus and refocus the new button by id, as Radix Tabs (used by ZCode) does. - While the new-tab page or hidden notifications was active, no tab had tabindex=0, so the strip had no Tab stop. The first tab now gets it. - Delete closes the focused closable tab. - Tabs and header buttons removed the outline with no replacement; they now get the same focus ring as the tab menu items. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../contracts/generated/chat-event-graph.json | 40 ++++----- .../side-pane/side-pane-controller.js | 2 +- .../side-pane/side-pane-tab-strip.js | 25 +++++- styles/ui-system/side-pane-tab-bar.css | 7 ++ tests/side-pane-tab-keyboard.test.mjs | 90 +++++++++++++++++++ 5 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 tests/side-pane-tab-keyboard.test.mjs diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 89867b65b..98d2d6e49 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -1244,7 +1244,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 200, + "line": 202, "kind": "custom-event-listener" } ], @@ -1263,7 +1263,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 200, + "line": 202, "kind": "custom-event-listener", "match": "tabItem.addEventListener('auxclick'" } @@ -4379,12 +4379,12 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 177, + "line": 179, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 189, + "line": 191, "kind": "custom-event-listener" }, { @@ -5980,13 +5980,13 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 177, + "line": 179, "kind": "custom-event-listener", "match": "btn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 189, + "line": 191, "kind": "custom-event-listener", "match": "closeBtn.addEventListener('click'" }, @@ -7276,7 +7276,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 212, + "line": 214, "kind": "custom-event-listener" }, { @@ -7414,7 +7414,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 212, + "line": 214, "kind": "custom-event-listener", "match": "tabItem.addEventListener('contextmenu'" }, @@ -15533,7 +15533,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 285, + "line": 302, "kind": "custom-event-listener" }, { @@ -15910,7 +15910,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 285, + "line": 302, "kind": "custom-event-listener", "match": "tabListElement.addEventListener('keydown'" }, @@ -18773,7 +18773,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 197, + "line": 199, "kind": "custom-event-listener" }, { @@ -18897,7 +18897,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 197, + "line": 199, "kind": "custom-event-listener", "match": "tabItem.addEventListener('mousedown'" }, @@ -18988,7 +18988,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 208, + "line": 210, "kind": "custom-event-listener" }, { @@ -19030,7 +19030,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 208, + "line": 210, "kind": "custom-event-listener", "match": "tabItem.addEventListener('mouseenter'" }, @@ -19103,7 +19103,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 209, + "line": 211, "kind": "custom-event-listener" }, { @@ -19186,7 +19186,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 209, + "line": 211, "kind": "custom-event-listener", "match": "tabItem.addEventListener('mouseleave'" }, @@ -22979,7 +22979,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 210, + "line": 212, "kind": "custom-event-listener" }, { @@ -23038,7 +23038,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 210, + "line": 212, "kind": "custom-event-listener", "match": "tabItem.addEventListener('pointerdown'" }, @@ -26418,7 +26418,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 254, + "line": 260, "kind": "custom-event-listener" }, { @@ -26482,7 +26482,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 254, + "line": 260, "kind": "custom-event-listener", "match": "tabListElement.addEventListener('scroll'" }, diff --git a/modules/ui-system/side-pane/side-pane-controller.js b/modules/ui-system/side-pane/side-pane-controller.js index fe00be598..c24baef4d 100644 --- a/modules/ui-system/side-pane/side-pane-controller.js +++ b/modules/ui-system/side-pane/side-pane-controller.js @@ -246,7 +246,7 @@ export function createSidePaneController({ isClosable: isClosableTab, statusTabId: SidePaneState.NOTIFICATIONS_TAB_ID, getStatus: readConnectionStatus, - onActivate: (tabId) => controller.activateTab(tabId), + onActivate: (tabId, options) => controller.activateTab(tabId, options), onClose: (tabId) => controller.closeTab(tabId), onReorder: (activeId, overId) => controller.reorderTab(activeId, overId), onContextMenu: (tabId, x, y) => tabMenu?.show(tabId, x, y), diff --git a/modules/ui-system/side-pane/side-pane-tab-strip.js b/modules/ui-system/side-pane/side-pane-tab-strip.js index 0721a4ad1..8d046f0e6 100644 --- a/modules/ui-system/side-pane/side-pane-tab-strip.js +++ b/modules/ui-system/side-pane/side-pane-tab-strip.js @@ -12,7 +12,8 @@ const TAB_TOOLTIP_DELAY_MS = 1500; * getTabs() / getActiveTabId() 当前要显示的标签 * isClosable(tab) 是否画关闭按钮 * statusTabId / getStatus() 带状态圆点的标签(通知)和它的 { status, text } - * onActivate / onClose / onReorder / onContextMenu / onRendered + * onActivate(tabId, { focus }) / onClose / onReorder / onContextMenu / onRendered + * 键盘切换时传 { focus: false }:焦点留在标签上,不挪进面板 */ export function createSidePaneTabStrip({ tabListElement, @@ -156,6 +157,7 @@ export function createSidePaneTabStrip({ btn.setAttribute('aria-selected', String(isActive)); btn.setAttribute('tabindex', isActive ? '0' : '-1'); btn.setAttribute('data-tab-id', tab.id); + if (isClosable(tab)) btn.setAttribute('aria-keyshortcuts', 'Delete'); const iconSpan = doc.createElement('span'); iconSpan.className = 'tab-icon vcp-ui-icon'; @@ -230,6 +232,10 @@ export function createSidePaneTabStrip({ getTabs().forEach(tab => { tabListElement.insertBefore(createTabItem(tab, tab.id === activeTabId), insertAnchor); }); + // 激活的是新标签页或藏起来的通知时没有标签带 tabindex=0,给第一个,标签条仍能用 Tab 键进来 + if (!tabListElement.querySelector('[role="tab"][tabindex="0"]')) { + tabListElement.querySelector('[role="tab"]')?.setAttribute('tabindex', '0'); + } syncStatus(); onRendered(); layout(); @@ -259,10 +265,19 @@ export function createSidePaneTabStrip({ cleanups.push(() => resizeObserver.disconnect()); } - // 方向键 / Home / End 在标签之间移动并激活 + // 方向键 / Home / End 在标签之间移动并激活(同 ZCode 用的 Radix Tabs:焦点跟着走、自动激活);Delete 关掉聚焦的标签 const onKeydown = (e) => { const tabButtons = Array.from(tabListElement.querySelectorAll('[role="tab"]')); if (tabButtons.length === 0) return; + if (e.key === 'Delete' && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) { + const focusedId = e.target?.closest?.('[role="tab"]')?.getAttribute('data-tab-id'); + const focusedTab = focusedId ? getTabs().find(tab => tab.id === focusedId) : null; + if (focusedTab && isClosable(focusedTab)) { + e.preventDefault(); + onClose(focusedId); + } + return; + } const currentIndex = tabButtons.findIndex(b => b.getAttribute('data-tab-id') === getActiveTabId()); let targetIndex = currentIndex; if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { @@ -278,8 +293,10 @@ export function createSidePaneTabStrip({ } e.preventDefault(); if (targetIndex !== currentIndex && targetIndex >= 0 && targetIndex < tabButtons.length) { - onActivate(tabButtons[targetIndex].getAttribute('data-tab-id')); - tabButtons[targetIndex].focus(); + const targetId = tabButtons[targetIndex].getAttribute('data-tab-id'); + onActivate(targetId, { focus: false }); + // 激活会重画标签条,原来的按钮已经不在了,按 id 找新的 + findByTabId(tabListElement, targetId, '[role="tab"][data-tab-id]')?.focus?.(); } }; tabListElement.addEventListener('keydown', onKeydown); diff --git a/styles/ui-system/side-pane-tab-bar.css b/styles/ui-system/side-pane-tab-bar.css index 8edb319db..ded2b7360 100644 --- a/styles/ui-system/side-pane-tab-bar.css +++ b/styles/ui-system/side-pane-tab-bar.css @@ -326,3 +326,10 @@ html .side-pane-action-btn svg:where(.vcp-ui-scope, .vcp-ui-scope *) { line-height: 1; } + +/* 标签和标题栏按钮去掉了默认 outline,键盘聚焦时补一圈焦点环(同 .side-pane-menu-item) */ +html .side-pane-tab:focus-visible:where(.vcp-ui-scope, .vcp-ui-scope *), +html .side-pane-action-btn:focus-visible:where(.vcp-ui-scope, .vcp-ui-scope *) { + outline: 2px solid var(--accent-bg, var(--vcp-ui-port-color-3b82f6)); + outline-offset: -2px; +} diff --git a/tests/side-pane-tab-keyboard.test.mjs b/tests/side-pane-tab-keyboard.test.mjs new file mode 100644 index 000000000..2c7763414 --- /dev/null +++ b/tests/side-pane-tab-keyboard.test.mjs @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { createSidePaneController } from '../modules/ui-system/side-pane/side-pane-controller.js'; + +function setup() { + const dom = new JSDOM(` +
+
+ + `, { pretendToBeVisual: true }); + const doc = dom.window.document; + const root = doc.getElementById('vcpSidePane'); + const tabList = root.querySelector('.side-pane-tabs'); + const controller = createSidePaneController({ + root, + resizerHandle: doc.getElementById('resizerRight'), + tabListElement: tabList, + contentContainer: root.querySelector('.side-pane-content-container'), + providers: { + probe: { + mountTab(tab, view) { + const input = doc.createElement('input'); + view.append(input); + return { focus() { input.focus(); }, dispose() {} }; + } + } + } + }); + const tab = id => tabList.querySelector(`[role="tab"][data-tab-id="${id}"]`); + const press = (target, key) => target.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + const open = id => controller.openTab({ id, kind: 'probe', title: id, closable: true, scopeMode: 'global' }); + return { dom, doc, tabList, controller, tab, press, open }; +} + +test('arrow keys walk the tab strip and keep focus on the tabs', async () => { + const { dom, doc, controller, tab, press, open } = setup(); + try { + await open('a'); + await open('b'); + await open('c'); + tab('a').focus(); + controller.activateTab('a', { focus: false }); + tab('a').focus(); + press(tab('a'), 'ArrowRight'); + assert.equal(controller.getSnapshot().activeTabId, 'b'); + assert.equal(doc.activeElement, tab('b'), 'focus stays in the strip after the strip re-renders'); + press(doc.activeElement, 'ArrowRight'); + assert.equal(controller.getSnapshot().activeTabId, 'c'); + assert.equal(doc.activeElement, tab('c')); + press(doc.activeElement, 'Home'); + assert.equal(doc.activeElement?.getAttribute('data-tab-id'), controller.getSnapshot().activeTabId); + } finally { + await controller.dispose(); + dom.window.close(); + } +}); + +test('Delete closes the focused tab and focus moves to the next active tab', async () => { + const { dom, doc, controller, tab, press, open } = setup(); + try { + await open('a'); + await open('b'); + tab('b').focus(); + press(tab('b'), 'Delete'); + await new Promise(resolve => setTimeout(resolve, 0)); + assert.equal(tab('b'), null); + assert.equal(controller.getSnapshot().activeTabId, 'a'); + assert.equal(doc.activeElement, tab('a')); + } finally { + await controller.dispose(); + dom.window.close(); + } +}); + +test('the strip keeps a Tab stop while the new-tab page is showing', async () => { + const { dom, tabList, controller, open } = setup(); + try { + await open('a'); + controller.showLauncher(); + const stops = tabList.querySelectorAll('[role="tab"][tabindex="0"]'); + assert.equal(stops.length, 1); + } finally { + await controller.dispose(); + dom.window.close(); + } +}); From 6757b37d1818a6c64bb90230023da80bbd513f92 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:35:11 +0000 Subject: [PATCH 007/143] Plan detail: keyboard focus survives the page's redraws render() rebuilds the whole page, and it only put focus back on filter inputs and page tabs. Toggling a section or a batch, opening filters, picking a contributor, or any background refresh dropped focus to . Interactive buttons now carry a data-focus-key that render() records and restores, the imperative equivalent of React keeping keyed nodes in ZCode and DSH. Clearing the filters lands on the search box. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../contracts/generated/chat-event-graph.json | 60 +++++++++---------- .../side-pane/planDetailSideProvider.js | 22 ++++++- tests/side-pane-plan-detail.test.mjs | 33 ++++++++++ 3 files changed, 84 insertions(+), 31 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 98d2d6e49..0232725f7 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -2368,7 +2368,7 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 567, + "line": 569, "kind": "custom-event-listener" } ], @@ -2543,7 +2543,7 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 567, + "line": 569, "kind": "custom-event-listener", "match": "el.addEventListener('change'" } @@ -4239,67 +4239,67 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 334, + "line": 335, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 369, + "line": 371, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 406, + "line": 408, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 415, + "line": 417, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 528, + "line": 530, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 582, + "line": 585, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 586, + "line": 591, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 658, + "line": 664, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 685, + "line": 691, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 703, + "line": 710, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 723, + "line": 730, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 751, + "line": 760, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 765, + "line": 775, "kind": "custom-event-listener" }, { @@ -5812,79 +5812,79 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 334, + "line": 335, "kind": "custom-event-listener", "match": "btn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 369, + "line": 371, "kind": "custom-event-listener", "match": "crumbs.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 406, + "line": 408, "kind": "custom-event-listener", "match": "refreshBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 415, + "line": 417, "kind": "custom-event-listener", "match": "forgeBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 528, + "line": 530, "kind": "custom-event-listener", "match": "row.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 582, + "line": 585, "kind": "custom-event-listener", "match": "toggle.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 586, + "line": 591, "kind": "custom-event-listener", "match": "clear.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 658, + "line": 664, "kind": "custom-event-listener", "match": "headBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 685, + "line": 691, "kind": "custom-event-listener", "match": "pick.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 703, + "line": 710, "kind": "custom-event-listener", "match": "pick.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 723, + "line": 730, "kind": "custom-event-listener", "match": "link.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 751, + "line": 760, "kind": "custom-event-listener", "match": "retry.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 765, + "line": 775, "kind": "custom-event-listener", "match": "retry.addEventListener('click'" }, @@ -14812,7 +14812,7 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 542, + "line": 544, "kind": "custom-event-listener" }, { @@ -15004,7 +15004,7 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 542, + "line": 544, "kind": "custom-event-listener", "match": "el.addEventListener('input'" }, diff --git a/modules/ui-system/side-pane/planDetailSideProvider.js b/modules/ui-system/side-pane/planDetailSideProvider.js index 53ea53ea1..a57775515 100644 --- a/modules/ui-system/side-pane/planDetailSideProvider.js +++ b/modules/ui-system/side-pane/planDetailSideProvider.js @@ -328,6 +328,7 @@ export function createPlanDetailSideProvider({ const sectionTitle = (key, label, meta) => { const btn = h('button', 'side-plan-section-title'); btn.type = 'button'; + btn.dataset.focusKey = `section:${key}`; btn.setAttribute('aria-expanded', String(!collapsed[key])); btn.append(icon(collapsed[key] ? 'chevron_right' : 'expand_more'), h('span', 'side-plan-section-label', label)); if (meta) btn.appendChild(h('span', 'side-plan-section-meta', meta)); @@ -364,6 +365,7 @@ export function createPlanDetailSideProvider({ // 一行胶囊:面包屑(范围 › 工作区 › 工程名)、状态和更新时间、刷新/打开。 // 面包屑本身就是工程切换按钮,任何工程都能从这里换过去 const crumbs = button('side-plan-crumbs', null, '切换工程'); + crumbs.dataset.focusKey = 'crumbs'; crumbs.setAttribute('aria-haspopup', 'listbox'); crumbs.setAttribute('aria-expanded', String(picker.isOpen())); crumbs.addEventListener('click', () => { @@ -577,12 +579,15 @@ export function createPlanDetailSideProvider({ const open = filtersOpen || advancedCount > 0; const toggle = button(`side-plan-filter-toggle${advancedCount ? ' is-active' : ''}`, null, '筛选'); toggle.setAttribute('aria-expanded', String(open)); + toggle.dataset.focusKey = 'filter-toggle'; toggle.appendChild(icon('filter_list')); if (advancedCount) toggle.appendChild(h('span', 'side-plan-filter-count', String(advancedCount))); toggle.addEventListener('click', () => { filtersOpen = !open; render(); }); row.append(search, toggle); if (hasFilters(filters)) { const clear = button('side-plan-filter-clear', '清除', '清除筛选'); + // 清除后按钮消失,焦点回到搜索框 + clear.dataset.focusKey = 'filter-clear'; clear.addEventListener('click', () => { filters = { ...EMPTY_FILTERS }; filtersOpen = false; runSearch(); }); row.appendChild(clear); } @@ -636,6 +641,7 @@ export function createPlanDetailSideProvider({ row.dataset.batchId = String(batch.id); const headBtn = button('side-plan-batch-head'); headBtn.setAttribute('aria-expanded', String(open)); + headBtn.dataset.focusKey = `batch:${batch.id}`; const mark = h('span', 'side-plan-batch-mark'); mark.appendChild(icon(KIND_ICON[batch.kind] || 'commit')); const text = h('div', 'side-plan-batch-text'); @@ -700,6 +706,7 @@ export function createPlanDetailSideProvider({ const label = c.maid || '外部修改'; const pick = button(`side-plan-contributor-btn${c.maid && filters.maid === c.maid ? ' is-active' : ''}`, null, c.maid ? `只看 @${c.maid} 的改动` : '外部修改'); pick.append(icon('person'), h('span', 'side-plan-contributor-name', label), h('span', 'side-plan-contributor-batches', `${c.batches} 批`), diffStat(c.added, c.removed)); + pick.dataset.focusKey = `maid:${c.maid || ''}`; if (c.maid) pick.addEventListener('click', () => applyFilter({ maid: c.maid })); else pick.disabled = true; row.appendChild(pick); @@ -728,10 +735,12 @@ export function createPlanDetailSideProvider({ function render() { if (disposed() || nodeView) return; - // 输入框随整页重绘,记下焦点和光标位置 + // 整页重绘会换掉所有节点:记下焦点所在的输入框(连同光标)、页签或带 data-focus-key 的按钮,画完找回来。 + // ZCode / DSH 用 React,节点按 key 复用,焦点自然保留;这里手工做同一件事 const active = doc.activeElement; const focusKey = active && body.contains(active) ? active.dataset?.filter : null; const pageFocus = active && chrome.contains(active) ? active.dataset?.planPage : null; + const elementFocus = active && (body.contains(active) || chrome.contains(active)) ? active.dataset?.focusKey : null; const caret = focusKey && typeof active.selectionStart === 'number' ? [active.selectionStart, active.selectionEnd] : null; const scrollTop = body.scrollTop; body.innerHTML = ''; @@ -756,6 +765,7 @@ export function createPlanDetailSideProvider({ body.appendChild(pages.panels); body.scrollTop = scrollTop; if (pageFocus) chrome.querySelector(`[data-plan-page="${navigation.selected}"]`)?.focus(); + restoreElementFocus(elementFocus); return; } if (staleError) { @@ -783,9 +793,19 @@ export function createPlanDetailSideProvider({ if (caret && again?.setSelectionRange) { try { again.setSelectionRange(caret[0], caret[1]); } catch (_e) { /* select 没有光标 */ } } } if (pageFocus) chrome.querySelector(`[data-plan-page="${navigation.selected}"]`)?.focus(); + restoreElementFocus(elementFocus); applyFocus(); } + function restoreElementFocus(key) { + // 只在焦点因重绘掉出侧栏时补救;用户已经把焦点移到别处就不抢 + const current = doc.activeElement; + if (!key || (current && current !== doc.body && current.isConnected)) return; + const same = [...viewElement.querySelectorAll('[data-focus-key]')].find(el => el.dataset.focusKey === key && !el.disabled); + const fallback = key === 'filter-clear' ? body.querySelector('[data-filter="keyword"]') : null; + (same || fallback)?.focus?.(); + } + /** 从状态面板点进来时定位到某条计划或某个区块。 */ function applyFocus() { if (!pendingFocus || !model) return; diff --git a/tests/side-pane-plan-detail.test.mjs b/tests/side-pane-plan-detail.test.mjs index 40349eb9a..214cab60c 100644 --- a/tests/side-pane-plan-detail.test.mjs +++ b/tests/side-pane-plan-detail.test.mjs @@ -366,3 +366,36 @@ test('a hidden plan tab reads the project once when shown again, not on every ch assert.equal(calls.get, 2); handle.dispose(); }); + +test('keyboard focus survives the redraws a section toggle, a batch toggle and a background refresh cause', async () => { + const { provider, view, dom, fire } = makeEnv(); + const doc = dom.window.document; + const handle = await provider.mountTab({ id: planTabId('p1'), payload: { projectId: 'p1' } }, view); + try { + const sectionToggle = () => view.querySelector('button.side-plan-section-title'); + const key = sectionToggle().dataset.focusKey; + sectionToggle().focus(); + sectionToggle().click(); + assert.equal(view.contains(doc.activeElement), true, 'focus is on a live element, not a removed one'); + assert.equal(doc.activeElement?.dataset?.focusKey, key, 'the section toggle keeps focus after it collapses its section'); + + sectionToggle().click(); // 展开回来 + + view.querySelector('[data-plan-page="timeline"]').click(); + const batchHead = () => view.querySelector('.side-plan-batch-head'); + const batchKey = batchHead().dataset.focusKey; + batchHead().focus(); + batchHead().click(); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.equal(view.contains(doc.activeElement), true); + assert.equal(doc.activeElement?.dataset?.focusKey, batchKey, 'the batch header keeps focus after it expands'); + + fire({ projectId: 'p1' }); + await new Promise(resolve => setTimeout(resolve, 320)); + assert.equal(view.contains(doc.activeElement), true); + assert.equal(doc.activeElement?.dataset?.focusKey, batchKey, 'a background refresh does not throw focus to '); + } finally { + handle.dispose(); + dom.window.close(); + } +}); From c8b5975d6a79aee1825f2b181e13d4f9e866eef6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:36:37 +0000 Subject: [PATCH 008/143] Git page: the file context menu works from the keyboard Shift+F10 on a change row opened the menu at (0,0) with focus left on the row, no arrow navigation, and Escape dropped focus. Like the Radix ContextMenu in ZCode's GitPaneChangeCard, the menu now opens beside the row when there is no pointer, takes focus, moves with the arrow keys, Home and End, and Escape, Tab or choosing an item returns focus to the row. Icon ligature text is hidden from screen readers. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../contracts/generated/chat-event-graph.json | 16 +++---- .../ui-system/side-pane/git/context-menu.js | 44 ++++++++++++++++--- tests/side-pane-git.test.mjs | 16 +++++++ 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 0232725f7..93faa8968 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -1695,7 +1695,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 98, + "line": 132, "kind": "custom-event-listener" }, { @@ -1796,7 +1796,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 98, + "line": 132, "kind": "custom-event-listener", "match": "win.addEventListener('blur'" }, @@ -4179,7 +4179,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 90, + "line": 114, "kind": "custom-event-listener" }, { @@ -5740,7 +5740,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 90, + "line": 114, "kind": "custom-event-listener", "match": "btn.addEventListener('click'" }, @@ -15463,7 +15463,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 97, + "line": 131, "kind": "custom-event-listener" }, { @@ -15826,7 +15826,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 97, + "line": 131, "kind": "custom-event-listener", "match": "doc.addEventListener('keydown'" }, @@ -22954,7 +22954,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 96, + "line": 130, "kind": "custom-event-listener" }, { @@ -23008,7 +23008,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 96, + "line": 130, "kind": "custom-event-listener", "match": "doc.addEventListener('pointerdown'" }, diff --git a/modules/ui-system/side-pane/git/context-menu.js b/modules/ui-system/side-pane/git/context-menu.js index 61b4f045c..512b8344d 100644 --- a/modules/ui-system/side-pane/git/context-menu.js +++ b/modules/ui-system/side-pane/git/context-menu.js @@ -27,6 +27,8 @@ export function createGitContextMenu({ workspaceOf }) { let contextMenu = null; + // 打开菜单的那一行:Esc、Tab 或执行完菜单项后焦点回到这里 + let returnFocusTo = null; async function copyText(text, label) { try { @@ -55,10 +57,13 @@ export function createGitContextMenu({ } } - function closeContextMenu() { + function closeContextMenu({ restoreFocus = false } = {}) { if (!contextMenu) return; contextMenu.remove(); contextMenu = null; + const target = returnFocusTo; + returnFocusTo = null; + if (restoreFocus && target?.isConnected) target.focus?.({ preventScroll: true }); doc.removeEventListener('pointerdown', onOutsidePointer, true); doc.removeEventListener('keydown', onMenuKey, true); win.removeEventListener('blur', closeContextMenu); @@ -66,7 +71,26 @@ export function createGitContextMenu({ function onOutsidePointer(event) { if (contextMenu && !contextMenu.contains(event.target)) closeContextMenu(); } - function onMenuKey(event) { if (event.key === 'Escape') closeContextMenu(); } + // 和 ZCode GitPaneChangeCard 用的 Radix ContextMenu 一样:上下键 / Home / End 在菜单项间移动,Esc 或 Tab 收起并把焦点还给那一行 + function onMenuKey(event) { + if (!contextMenu) return; + if (event.key === 'Escape' || event.key === 'Tab') { + event.preventDefault(); + closeContextMenu({ restoreFocus: true }); + return; + } + if (!contextMenu.contains(doc.activeElement)) return; + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + const items = [...contextMenu.querySelectorAll('[role="menuitem"]:not([disabled])')]; + if (!items.length) return; + event.preventDefault(); + const current = items.indexOf(doc.activeElement); + let next = 0; + if (event.key === 'End') next = items.length - 1; + else if (event.key === 'ArrowDown') next = current < 0 ? 0 : (current + 1) % items.length; + else if (event.key === 'ArrowUp') next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length; + items[next].focus(); + } function openContextMenu(event, item) { event.preventDefault(); @@ -85,14 +109,24 @@ export function createGitContextMenu({ btn.className = 'side-git-context-item'; btn.setAttribute('role', 'menuitem'); btn.disabled = Boolean(entry.disabled); - btn.innerHTML = `${entry.icon}`; + btn.innerHTML = ``; btn.lastElementChild.textContent = entry.label; - btn.addEventListener('click', () => { closeContextMenu(); entry.run(); }); + btn.addEventListener('click', () => { closeContextMenu({ restoreFocus: true }); entry.run(); }); menu.appendChild(btn); }); doc.body.appendChild(menu); - placeMenuAt(menu, event.clientX, event.clientY, win); + // 键盘(Shift+F10 / 菜单键)打开时没有指针坐标,贴着那一行出菜单 + const anchor = typeof event.currentTarget?.getBoundingClientRect === 'function' ? event.currentTarget : null; + let { clientX: x, clientY: y } = event; + if (!x && !y && anchor?.getBoundingClientRect) { + const rect = anchor.getBoundingClientRect(); + x = rect.left + 8; + y = rect.bottom; + } + placeMenuAt(menu, x, y, win); contextMenu = menu; + returnFocusTo = anchor || (doc.activeElement !== doc.body ? doc.activeElement : null); + menu.querySelector('[role="menuitem"]:not([disabled])')?.focus?.({ preventScroll: true }); doc.addEventListener('pointerdown', onOutsidePointer, true); doc.addEventListener('keydown', onMenuKey, true); win.addEventListener('blur', closeContextMenu); diff --git a/tests/side-pane-git.test.mjs b/tests/side-pane-git.test.mjs index 95370260f..025ebc86f 100644 --- a/tests/side-pane-git.test.mjs +++ b/tests/side-pane-git.test.mjs @@ -97,6 +97,22 @@ test('the Git view has source select, flat change cards, expandable diff, contex await new Promise(resolve => setTimeout(resolve, 20)); assert.deepEqual(cards().map(c => c.dataset.path), ['src/staged.js']); + // keyboard: Shift+F10 on a row opens the menu with focus inside, arrows move, Escape returns focus to the row + const kbRow = cards()[0].querySelector('.side-git-row'); + kbRow.focus(); + kbRow.dispatchEvent(new dom.window.MouseEvent('contextmenu', { bubbles: true, cancelable: true })); + const menuItems = () => [...doc.querySelectorAll('.side-git-context-item')]; + assert.equal(doc.activeElement, menuItems()[0], 'focus moves into the menu'); + assert.equal(menuItems()[0].querySelector('.vcp-ui-icon').getAttribute('aria-hidden'), 'true'); + doc.activeElement.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + assert.equal(doc.activeElement, menuItems()[1]); + doc.activeElement.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + doc.activeElement.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + assert.equal(doc.activeElement, menuItems()[2], 'arrows wrap around'); + doc.activeElement.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + assert.equal(doc.querySelector('.side-git-context-menu'), null); + assert.equal(doc.activeElement, kbRow, 'Escape hands focus back to the row'); + // context menu: reveal / copy absolute / copy relative cards()[0].querySelector('.side-git-row').dispatchEvent(new dom.window.MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 })); const items = [...doc.querySelectorAll('.side-git-context-item')]; From a53275a59e7f8a03e3ad24e8f5dcf7be6645191c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:37:55 +0000 Subject: [PATCH 009/143] Code viewer: the file button shows the file in the file manager The button called openPythonAttachmentInTextEditor, which only accepts .py file URLs on Windows, so it failed silently for every file. Sending the path to open-external-link instead would hand model-supplied paths (.bat, .lnk) to shell.openExternal. The button now reveals workspace files through the workspace-checked git:reveal-path and shows the path for anything else. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../side-pane/codeViewerSideProvider.js | 29 +++++++++++------ tests/side-pane-code-viewer.test.mjs | 31 +++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/modules/ui-system/side-pane/codeViewerSideProvider.js b/modules/ui-system/side-pane/codeViewerSideProvider.js index e027db9c3..3e7eb8ddb 100644 --- a/modules/ui-system/side-pane/codeViewerSideProvider.js +++ b/modules/ui-system/side-pane/codeViewerSideProvider.js @@ -16,6 +16,7 @@ import { readFileForViewer } from './code-viewer/file-read.js'; import { createCodeViewerDiffView } from './code-viewer/diff-view.js'; import { computeLineDiff } from '../line-diff.js'; import { detectLanguage } from './code-viewer/helpers.js'; +import { toWorkspaceRelative } from '../git-file-diff.js'; import { createSidePaneRootScope } from './side-pane-occurrence.js'; export { detectLanguage } from './code-viewer/helpers.js'; export { escapeHtml } from '../text-escape.js'; @@ -156,9 +157,9 @@ export function createCodeViewerSideProvider({ externalBtn.type = 'button'; externalBtn.className = 'side-code-action-btn'; externalBtn.setAttribute('data-action', 'open-external'); - externalBtn.title = '在外部编辑器中打开'; - externalBtn.setAttribute('aria-label', '外部打开'); - externalBtn.innerHTML = 'open_in_new'; + externalBtn.title = '在文件管理器中显示'; + externalBtn.setAttribute('aria-label', '在文件管理器中显示'); + externalBtn.innerHTML = ''; } // Reload Button(文件可能已在外部被修改或删除) @@ -303,13 +304,21 @@ export function createCodeViewerSideProvider({ if (reloadBtn) own.listen(reloadBtn, 'click', () => reload()); if (externalBtn && filePath) { - own.listen(externalBtn, 'click', () => { - if (api?.openPythonAttachmentInTextEditor) { - api.openPythonAttachmentInTextEditor(filePath); - } else if (api?.sendOpenExternalLink) { - api.sendOpenExternalLink(filePath); - } else { - uiHelper?.showToastNotification?.(`文件路径: ${filePath}`, 'info'); + // 只在文件管理器里定位,不按文件关联打开:路径可能来自模型的工具调用,.bat / .lnk 按关联打开就是执行。 + // 已登记工作区里的文件走 gitRevealPath(主进程校验在工作区内);别处的文件只提示路径。 + own.listen(externalBtn, 'click', async () => { + const toast = (message, type) => uiHelper?.showToastNotification?.(message, type); + try { + const listed = typeof api?.gitRevealPath === 'function' ? await api.gitListWorkspaces?.() : null; + const match = listed?.success ? toWorkspaceRelative(filePath, listed.data?.workspaces) : null; + if (!match) { + toast(`文件路径:${filePath}`, 'info'); + return; + } + const res = await api.gitRevealPath(match.workspace.id, match.relPath); + if (!res?.success) throw new Error(res?.error || '无法在文件管理器中显示'); + } catch (error) { + toast(error?.message || String(error), 'error'); } }); } diff --git a/tests/side-pane-code-viewer.test.mjs b/tests/side-pane-code-viewer.test.mjs index 08a5e0715..95f381327 100644 --- a/tests/side-pane-code-viewer.test.mjs +++ b/tests/side-pane-code-viewer.test.mjs @@ -308,3 +308,34 @@ test('large files only render the first preview chunk, cut at a line end', async dom.window.close(); } }); + +test('the file button reveals a workspace file in the file manager and never opens it by association', async () => { + const dom = new JSDOM('
'); + const doc = dom.window.document; + const revealed = []; + const toasts = []; + const opened = []; + const api = { + gitListWorkspaces: async () => ({ success: true, data: { workspaces: [{ id: 'ws1', path: 'C:\\proj' }] } }), + sourceReadFile: async () => ({ success: true, data: { content: 'x', encoding: 'utf8' } }), + gitRevealPath: async (wsId, rel) => { revealed.push([wsId, rel]); return { success: true }; }, + openPythonAttachmentInTextEditor: (p) => opened.push(p), + sendOpenExternalLink: (p) => opened.push(p) + }; + const provider = createCodeViewerSideProvider({ document: doc, api, uiHelper: { showToastNotification: (m, t) => toasts.push([m, t]) } }); + const click = async (filePath) => { + const view = doc.createElement('section'); + doc.body.append(view); + const handle = await provider.mountTab({ title: 'f', payload: { filePath } }, view); + view.querySelector('[data-action="open-external"]').click(); + await new Promise(resolve => setTimeout(resolve, 10)); + await handle?.dispose?.(); + }; + await click('C:\\proj\\src\\a.js'); + assert.deepEqual(revealed, [['ws1', 'src/a.js']]); + await click('C:\\Users\\me\\payload.bat'); + assert.deepEqual(revealed.length, 1, 'a file outside the workspaces is not revealed'); + assert.equal(toasts.at(-1)[0].includes('payload.bat'), true, 'its path is shown instead'); + assert.deepEqual(opened, [], 'nothing is opened through a file association'); + dom.window.close(); +}); From ba0f44d783a6427396ea1aa2b1ed00f0738204a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:39:25 +0000 Subject: [PATCH 010/143] Side pane resizer: keyboard resizing saves the width once, after the keys stop Each arrow, Home or End press wrote settings over IPC, about 30 writes a second while an arrow key was held. The width still applies on every press; the save now waits until the keys stop for 400 ms (the debounced onLayoutChange ZCode's resizable.tsx uses), and runs at once when focus leaves the handle or the pane is torn down. Pointer drags save on release as before. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../contracts/generated/chat-event-graph.json | 15 ++++++- .../side-pane/side-pane-resizer-owner.js | 37 ++++++++++++--- tests/side-pane-resizer-owner.test.mjs | 45 +++++++++++++++++++ 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 93faa8968..3963b815c 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -1708,6 +1708,11 @@ "line": 146, "kind": "custom-event-listener" }, + { + "file": "modules/ui-system/side-pane/side-pane-resizer-owner.js", + "line": 168, + "kind": "custom-event-listener" + }, { "file": "modules/ui-system/side-pane/side-pane-tab-menu.js", "line": 95, @@ -1812,6 +1817,12 @@ "kind": "custom-event-listener", "match": "profileName.addEventListener('blur'" }, + { + "file": "modules/ui-system/side-pane/side-pane-resizer-owner.js", + "line": 168, + "kind": "custom-event-listener", + "match": "handle?.addEventListener?.('blur'" + }, { "file": "modules/ui-system/side-pane/side-pane-tab-menu.js", "line": 95, @@ -15498,7 +15509,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-resizer-owner.js", - "line": 143, + "line": 167, "kind": "custom-event-listener" }, { @@ -15868,7 +15879,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-resizer-owner.js", - "line": 143, + "line": 167, "kind": "custom-event-listener", "match": "handle?.addEventListener?.('keydown'" }, diff --git a/modules/ui-system/side-pane/side-pane-resizer-owner.js b/modules/ui-system/side-pane/side-pane-resizer-owner.js index 901948886..69bbef078 100644 --- a/modules/ui-system/side-pane/side-pane-resizer-owner.js +++ b/modules/ui-system/side-pane/side-pane-resizer-owner.js @@ -12,7 +12,9 @@ export function createSidePaneResizerOwner({ onWidthCommit = null, scope = null, documentRef = (typeof document !== 'undefined' ? document : null), - windowRef = (typeof window !== 'undefined' ? window : null) + windowRef = (typeof window !== 'undefined' ? window : null), + // 键盘调宽时按住方向键每秒几十次,宽度当场生效,写设置等停手后再写一次(同 ZCode resizable.tsx 走防抖的 onLayoutChange) + keyboardCommitDelayMs = 400 }) { if (!handle || !paneElement) { throw new TypeError('SidePaneResizerOwner requires a handle and paneElement'); @@ -53,6 +55,27 @@ export function createSidePaneResizerOwner({ let isDisposed = false; let dragStyles = null; + let keyboardCommit = null; // { timer, width } + + function flushKeyboardCommit() { + if (!keyboardCommit) return; + const { timer, width } = keyboardCommit; + keyboardCommit = null; + win?.clearTimeout?.(timer); + onWidthCommit?.(width); + } + + function commitWidth(width, fromKeyboard) { + if (!fromKeyboard) { + if (keyboardCommit) { win?.clearTimeout?.(keyboardCommit.timer); keyboardCommit = null; } + onWidthCommit?.(width); + return; + } + onWidthChange?.(width); + if (keyboardCommit) win?.clearTimeout?.(keyboardCommit.timer); + keyboardCommit = { width, timer: win?.setTimeout?.(flushKeyboardCommit, keyboardCommitDelayMs) }; + if (!keyboardCommit.timer) flushKeyboardCommit(); + } function restoreDragStyles() { if (!dragStyles) return; @@ -108,11 +131,11 @@ export function createSidePaneResizerOwner({ } handle.classList.add('active'); }, - onCommit: (width) => { + onCommit: (width, event) => { if (isDisposed) return; const finalWidth = Math.round(width); handle?.setAttribute?.('aria-valuenow', String(finalWidth)); - onWidthCommit?.(finalWidth); + commitWidth(finalWidth, event?.type === 'keydown'); } }); @@ -135,12 +158,14 @@ export function createSidePaneResizerOwner({ paneElement.style.width = `${finalWidth}px`; } handle?.setAttribute?.('aria-valuenow', String(finalWidth)); - onWidthChange?.(finalWidth); - onWidthCommit?.(finalWidth); + commitWidth(finalWidth, true); } }; + // 焦点离开分隔条时把没写的宽度写掉 + const onBlur = () => flushKeyboardCommit(); handle?.addEventListener?.('keydown', onKeydown); + handle?.addEventListener?.('blur', onBlur); const owner = Object.freeze({ cancel() { @@ -156,8 +181,10 @@ export function createSidePaneResizerOwner({ }, dispose() { if (isDisposed) return; + flushKeyboardCommit(); isDisposed = true; handle?.removeEventListener?.('keydown', onKeydown); + handle?.removeEventListener?.('blur', onBlur); try { resizer?.dispose?.(); } finally { diff --git a/tests/side-pane-resizer-owner.test.mjs b/tests/side-pane-resizer-owner.test.mjs index 83a77e044..01cb99f01 100644 --- a/tests/side-pane-resizer-owner.test.mjs +++ b/tests/side-pane-resizer-owner.test.mjs @@ -132,3 +132,48 @@ test('one arrow key press moves the side pane once and commits once', async () = assert.equal(width, 780); assert.deepEqual(commits, [420, 400, 780]); }); + +test('holding an arrow key resizes on every press but saves the width once, after the keys stop', async () => { + const { readFile } = await import('node:fs/promises'); + const source = await readFile(new URL('../modules/ui-system/sidebar-resizer.js', import.meta.url), 'utf8'); + const win = { requestAnimationFrame: fn => setTimeout(fn, 0), cancelAnimationFrame: clearTimeout }; + new Function('window', source)(win); + + const listeners = {}; + const handle = { + setAttribute() {}, + classList: { toggle() {} }, + addEventListener(type, fn) { (listeners[type] ||= []).push(fn); }, + removeEventListener() {}, + ownerDocument: { body: { style: {}, classList: { toggle() {} } } }, + }; + let width = 400; + const pane = { style: {}, getBoundingClientRect: () => ({ width }) }; + const commits = []; + const owner = createSidePaneResizerOwner({ + handle, + paneElement: pane, + resizerFactory: win.VCPSidebarResizer.create, + documentRef: { querySelector: () => null, addEventListener() {}, removeEventListener() {} }, + windowRef: { innerWidth: 1200, setTimeout, clearTimeout }, + keyboardCommitDelayMs: 30, + onWidthChange: w => { width = w; }, + onWidthCommit: w => commits.push(w), + }); + + const press = key => listeners.keydown.forEach(fn => fn({ type: 'keydown', key, preventDefault() {} })); + for (let i = 0; i < 5; i++) press('ArrowLeft'); + assert.equal(width, 500, 'every press resizes right away'); + assert.deepEqual(commits, [], 'nothing is saved while keys are still coming'); + await new Promise(resolve => setTimeout(resolve, 60)); + assert.deepEqual(commits, [500]); + + press('End'); + assert.equal(width, 780); + listeners.blur.forEach(fn => fn()); + assert.deepEqual(commits, [500, 780], 'leaving the handle saves at once'); + + press('ArrowRight'); + owner.dispose(); + assert.deepEqual(commits, [500, 780, 760], 'disposing saves what is pending'); +}); From 6afd6086219d2d000b4ca1afce4589d114f77c12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:40:06 +0000 Subject: [PATCH 011/143] Side pane: open/close animation, topic scope, dormancy quota and tab focus hold under real use - Reopening while the close animation runs no longer leaves the pane collapsed with its expand button hidden; a repeated open no longer jumps back to zero width. - With no current conversation (group selected, early startup) topic tabs of other conversations stay off the strip, so "close all" can no longer delete their side chats. - Keep views (side chats) no longer take places under the live-view limit; browser tabs sleep only for the limit, not for being hidden; collapsing the pane no longer puts the current tab to sleep. - Arrow keys on the tab strip keep focus on the tab; tab ids with Windows paths or quotes are looked up without building selectors. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- .../side-pane/side-pane-controller.js | 10 +-- .../ui-system/side-pane/side-pane-dormancy.js | 9 ++- .../ui-system/side-pane/side-pane-state.js | 20 +++--- .../side-pane/side-pane-tab-strip.js | 15 +++-- .../side-pane/side-pane-tab-utils.js | 10 +++ .../ui-system/side-pane/side-pane-types.js | 5 +- .../side-pane/side-pane-visibility.js | 37 ++++++++++- .../ui-system/side-pane/tab-types/browser.js | 1 + tests/side-chat-audit-probes.test.mjs | 1 + tests/side-chat-ephemeral-parity.test.mjs | 1 + tests/side-chat-integration.test.mjs | 1 + tests/side-pane-controller.test.mjs | 1 + tests/side-pane-dormancy.test.mjs | 42 ++++++++++++ tests/side-pane-parent-scope.test.mjs | 59 +++++++++++++++++ tests/side-pane-state.test.mjs | 9 +-- tests/side-pane-tab-keyboard.test.mjs | 63 ++++++++++++++++++ tests/side-pane-terminal-lifecycle.test.mjs | 11 ++-- tests/side-pane-visibility-animation.test.mjs | 65 +++++++++++++++++++ 18 files changed, 325 insertions(+), 35 deletions(-) create mode 100644 tests/side-pane-parent-scope.test.mjs create mode 100644 tests/side-pane-tab-keyboard.test.mjs create mode 100644 tests/side-pane-visibility-animation.test.mjs diff --git a/modules/ui-system/side-pane/side-pane-controller.js b/modules/ui-system/side-pane/side-pane-controller.js index d90d60ad6..3d2358adf 100644 --- a/modules/ui-system/side-pane/side-pane-controller.js +++ b/modules/ui-system/side-pane/side-pane-controller.js @@ -15,6 +15,7 @@ import { createSidePaneShortcuts } from './side-pane-shortcuts.js'; import { createSidePaneLayoutStore, parseLayout, rememberBounded, serializeLayout } from './side-pane-persistence.js'; import { createSidePaneRootScope, createTabOccurrence } from './side-pane-occurrence.js'; import { selectDormantViews } from './side-pane-dormancy.js'; +import { findByTabId } from './side-pane-tab-utils.js'; /** @typedef {import('./side-pane-types.js').SidePaneTab} SidePaneTab */ /** @typedef {import('./side-pane-types.js').SidePaneTabType} SidePaneTabType */ @@ -245,7 +246,7 @@ export function createSidePaneController({ isClosable: isClosableTab, statusTabId: SidePaneState.NOTIFICATIONS_TAB_ID, getStatus: readConnectionStatus, - onActivate: (tabId) => controller.activateTab(tabId), + onActivate: (tabId, options) => controller.activateTab(tabId, options), onClose: (tabId) => controller.closeTab(tabId), onReorder: (activeId, overId) => controller.reorderTab(activeId, overId), onContextMenu: (tabId, x, y) => tabMenu?.show(tabId, x, y), @@ -339,9 +340,10 @@ export function createSidePaneController({ } // ---- 休眠 ---- - // 计时看的是"在面板里是不是当前标签",不看窗口有没有最小化:用户回来时看到的还是它,不该被换掉 + // 计时看的是"在当前对话里是不是当前标签",不看窗口有没有最小化,也不看面板是否收起: + // 用户重新展开时看到的还是它,不该被换掉(对照 ZCode shouldMountSidePaneContent、DSH TabLayout 收起时保留选中标签) function isTabPresented(tabId, { visibleTabIds, activeViewId } = getActiveView()) { - return !isDisposed && state.visible && tabId === activeViewId && visibleTabIds.has(tabId); + return !isDisposed && tabId === activeViewId && visibleTabIds.has(tabId); } function noteViewPresence(active = getActiveView()) { @@ -477,7 +479,7 @@ export function createSidePaneController({ const pending = { promise: null, viewElement: null, canceled: false, onClosed }; const mounting = (async () => { - let view = contentContainer?.querySelector(`[data-tab-id="${tabId}"]`); + let view = findByTabId(contentContainer, '[data-tab-id]', tabId); if (!view && contentContainer) { view = doc.createElement('section'); view.className = 'side-pane-view'; diff --git a/modules/ui-system/side-pane/side-pane-dormancy.js b/modules/ui-system/side-pane/side-pane-dormancy.js index e2cb579b8..39c75f4d7 100644 --- a/modules/ui-system/side-pane/side-pane-dormancy.js +++ b/modules/ui-system/side-pane/side-pane-dormancy.js @@ -5,6 +5,9 @@ * - 同时挂着的视图超过 maxLiveViews 时,最久没显示的先休眠。 * 不休眠:正在显示的、类型声明 dormancy 'keep' 的、视图报告自己正忙的(加载中、在放声音、命令还在跑……), * 忙的到期后隔 busyRetryMs 再看一次。 + * 'limit-only' 的类型(浏览器)不按隐藏时长休眠,只在超过上限时参与淘汰:销毁网页会丢掉表单、登录和后退栈, + * 代价远高于重建其他视图(对照 ZCode browserTabResidencyPolicy.ts 只按数量淘汰)。 + * 'keep' 的视图不占 maxLiveViews 的名额,否则它们攒多了会把其他标签一切走就挤去休眠。 * 这里只做判断,不碰 DOM 和定时器,控制器负责执行和定时。 */ @@ -19,7 +22,7 @@ export const DORMANCY_DEFAULTS = Object.freeze({ * @typedef {object} DormancyCandidate * @property {string} tabId * @property {boolean} shown 正在显示 - * @property {'none' | 'detach' | 'keep'} dormancy + * @property {'none' | 'detach' | 'limit-only' | 'keep'} dormancy * @property {boolean} busy * @property {boolean} otherTopic 属于别的对话 * @property {number | null} hiddenSince 从什么时候开始不显示;一直没显示过的用挂载时间 @@ -45,7 +48,7 @@ export function selectDormantViews(candidates, options) { const sleepable = candidate => !candidate.shown && candidate.dormancy !== 'keep'; for (const candidate of candidates) { - if (!sleepable(candidate)) continue; + if (!sleepable(candidate) || candidate.dormancy === 'limit-only') continue; const reason = candidate.otherTopic ? 'other-topic' : 'hidden'; const due = (candidate.hiddenSince ?? now) + (candidate.otherTopic ? otherTopicMs : hiddenMs); if (due > now) { @@ -58,7 +61,7 @@ export function selectDormantViews(candidates, options) { } } - let live = candidates.length - released.size; + let live = candidates.filter(candidate => candidate.dormancy !== 'keep').length - released.size; if (live > maxLiveViews) { const victims = candidates .filter(candidate => sleepable(candidate) && !candidate.busy && !released.has(candidate.tabId)) diff --git a/modules/ui-system/side-pane/side-pane-state.js b/modules/ui-system/side-pane/side-pane-state.js index 0824e86e5..58556e160 100644 --- a/modules/ui-system/side-pane/side-pane-state.js +++ b/modules/ui-system/side-pane/side-pane-state.js @@ -222,8 +222,7 @@ export function openTab(state, rawTab) { } const tabParent = getTabParent({ ...rawTab, scopeMode }); - const isVisibleForCurrentParent = scopeMode === 'global' || !state.parent || !tabParent - || matchesConversation(tabParent, state.parent); + const isVisibleForCurrentParent = isTabVisibleForParent({ ...rawTab, scopeMode }, state.parent); return Object.freeze({ ...state, @@ -320,14 +319,17 @@ export function reorderTabs(state, activeId, overId) { return Object.freeze({ ...state, tabs: Object.freeze(next) }); } +// 话题级标签只在所属对话里可见;没有当前对话(群组、启动早期)时一律不可见, +// 否则「关闭所有」会关掉、进而删除别的对话的辅助对话(对照 ZCode workspaceSidePane.ts isSidePaneTabVisibleForParent) +function isTabVisibleForParent(tab, parentRef) { + if (tab.id === NOTIFICATIONS_TAB_ID || tab.scopeMode === 'global') return true; + const tabParent = getTabParent(tab); + if (!tabParent) return true; + return matchesConversation(tabParent, parentRef); +} + export function getVisibleTabs(state, parentRef = null) { - if (!parentRef) return state.tabs; - return state.tabs.filter(tab => { - if (tab.id === NOTIFICATIONS_TAB_ID) return true; - if (tab.scopeMode === 'global') return true; - const tabParent = getTabParent(tab); - return tabParent ? matchesConversation(tabParent, parentRef) : true; - }); + return state.tabs.filter(tab => isTabVisibleForParent(tab, parentRef)); } const api = Object.freeze({ diff --git a/modules/ui-system/side-pane/side-pane-tab-strip.js b/modules/ui-system/side-pane/side-pane-tab-strip.js index 964f5fe82..f5c279ccc 100644 --- a/modules/ui-system/side-pane/side-pane-tab-strip.js +++ b/modules/ui-system/side-pane/side-pane-tab-strip.js @@ -2,7 +2,7 @@ 'use strict'; import { createTabSortable } from './side-pane-tab-dnd.js'; -import { getTabIconName, resolveTabsOverflow } from './side-pane-tab-utils.js'; +import { findByTabId, getTabIconName, resolveTabsOverflow } from './side-pane-tab-utils.js'; // 标签标题悬停提示、面板切换动画、可拖拽排序的标签 const TAB_TOOLTIP_DELAY_MS = 1500; @@ -133,7 +133,7 @@ export function createSidePaneTabStrip({ function syncStatus() { const current = statusTabId ? getStatus() : null; if (!current) return; - const btn = tabListElement.querySelector(`.side-pane-tab[data-tab-id="${statusTabId}"]`); + const btn = findByTabId(tabListElement, '.side-pane-tab[data-tab-id]', statusTabId); const dot = btn?.querySelector('.side-pane-tab-status'); if (!dot) return; const { status = 'unknown', text = '' } = current; @@ -268,7 +268,8 @@ export function createSidePaneTabStrip({ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { targetIndex = (currentIndex + 1) % tabButtons.length; } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { - targetIndex = (currentIndex - 1 + tabButtons.length) % tabButtons.length; + // 当前是新标签页(不在标签条上)时,往左落到最后一个 + targetIndex = currentIndex < 0 ? tabButtons.length - 1 : (currentIndex - 1 + tabButtons.length) % tabButtons.length; } else if (e.key === 'Home') { targetIndex = 0; } else if (e.key === 'End') { @@ -278,8 +279,10 @@ export function createSidePaneTabStrip({ } e.preventDefault(); if (targetIndex !== currentIndex && targetIndex >= 0 && targetIndex < tabButtons.length) { - onActivate(tabButtons[targetIndex].getAttribute('data-tab-id')); - tabButtons[targetIndex].focus(); + // 激活会重建标签条,旧按钮随之离开文档;焦点留在标签上(WAI-ARIA Tabs),按 id 找重建后的按钮 + const targetId = tabButtons[targetIndex].getAttribute('data-tab-id'); + onActivate(targetId, { focus: false }); + findByTabId(tabListElement, '[role="tab"][data-tab-id]', targetId)?.focus?.(); } }; tabListElement.addEventListener('keydown', onKeydown); @@ -294,7 +297,7 @@ export function createSidePaneTabStrip({ syncStatus, hideTooltip, focusTab(tabId) { - tabListElement.querySelector(`[role="tab"][data-tab-id="${tabId}"]`)?.focus?.(); + findByTabId(tabListElement, '[role="tab"][data-tab-id]', tabId)?.focus?.(); }, dispose() { disposed = true; diff --git a/modules/ui-system/side-pane/side-pane-tab-utils.js b/modules/ui-system/side-pane/side-pane-tab-utils.js index 0da0acbcd..e5dd8f4aa 100644 --- a/modules/ui-system/side-pane/side-pane-tab-utils.js +++ b/modules/ui-system/side-pane/side-pane-tab-utils.js @@ -145,3 +145,13 @@ export function moveIdBefore(ids, activeId, overId) { next.splice(to, 0, activeId); return next; } + +// 按 data-tab-id 找元素,不把 id 拼进选择器:文件标签的 id 带 Windows 路径(反斜杠会被当成转义)或引号 +export function findByTabId(container, selector, tabId) { + if (!container || tabId == null) return null; + const id = String(tabId); + for (const element of container.querySelectorAll(selector)) { + if (element.getAttribute('data-tab-id') === id) return element; + } + return null; +} diff --git a/modules/ui-system/side-pane/side-pane-types.js b/modules/ui-system/side-pane/side-pane-types.js index dfe5080dc..ba5553a89 100644 --- a/modules/ui-system/side-pane/side-pane-types.js +++ b/modules/ui-system/side-pane/side-pane-types.js @@ -93,10 +93,11 @@ * @property {SidePaneProvider | null} [provider] 没有时只占位,不挂视图(比如通知)。 * 可以是 tab-types/lazy-provider.js 的懒加载代理,第一次挂载或第一次调用 open 函数时才加载实现 * @property {() => Promise | SidePaneProvider} [load] 没给 provider 时用它懒加载 - * @property {'none' | 'detach' | 'keep'} [dormancy] 隐藏太久时怎么休眠: + * @property {'none' | 'detach' | 'limit-only' | 'keep'} [dormancy] 隐藏太久时怎么休眠: * none(默认)释放视图、保留标签,再显示时重新挂载;detach 同样释放视图,但 provider 把会话和画面 * 挂在 occurrence 上,休眠时把画面收进暂存区,重新挂载时接回去(终端),会话不动; - * keep 不休眠(比如有没发出去的输入) + * limit-only 不按隐藏时长休眠,只在挂着的视图超过上限时参与淘汰(浏览器); + * keep 不休眠,也不占视图上限的名额(比如有没发出去的输入) * @property {SidePaneOpenTabEntry | null} [entry] 有时出现在新标签页里 * @property {(payload: object, tabs: readonly SidePaneTab[]) => SidePaneTab} [toTab] * openTab(payload) 先经过它变成标签;可以返回已有标签的 id 让重复打开落到同一个标签上 diff --git a/modules/ui-system/side-pane/side-pane-visibility.js b/modules/ui-system/side-pane/side-pane-visibility.js index 0a75f0d44..5d95088ad 100644 --- a/modules/ui-system/side-pane/side-pane-visibility.js +++ b/modules/ui-system/side-pane/side-pane-visibility.js @@ -26,6 +26,9 @@ export function createSidePaneVisibility({ root, resizerHandle = null, initialRa let animating = false; let animationTimer = null; let animationRafId = null; + // 当前这轮动画的目标(true 展开 / false 收起)和它的收尾撤销函数;新一轮开始前必须撤掉旧一轮的 transitionend 监听 + let animationTarget = null; + let cancelTransition = null; const isJSDOM = (typeof navigator !== 'undefined' && navigator.userAgent && navigator.userAgent.includes('jsdom')) || (typeof win !== 'undefined' && win.name === 'nodejs'); @@ -42,6 +45,11 @@ export function createSidePaneVisibility({ root, resizerHandle = null, initialRa } function clearPendingAnimation() { + if (cancelTransition) { + const cancel = cancelTransition; + cancelTransition = null; + cancel(); + } if (animationTimer) { clearTimeout(animationTimer); animationTimer = null; @@ -75,6 +83,7 @@ export function createSidePaneVisibility({ root, resizerHandle = null, initialRa function applySynchronous(isVisible) { clearPendingAnimation(); animating = false; + animationTarget = null; root.classList.remove('is-animating'); root.classList.toggle('active', isVisible); root.classList.toggle('collapsed', !isVisible); @@ -93,15 +102,25 @@ export function createSidePaneVisibility({ root, resizerHandle = null, initialRa const onTransitionEnd = (e) => { if (e.target === root && e.propertyName === 'width') done(); }; + let settled = false; + const detach = () => { + if (settled) return false; + settled = true; + root.removeEventListener('transitionend', onTransitionEnd); + return true; + }; const done = () => { + if (!detach()) return; + cancelTransition = null; clearPendingAnimation(); animating = false; - root.removeEventListener('transitionend', onTransitionEnd); + animationTarget = null; finish(); unlockContentWidth(); resizerHandle?.classList.remove('is-animating', 'is-animating-closing'); }; root.addEventListener('transitionend', onTransitionEnd); + cancelTransition = detach; animationTimer = setTimeout(done, ANIMATION_FALLBACK_MS); animationRafId = win.requestAnimationFrame(() => { animationRafId = null; @@ -110,8 +129,11 @@ export function createSidePaneVisibility({ root, resizerHandle = null, initialRa } function animateOpen() { + // 收起动画还没走完就反向展开:从当前宽度接着走,不先跳回 0 + const reversing = animating && animationTarget === false; clearPendingAnimation(); animating = true; + animationTarget = true; const targetPercent = formattedPercent(); lockContentWidth(readExpandedWidthPx()); @@ -120,8 +142,10 @@ export function createSidePaneVisibility({ root, resizerHandle = null, initialRa root.classList.remove('collapsed'); root.removeAttribute('aria-hidden'); root.classList.add('is-animating', 'active'); - root.style.width = '0%'; - root.style.opacity = '0'; + if (!reversing) { + root.style.width = '0%'; + root.style.opacity = '0'; + } resizerHandle?.classList.add('is-animating', 'is-animating-closing'); onSync(true); @@ -139,6 +163,7 @@ export function createSidePaneVisibility({ root, resizerHandle = null, initialRa function animateClose() { clearPendingAnimation(); animating = true; + animationTarget = false; const startPercent = root.style.width || formattedPercent(); lockContentWidth(root.getBoundingClientRect().width); @@ -197,6 +222,12 @@ export function createSidePaneVisibility({ root, resizerHandle = null, initialRa return; } + // 正朝同一个方向动画时不重启,否则展开到一半会跳回 0 宽 + if (animating && animationTarget === isVisible) { + onSync(isVisible); + return; + } + const currentlyVisible = root.classList.contains('active') && !root.classList.contains('collapsed'); if (isVisible === currentlyVisible && !animating) { if (isVisible) root.style.width = formattedPercent(); diff --git a/modules/ui-system/side-pane/tab-types/browser.js b/modules/ui-system/side-pane/tab-types/browser.js index 959325e4c..a8ebd0ad4 100644 --- a/modules/ui-system/side-pane/tab-types/browser.js +++ b/modules/ui-system/side-pane/tab-types/browser.js @@ -7,6 +7,7 @@ export function defineBrowserTabType({ document: doc, window: win, chatAPI, side }, ['openBrowserTab']); return Object.freeze({ kind: 'browser', label: '浏览器', icon: 'public', searchHint: '浏览器', provider, + dormancy: 'limit-only', entry: { id: 'browser', order: 40, open: () => provider.openBrowserTab() } }); } diff --git a/tests/side-chat-audit-probes.test.mjs b/tests/side-chat-audit-probes.test.mjs index dc56da949..88a26978f 100644 --- a/tests/side-chat-audit-probes.test.mjs +++ b/tests/side-chat-audit-probes.test.mjs @@ -87,6 +87,7 @@ test('R02: Reopening same child activates existing view without duplicate panels } }); + ctrl.setParent(createDescriptor('s1').parent); await ctrl.openTab({ kind: 'chat', descriptor: createDescriptor('s1') }); await ctrl.openTab({ kind: 'chat', descriptor: createDescriptor('s2') }); diff --git a/tests/side-chat-ephemeral-parity.test.mjs b/tests/side-chat-ephemeral-parity.test.mjs index 1517acccd..9179e2046 100644 --- a/tests/side-chat-ephemeral-parity.test.mjs +++ b/tests/side-chat-ephemeral-parity.test.mjs @@ -83,6 +83,7 @@ test('Parity: Closing selection-side-chat destroys runtime and is explicitly exc title: 'Ephemeral Side Chat' }); + ctrl.setParent(desc.parent); await ctrl.openTab({ kind: 'chat', descriptor: desc }); assert.equal(ctrl.getSnapshot().tabs.length, 2); // notifications + side chat assert.equal(ctrl.getSnapshot().activeTabId, desc.id); diff --git a/tests/side-chat-integration.test.mjs b/tests/side-chat-integration.test.mjs index a6478a0d8..3fd40cddd 100644 --- a/tests/side-chat-integration.test.mjs +++ b/tests/side-chat-integration.test.mjs @@ -100,6 +100,7 @@ test('Full Side Chat lifecycle integration: open, refer, send, and close', async model: 'test-model' }); + controller.setParent(desc.parent); const handle = await controller.openTab({ kind: 'chat', descriptor: desc }); if (opts.reference) { handle.addReference(opts.reference); diff --git a/tests/side-pane-controller.test.mjs b/tests/side-pane-controller.test.mjs index ed9cf096d..eb03f75ae 100644 --- a/tests/side-pane-controller.test.mjs +++ b/tests/side-pane-controller.test.mjs @@ -95,6 +95,7 @@ test('SidePaneController showNotifications and openChat mount views and sync vis title: '测试侧聊', }; + controller.setParent(desc.parent); await controller.openTab({ kind: 'chat', descriptor: desc }); assert.equal(controller.getSnapshot().activeTabId, 'side-chat-test'); assert.equal(tabListElement.children.length, 2); diff --git a/tests/side-pane-dormancy.test.mjs b/tests/side-pane-dormancy.test.mjs index 8c075bb0e..a057c345d 100644 --- a/tests/side-pane-dormancy.test.mjs +++ b/tests/side-pane-dormancy.test.mjs @@ -232,3 +232,45 @@ test('diagnostics count what each live view still holds, including what its prov await h.cleanup(); } }); + +test('policy: keep views do not take places under the live-view limit', () => { + const { release } = selectDormantViews([ + candidate('chat-1', { dormancy: 'keep' }), + candidate('chat-2', { dormancy: 'keep' }), + candidate('chat-3', { dormancy: 'keep' }), + candidate('browser', { dormancy: 'limit-only', hiddenSince: 0, lastShownAt: 10 }), + candidate('plan', { shown: true, hiddenSince: null, lastShownAt: 20 }) + ], { now: 0, maxLiveViews: 2 }); + assert.deepEqual(release, []); +}); + +test('policy: limit-only views never sleep for being hidden, only for the live-view limit', () => { + const now = 60 * 60_000; + const hiddenLong = selectDormantViews([ + candidate('browser', { dormancy: 'limit-only', hiddenSince: 0 }) + ], { now }); + assert.deepEqual(hiddenLong.release, []); + + const overLimit = selectDormantViews([ + candidate('browser-old', { dormancy: 'limit-only', lastShownAt: 1 }), + candidate('browser-new', { dormancy: 'limit-only', lastShownAt: 2 }), + candidate('shown', { shown: true, hiddenSince: null, lastShownAt: 3 }) + ], { now, maxLiveViews: 2 }); + assert.deepEqual(overLimit.release, [{ tabId: 'browser-old', reason: 'view-limit' }]); +}); + +test('collapsing the pane does not put the current tab to sleep', async () => { + const h = fixture({ hiddenMs: 30 }); + try { + await h.controller.openTab(tab('probe:a')); + h.controller.setVisible(false); + await sleep(80); + await settle(); + + assert.deepEqual(h.residency().live, ['probe:a']); + assert.deepEqual(h.residency().dormant, []); + h.controller.setVisible(true); + await settle(); + assert.equal(h.mounts.length, 1, 'expanding again shows the same view'); + } finally { await h.cleanup(); } +}); diff --git a/tests/side-pane-parent-scope.test.mjs b/tests/side-pane-parent-scope.test.mjs new file mode 100644 index 000000000..ee2936122 --- /dev/null +++ b/tests/side-pane-parent-scope.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { createSidePaneController } from '../modules/ui-system/side-pane/side-pane-controller.js'; +import { defineChatTabType } from '../modules/ui-system/side-pane/tab-types/chat.js'; + +const parentOf = topicId => ({ itemType: 'agent', itemId: 'agent', topicId }); +const descriptor = (id, topicId) => ({ + id, + title: id, + parent: parentOf(topicId), + child: { itemType: 'agent', itemId: 'agent', topicId: `child-${id}` } +}); + +function createController() { + const dom = new JSDOM('
'); + const doc = dom.window.document; + const closed = []; + const ctrl = createSidePaneController({ + root: doc.getElementById('pane'), + tabListElement: doc.getElementById('tabs'), + contentContainer: doc.getElementById('content'), + tabTypes: [defineChatTabType({ + provider: { mountTab: async () => ({ dispose: async () => {}, focus() {} }) }, + onClosed: desc => closed.push(desc.id) + })] + }); + return { doc, ctrl, closed }; +} + +test('without a current conversation, topic tabs of other conversations stay off the strip', async () => { + const { doc, ctrl } = createController(); + ctrl.setParent(parentOf('a')); + await ctrl.openTab({ kind: 'chat', descriptor: descriptor('side-a', 'a') }); + ctrl.setParent(parentOf('b')); + await ctrl.openTab({ kind: 'chat', descriptor: descriptor('side-b', 'b') }); + + ctrl.setParent(null); + + const stripIds = [...doc.querySelectorAll('#tabs [data-tab-id]')].map(el => el.dataset.tabId); + assert.ok(!stripIds.includes('side-a')); + assert.ok(!stripIds.includes('side-b')); + ctrl.dispose?.(); +}); + +test('close all without a current conversation does not delete other conversations\' side chats', async () => { + const { ctrl, closed } = createController(); + ctrl.setParent(parentOf('a')); + await ctrl.openTab({ kind: 'chat', descriptor: descriptor('side-a', 'a') }); + ctrl.setParent(parentOf('b')); + await ctrl.openTab({ kind: 'chat', descriptor: descriptor('side-b', 'b') }); + + ctrl.setParent(null); + await ctrl.closeAllTabs(); + + assert.deepEqual(closed, []); + assert.deepEqual(ctrl.getSnapshot().tabs.map(t => t.id).sort(), ['notifications', 'side-a', 'side-b']); + ctrl.dispose?.(); +}); diff --git a/tests/side-pane-state.test.mjs b/tests/side-pane-state.test.mjs index 24a0da6c0..b6a05d841 100644 --- a/tests/side-pane-state.test.mjs +++ b/tests/side-pane-state.test.mjs @@ -64,7 +64,7 @@ test('chat tab type validates the descriptor and reuses the tab of an open child title: '侧聊 1', }; - const s1 = openChat(s0, desc1); + const s1 = openChat(SidePaneState.setParent(s0, desc1.parent), desc1); assert.equal(s1.visible, true); assert.equal(s1.activeTabId, 'side-1'); assert.equal(s1.tabs.length, 2); @@ -97,7 +97,7 @@ test('closeTab handles fallback and protects notifications tab', () => { title: '侧聊 2', }; - let state = openChat(s0, desc1); + let state = openChat(SidePaneState.setParent(s0, desc1.parent), desc1); state = openChat(state, desc2); assert.equal(state.tabs.length, 3); assert.equal(state.activeTabId, 'side-2'); @@ -139,8 +139,9 @@ test('getVisibleTabs filters chat tabs by parent conversation reference', () => const visibleB = SidePaneState.getVisibleTabs(state, parentB); assert.deepEqual(visibleB.map(t => t.id), ['notifications', 'side-b1']); - const all = SidePaneState.getVisibleTabs(state, null); - assert.equal(all.length, 3); + const withoutParent = SidePaneState.getVisibleTabs(state, null); + assert.deepEqual(withoutParent.map(t => t.id), ['notifications']); + assert.deepEqual(SidePaneState.getClosableVisibleTabs(SidePaneState.setParent(state, null)), []); }); test('global tools retain selection and visibility across topic changes', () => { diff --git a/tests/side-pane-tab-keyboard.test.mjs b/tests/side-pane-tab-keyboard.test.mjs new file mode 100644 index 000000000..8caba9c23 --- /dev/null +++ b/tests/side-pane-tab-keyboard.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { createSidePaneController } from '../modules/ui-system/side-pane/side-pane-controller.js'; + +const settle = () => new Promise(resolve => setImmediate(resolve)); + +function createController() { + const dom = new JSDOM('
'); + const doc = dom.window.document; + const provider = { + mountTab(_payload, view) { + const input = doc.createElement('input'); + view.append(input); + return { focus: () => input.focus(), dispose() {} }; + } + }; + const ctrl = createSidePaneController({ + root: doc.getElementById('pane'), + tabListElement: doc.getElementById('tabs'), + contentContainer: doc.getElementById('content') + }); + ctrl.registerTabType({ kind: 'probe', label: 'Probe', provider }); + const tab = id => ({ id, kind: 'probe', title: id, closable: true, scopeMode: 'global' }); + const button = id => [...doc.querySelectorAll('#tabs [role="tab"]')].find(el => el.dataset.tabId === id); + const press = key => doc.activeElement.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key, bubbles: true })); + return { dom, doc, ctrl, tab, button, press }; +} + +test('arrow keys move the active tab and keep focus on the tab strip', async () => { + const { doc, ctrl, tab, button, press } = createController(); + try { + await ctrl.openTab(tab('probe:a')); + await ctrl.openTab(tab('probe:b')); + button('probe:b').focus(); + + press('ArrowLeft'); + await settle(); + assert.equal(ctrl.getSnapshot().activeTabId, 'probe:a'); + assert.equal(doc.activeElement, button('probe:a')); + + press('ArrowRight'); + await settle(); + assert.equal(ctrl.getSnapshot().activeTabId, 'probe:b'); + assert.equal(doc.activeElement, button('probe:b')); + } finally { await ctrl.dispose(); } +}); + +test('tab ids carrying Windows paths and quotes mount, focus and close', async () => { + const { doc, ctrl, tab, button } = createController(); + const windowsId = 'code-viewer:C:\\Users\\me\\proj\\a.js'; + const quotedId = 'code-viewer:/tmp/a"b.js'; + try { + await ctrl.openTab(tab(windowsId)); + await ctrl.openTab(tab(quotedId)); + assert.deepEqual(ctrl.getViewResidency().live.sort(), [quotedId, windowsId].sort()); + + await ctrl.closeTab(quotedId); + await settle(); + assert.equal(ctrl.getSnapshot().activeTabId, windowsId); + assert.equal(doc.activeElement, button(windowsId), 'focus lands on the remaining file tab'); + } finally { await ctrl.dispose(); } +}); diff --git a/tests/side-pane-terminal-lifecycle.test.mjs b/tests/side-pane-terminal-lifecycle.test.mjs index e3b82b72a..4fec7cec6 100644 --- a/tests/side-pane-terminal-lifecycle.test.mjs +++ b/tests/side-pane-terminal-lifecycle.test.mjs @@ -161,7 +161,8 @@ test('a sleeping terminal keeps its shell: the screen waits in the stash and com try { const handle = await h.provider.openTerminalTab(); await until(() => handle.getSessionId() === 'view:1'); - h.controller.setVisible(false); + // 切到别的标签让终端隐藏;面板收起时当前标签不休眠 + h.controller.showNotifications(); await new Promise(resolve => setTimeout(resolve, 60)); await until(() => h.controller.getViewResidency().dormant.length === 1); @@ -171,7 +172,7 @@ test('a sleeping terminal keeps its shell: the screen waits in the stash and com assert.equal(h.unsubscriptions, 0, 'output keeps flowing into the parked terminal'); h.listeners.get('data')?.({ id: 'view:1', data: 'while asleep' }); - h.controller.setVisible(true); + h.controller.activateTab('terminal:main'); await until(() => h.controller.getViewResidency().live.includes('terminal:main')); const view = h.controller.getTabHandle('terminal:main'); assert.equal(view.getSessionId(), 'view:1'); @@ -193,7 +194,8 @@ test('closing a terminal tab while it sleeps ends the parked session', async () try { await h.provider.openTerminalTab(); await until(() => h.status().dataset.state === 'connected'); - h.controller.setVisible(false); + // 切到别的标签让终端隐藏;面板收起时当前标签不休眠 + h.controller.showNotifications(); await new Promise(resolve => setTimeout(resolve, 60)); await until(() => h.stash()?.contains(h.screen()) === true); await h.controller.closeTab('terminal:main'); @@ -222,7 +224,8 @@ test('a live terminal view holds its buttons and size observer through the view assert.equal(live.byType.observer, 1); assert.equal(observers[0].targets.size, 1); - h.controller.setVisible(false); + // 切到别的标签让终端隐藏;面板收起时当前标签不休眠 + h.controller.showNotifications(); await new Promise(resolve => setTimeout(resolve, 60)); await until(() => terminalTab().view === 'dormant'); assert.equal(terminalTab().resources, null); diff --git a/tests/side-pane-visibility-animation.test.mjs b/tests/side-pane-visibility-animation.test.mjs new file mode 100644 index 000000000..68a75a83c --- /dev/null +++ b/tests/side-pane-visibility-animation.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { createSidePaneVisibility } from '../modules/ui-system/side-pane/side-pane-visibility.js'; + +function setup() { + const dom = new JSDOM('
', { pretendToBeVisual: true }); + const root = dom.window.document.getElementById('pane'); + const synced = []; + const visibility = createSidePaneVisibility({ root, initialRatio: 0.45, onSync: v => synced.push(v) }); + const transitionEnd = () => { + const event = new dom.window.Event('transitionend'); + event.propertyName = 'width'; + root.dispatchEvent(event); + }; + const frame = () => new Promise(resolve => dom.window.requestAnimationFrame(() => resolve())); + return { dom, root, visibility, transitionEnd, frame, synced }; +} + +test('reopening while the close animation runs ends expanded, not collapsed', async () => { + const { root, visibility, transitionEnd, frame } = setup(); + visibility.sync(true, { animate: false }); + + visibility.sync(false); + await frame(); + visibility.sync(true); + await frame(); + transitionEnd(); + + 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(); +}); + +test('a second open request during the open animation does not restart from zero width', async () => { + const { root, visibility, frame } = setup(); + visibility.sync(false, { animate: false }); + + visibility.sync(true); + await frame(); + const midWidth = root.style.width; + visibility.sync(true); + + assert.equal(root.style.width, midWidth); + assert.notEqual(root.style.width, '0%'); + visibility.dispose(); +}); + +test('closing while the open animation runs ends collapsed', async () => { + const { root, visibility, transitionEnd, frame } = setup(); + visibility.sync(false, { animate: false }); + + visibility.sync(true); + await frame(); + visibility.sync(false); + await frame(); + transitionEnd(); + + assert.ok(root.classList.contains('collapsed')); + assert.ok(!root.classList.contains('active')); + assert.equal(root.getAttribute('aria-hidden'), 'true'); + visibility.dispose(); +}); From 8d2d4295f07ca9e7385fe322ff6b6828edddfdc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:41:06 +0000 Subject: [PATCH 012/143] CI: run the side pane tests on every UI pull request The 430-odd side pane, side chat, git and browser tests were not in any workflow; test:side-pane gathers them and the Chat Kernel and UI job runs it. The PTY-backed terminal handler tests need a real PowerShell and stay out until a Windows job exists. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- .github/workflows/chat_kernel_ui.yml | 3 +++ package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/chat_kernel_ui.yml b/.github/workflows/chat_kernel_ui.yml index a9adaaee3..ebf406d00 100644 --- a/.github/workflows/chat_kernel_ui.yml +++ b/.github/workflows/chat_kernel_ui.yml @@ -54,6 +54,9 @@ jobs: - name: Run Chat Kernel tests run: npm run test:chat-kernel + - name: Run side pane tests + run: npm run test:side-pane + - name: Run Chat contract evidence gate env: VCPCHAT_PACKAGED_ARTIFACT_SMOKE_OUTPUT: ${{ runner.temp }}/packaged-artifact-smoke.json diff --git a/package.json b/package.json index 50c91de3a..70498447d 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "check:artifact-plane": "node scripts/check-artifact-plane.mjs", "test:built-artifact-smoke": "node scripts/test-built-artifact-smoke.mjs", "test:chat-transcript-snapshot": "node scripts/test-chat-transcript-snapshot.mjs", + "test:side-pane": "node --test tests/side-pane-*.test.mjs tests/side-chat-*.test.mjs tests/side-browser-address.test.mjs tests/side-plan-revert-confirmation.test.mjs tests/sidebar-resizer-lifecycle.test.mjs tests/conversation-status-panel.test.mjs tests/domain-activator.test.js tests/browser-handlers.test.js tests/git-service.test.js tests/git-watcher.test.js tests/git-file-diff.test.mjs tests/git-changes-source.test.mjs tests/git-workspace-source.test.mjs", "check:chat-evidence": "node scripts/check-chat-evidence.mjs", "check:chat-release-evidence": "node scripts/check-chat-release-evidence.mjs", "write:chat-evidence-manifest": "node scripts/write-chat-evidence-manifest.mjs", From b6abbd08c89a3c5c637f6ac6ff0f02322fe504d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:41:17 +0000 Subject: [PATCH 013/143] Code viewer: render code in content-visibility chunks so big files open and re-show in constant time Measured in the real Electron app (headless, 6000-line JS file): opening went from 1215ms to 330ms and switching back to the tab from 752ms to ~110ms with no long task. Before, focusing the re-shown view forced a full style and layout pass over ~60k highlight nodes; now only the chunks in the viewport take part. Gutter and code are split into 200-line blocks with content-visibility:auto and an intrinsic height from the fixed 18px line height, so numbers stay aligned. highlight.js still highlights the whole text once; its output is split per line with spans that cross lines closed and reopened, so multi-line comments and template strings keep their colours. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017bCbRB3E9yajQ3HbdMrjiJ --- .../ui-system/side-pane/code-viewer/editor.js | 88 +++++++++++++++---- styles/ui-system/side-pane-code-viewer.css | 7 ++ tests/side-pane-code-viewer.test.mjs | 37 ++++++++ 3 files changed, 114 insertions(+), 18 deletions(-) diff --git a/modules/ui-system/side-pane/code-viewer/editor.js b/modules/ui-system/side-pane/code-viewer/editor.js index 57e2a8f06..9444ebd8e 100644 --- a/modules/ui-system/side-pane/code-viewer/editor.js +++ b/modules/ui-system/side-pane/code-viewer/editor.js @@ -8,6 +8,38 @@ // 整段高亮和逐行行号的成本随文件大小线性增长;超过这个字符数只预览开头,复制和插入仍用完整内容 export const PREVIEW_CHAR_LIMIT = 256 * 1024; +// 每块的行数:块越小滚动时补排越勤,越大切回标签时要排的越多;200 行约 3600px,比侧栏高出几屏 +export const CODE_CHUNK_LINES = 200; + +/** + * 把 highlight.js 的整段输出按行切开:跨行的 (多行注释、模板字符串)在行尾闭合、下一行重新打开, + * 这样每块都是合法的 HTML,又不必按块各自高亮(那样会丢掉跨块的语法状态)。 + * highlight.js 只输出 和转义过的文本,所以按标签切分是安全的。 + */ +export function splitHighlightedLines(html) { + const lines = []; + const open = []; + let current = ''; + const token = /(]*>)|(<\/span>)|(\r?\n)|([^<\r\n]+)/g; + let match; + while ((match = token.exec(html))) { + if (match[1]) { + open.push(match[1]); + current += match[1]; + } else if (match[2]) { + open.pop(); + current += match[2]; + } else if (match[3]) { + lines.push(current + ''.repeat(open.length)); + current = open.join(''); + } else { + current += match[4]; + } + } + lines.push(current); + return lines; +} + // 文件末尾的换行只是行结束符,不算多出来的一行 function countLines(text) { let count = 1; @@ -82,33 +114,53 @@ export function createCodeViewerEditor({ if (store.isWrapped) editorShell.classList.add('is-wrapped'); // 文件末尾的换行只是行结束符,不算多出来的一行 - const lines = shownCode ? shownCode.replace(/\r?\n$/, '').split(/\r?\n/) : ['']; + const trimmed = shownCode.replace(/\r?\n$/, ''); + const lineCount = shownCode ? trimmed.split(/\r?\n/).length : 1; + const win = doc.defaultView || (typeof window !== 'undefined' ? window : null); + let lineHtml = null; + if (win?.hljs?.highlight) { + try { + lineHtml = splitHighlightedLines(win.hljs.highlight(trimmed, { language: store.currentLang, ignoreIllegals: true }).value); + } catch { + lineHtml = null; + } + } + const lineText = lineHtml ? null : (shownCode ? trimmed.split(/\r?\n/) : ['']); + + // 行号栏和代码各按 CODE_CHUNK_LINES 行分块,块用 content-visibility:auto:滚出视口的块不参与样式和布局。 + // 大文件切回这个标签、聚焦时,浏览器只需要排视口里的那几块,开销不再随文件行数增长。 + // 行高固定,两栏的块高度一致,行号仍然和代码对齐。 const gutter = doc.createElement('div'); gutter.className = 'side-code-gutter'; gutter.setAttribute('aria-hidden', 'true'); - - for (let idx = 1; idx <= lines.length; idx++) { - const lineNum = doc.createElement('div'); - lineNum.className = 'side-code-line-number'; - lineNum.textContent = String(idx); - gutter.appendChild(lineNum); - } - const pre = doc.createElement('pre'); pre.className = 'side-code-pre'; const code = doc.createElement('code'); code.className = `side-code-highlighted language-${store.currentLang}`; - const win = doc.defaultView || (typeof window !== 'undefined' ? window : null); - if (win?.hljs?.highlight) { - try { - const highlighted = win.hljs.highlight(shownCode, { language: store.currentLang, ignoreIllegals: true }); - code.innerHTML = highlighted.value; - } catch { - code.textContent = shownCode; + for (let start = 0; start < lineCount; start += CODE_CHUNK_LINES) { + const end = Math.min(lineCount, start + CODE_CHUNK_LINES); + const lines = String(end - start); + + const numbers = doc.createElement('div'); + numbers.className = 'side-code-chunk'; + numbers.style.setProperty('--side-code-chunk-lines', lines); + for (let idx = start + 1; idx <= end; idx++) { + const lineNum = doc.createElement('div'); + lineNum.className = 'side-code-line-number'; + lineNum.textContent = String(idx); + numbers.appendChild(lineNum); } - } else { - code.textContent = shownCode; + gutter.appendChild(numbers); + + const text = doc.createElement('div'); + text.className = 'side-code-chunk'; + text.style.setProperty('--side-code-chunk-lines', lines); + // 块末尾补一个换行:块尾的换行不会多画一行,但 textContent 和复制出来的文本保持原样 + const tail = end < lineCount ? '\n' : ''; + if (lineHtml) text.innerHTML = lineHtml.slice(start, end).join('\n') + tail; + else text.textContent = lineText.slice(start, end).join('\n') + tail; + code.appendChild(text); } pre.appendChild(code); diff --git a/styles/ui-system/side-pane-code-viewer.css b/styles/ui-system/side-pane-code-viewer.css index 1f4b5033a..b0dcc3efd 100644 --- a/styles/ui-system/side-pane-code-viewer.css +++ b/styles/ui-system/side-pane-code-viewer.css @@ -188,6 +188,13 @@ html body[data-vcp-theme="light"] .side-code-gutter:where(.vcp-ui-scope, .vcp-ui color: var(--secondary-text, var(--vcp-ui-port-color-rgba-0-0-0-0-35)); } +/* 行号栏和代码的分块(见 editor.js 的 CODE_CHUNK_LINES):滚出视口时浏览器跳过它的样式和布局,占位高度按行数算 */ +html .side-code-chunk:where(.vcp-ui-scope, .vcp-ui-scope *) { + display: block; + content-visibility: auto; + contain-intrinsic-block-size: auto calc(var(--side-code-chunk-lines, 1) * 18px); +} + html .side-code-line-number:where(.vcp-ui-scope, .vcp-ui-scope *) { height: 18px; line-height: 18px; diff --git a/tests/side-pane-code-viewer.test.mjs b/tests/side-pane-code-viewer.test.mjs index 08a5e0715..04d42eea8 100644 --- a/tests/side-pane-code-viewer.test.mjs +++ b/tests/side-pane-code-viewer.test.mjs @@ -308,3 +308,40 @@ test('large files only render the first preview chunk, cut at a line end', async dom.window.close(); } }); + +test('code is rendered in chunks whose text and line numbers stay continuous', async () => { + const { CODE_CHUNK_LINES } = await import('../modules/ui-system/side-pane/code-viewer/editor.js'); + const dom = new JSDOM('
'); + const doc = dom.window.document; + const lineCount = CODE_CHUNK_LINES * 2 + 50; + const code = Array.from({ length: lineCount }, (_, i) => `line ${i + 1}`).join('\n'); + const provider = createCodeViewerSideProvider({ document: doc, uiHelper: null, api: null }); + const view = doc.getElementById('view'); + const handle = await provider.mountTab({ title: 'chunks.txt', payload: { code, language: 'plaintext' } }, view); + try { + const codeChunks = view.querySelectorAll('.side-code-pre .side-code-chunk'); + const gutterChunks = view.querySelectorAll('.side-code-gutter .side-code-chunk'); + assert.equal(codeChunks.length, 3); + assert.deepEqual([...gutterChunks].map(chunk => chunk.children.length), [CODE_CHUNK_LINES, CODE_CHUNK_LINES, 50]); + assert.deepEqual([...codeChunks].map(chunk => chunk.style.getPropertyValue('--side-code-chunk-lines')), [String(CODE_CHUNK_LINES), String(CODE_CHUNK_LINES), '50']); + assert.equal(view.querySelector('.side-code-pre').textContent, code); + assert.equal(view.querySelectorAll('.side-code-line-number').length, lineCount); + assert.equal([...view.querySelectorAll('.side-code-line-number')].at(-1).textContent, String(lineCount)); + } finally { + handle.dispose(); + dom.window.close(); + } +}); + +test('highlighted html is split per line with spans that cross lines closed and reopened', async () => { + const { splitHighlightedLines } = await import('../modules/ui-system/side-pane/code-viewer/editor.js'); + const html = '/* a\nb */ x\n`c\n${d}\ne`'; + assert.deepEqual(splitHighlightedLines(html), [ + '/* a', + 'b */ x', + '`c', + '${d}', + 'e`' + ]); + assert.deepEqual(splitHighlightedLines(''), ['']); +}); From 04195c72c0fef3d7a12c6e2d2fd7f10164a937c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:42:17 +0000 Subject: [PATCH 014/143] Code viewer: ask before reading a file outside the workspaces File paths shown as changed files come from the model's tool-call arguments, and the viewer read any of them through get-text-content, so a prompt-injected path such as ~/.ssh/id_rsa showed its content, one click from being inserted back into the chat. Like the workspace-bound previews in ZCode and DeepSeek Harness, a path outside every registered workspace now shows where it is and is only read after the user clicks to read it. Workspace files are read as before; windows without the workspace service keep the old behaviour. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../contracts/generated/chat-event-graph.json | 11 ++++++ .../ui-system/side-pane/code-viewer/editor.js | 30 ++++++++++++++-- .../side-pane/code-viewer/file-read.js | 31 +++++++++++------ .../side-pane/codeViewerSideProvider.js | 2 +- tests/side-pane-code-viewer.test.mjs | 34 +++++++++++++++++++ 5 files changed, 95 insertions(+), 13 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 3963b815c..057f50dc0 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -4183,6 +4183,11 @@ "line": 79, "kind": "custom-event-listener" }, + { + "file": "modules/ui-system/side-pane/code-viewer/editor.js", + "line": 54, + "kind": "custom-event-listener" + }, { "file": "modules/ui-system/side-pane/git/cards.js", "line": 220, @@ -5743,6 +5748,12 @@ "kind": "custom-event-listener", "match": "more.addEventListener('click'" }, + { + "file": "modules/ui-system/side-pane/code-viewer/editor.js", + "line": 54, + "kind": "custom-event-listener", + "match": "read.addEventListener('click'" + }, { "file": "modules/ui-system/side-pane/git/cards.js", "line": 220, diff --git a/modules/ui-system/side-pane/code-viewer/editor.js b/modules/ui-system/side-pane/code-viewer/editor.js index 57e2a8f06..7d3c1d7c9 100644 --- a/modules/ui-system/side-pane/code-viewer/editor.js +++ b/modules/ui-system/side-pane/code-viewer/editor.js @@ -25,6 +25,8 @@ export function createCodeViewerEditor({ // 只有按路径打开的文件标签才有 readFile;代码片段和差异是打开时的快照,不重新读取 let fileLoaded = false; let readToken = 0; + // 用户点过「读取」后,工作区外的这个文件就不再问 + let outsideWorkspaceAllowed = false; function setBodyMessage(text, isError = false) { body.innerHTML = ''; @@ -34,6 +36,29 @@ export function createCodeViewerEditor({ body.appendChild(msg); } + function showConsent(notice) { + body.innerHTML = ''; + const box = doc.createElement('div'); + box.className = 'side-pane-mount-error side-code-consent'; + const [title, ...rest] = String(notice || '').split('\n'); + const titleEl = doc.createElement('div'); + titleEl.className = 'side-pane-mount-error-title'; + titleEl.textContent = title; + const detail = doc.createElement('div'); + detail.className = 'side-pane-mount-error-detail'; + detail.textContent = rest.join('\n'); + const read = doc.createElement('button'); + read.type = 'button'; + read.className = 'side-pane-mount-error-retry'; + read.textContent = '读取这个文件'; + read.addEventListener('click', () => { + outsideWorkspaceAllowed = true; + void refreshView({ force: true }); + }); + box.append(titleEl, detail, read); + body.appendChild(box); + } + /** * 读取文件内容到 store。返回 true 表示可以渲染;false 表示已显示错误/提示,或者被更新的读取取代。 * force 用于重新打开或手动刷新:文件可能已在外部被修改或删除。 @@ -44,7 +69,7 @@ export function createCodeViewerEditor({ body.innerHTML = '
sync 加载文件中...
'; let result; try { - result = await readFile(); + result = await readFile({ allowOutsideWorkspace: outsideWorkspaceAllowed }); } catch (err) { result = { ok: false, error: `读取文件失败: ${err?.message || err}` }; } @@ -53,7 +78,8 @@ export function createCodeViewerEditor({ // 失败时不保留旧内容,免得复制/插入拿到已经不存在的文件内容 store.currentCode = ''; fileLoaded = false; - if (result?.notice) setBodyMessage(result.notice); + if (result?.needsConsent) showConsent(result.notice); + else if (result?.notice) setBodyMessage(result.notice); else setBodyMessage(result?.error || '读取文件失败', true); return false; } diff --git a/modules/ui-system/side-pane/code-viewer/file-read.js b/modules/ui-system/side-pane/code-viewer/file-read.js index dafa24f5f..6ae838f34 100644 --- a/modules/ui-system/side-pane/code-viewer/file-read.js +++ b/modules/ui-system/side-pane/code-viewer/file-read.js @@ -12,9 +12,18 @@ import { toWorkspaceRelative } from '../../git-file-diff.js'; const UNREADABLE = '读取文件失败:文件不存在、无法访问,或不是可预览的文本文件'; -export async function readFileForViewer(api, filePath) { +/** + * allowOutsideWorkspace:已登记工作区以外的路径要用户点过「读取」才读。路径可能来自模型的工具调用参数, + * 不先问就能把任意本地文件(SSH 私钥、配置里的密钥)显示出来再被「插入引用」送回模型。 + * ZCode、DeepSeek Harness 的预览同样只认工作区内的路径("outside the workspace" 直接拒绝),这里留一次确认给附件之类的文件。 + */ +export async function readFileForViewer(api, filePath, { allowOutsideWorkspace = false } = {}) { const fromWorkspace = await readThroughWorkspace(api, filePath); - if (fromWorkspace) return fromWorkspace; + if (fromWorkspace?.result) return fromWorkspace.result; + // 没有工作区服务的窗口分不清内外,照旧读 + if (fromWorkspace && !fromWorkspace.inWorkspace && !allowOutsideWorkspace) { + return { ok: false, needsConsent: true, notice: `这个文件不在任何已登记的工作区里:\n${filePath}` }; + } if (typeof api?.getTextContent !== 'function') return { ok: false, error: '当前窗口不支持读取文件' }; const res = await api.getTextContent(filePath); if (typeof res === 'string') return { ok: true, text: res }; @@ -24,25 +33,27 @@ export async function readFileForViewer(api, filePath) { return { ok: false, error: UNREADABLE }; } -// 已登记工作区里的文件走源码服务:能分清不存在、二进制和过大,并且读取有上限 +// 已登记工作区里的文件走源码服务:能分清不存在、二进制和过大,并且读取有上限。 +// 返回 { inWorkspace, result }:result 为空表示要交给附件读取(不在工作区,或不是 UTF-8);读不到工作区列表时返回 null async function readThroughWorkspace(api, filePath) { if (typeof api?.gitListWorkspaces !== 'function' || typeof api?.sourceReadFile !== 'function') return null; let match = null; try { const res = await api.gitListWorkspaces(); - match = res?.success ? toWorkspaceRelative(filePath, res.data?.workspaces) : null; + if (!res?.success) return null; + match = toWorkspaceRelative(filePath, res.data?.workspaces); } catch (_error) { return null; } - if (!match) return null; + if (!match) return { inWorkspace: false, result: null }; const res = await api.sourceReadFile(match.workspace.id, match.relPath); - if (!res?.success) return { ok: false, error: res?.error || '读取文件失败' }; + if (!res?.success) return { inWorkspace: true, result: { ok: false, error: res?.error || '读取文件失败' } }; const file = res.data || {}; - if (file.binary) return { ok: false, notice: '二进制文件,无法预览' }; + if (file.binary) return { inWorkspace: true, result: { ok: false, notice: '二进制文件,无法预览' } }; if (file.tooLarge) { - return { ok: false, notice: `文件过大(${Math.round((file.size || 0) / 1024)} KB),无法预览,请在外部编辑器中打开` }; + return { inWorkspace: true, result: { ok: false, notice: `文件过大(${Math.round((file.size || 0) / 1024)} KB),无法预览,请在外部编辑器中打开` } }; } // 不是 UTF-8(比如 GBK)时交给附件读取,它会按 GB18030 解码 - if (file.encodingError) return null; - return { ok: true, text: typeof file.text === 'string' ? file.text : '' }; + if (file.encodingError) return { inWorkspace: true, result: null }; + return { inWorkspace: true, result: { ok: true, text: typeof file.text === 'string' ? file.text : '' } }; } diff --git a/modules/ui-system/side-pane/codeViewerSideProvider.js b/modules/ui-system/side-pane/codeViewerSideProvider.js index 3e7eb8ddb..38968820f 100644 --- a/modules/ui-system/side-pane/codeViewerSideProvider.js +++ b/modules/ui-system/side-pane/codeViewerSideProvider.js @@ -236,7 +236,7 @@ export function createCodeViewerSideProvider({ store, body, doc, - readFile: isFileBacked ? () => readFileForViewer(api, filePath) : null, + readFile: isFileBacked ? (options) => readFileForViewer(api, filePath, options) : null, renderDiffView: (...args) => renderDiffView(...args) }); const { setBodyMessage, renderCodeView, refreshView, reload } = editorOwner; diff --git a/tests/side-pane-code-viewer.test.mjs b/tests/side-pane-code-viewer.test.mjs index 95f381327..34baf3515 100644 --- a/tests/side-pane-code-viewer.test.mjs +++ b/tests/side-pane-code-viewer.test.mjs @@ -339,3 +339,37 @@ test('the file button reveals a workspace file in the file manager and never ope assert.deepEqual(opened, [], 'nothing is opened through a file association'); dom.window.close(); }); + +test('a path outside every workspace is only read after the user asks for it', async () => { + const dom = new JSDOM('
'); + const doc = dom.window.document; + const reads = []; + const provider = createCodeViewerSideProvider({ document: doc, uiHelper: null, api: { + gitListWorkspaces: async () => ({ success: true, data: { workspaces: [{ id: 'ws1', path: 'C:\\proj' }] } }), + sourceReadFile: async () => ({ success: true, data: { text: 'in workspace' } }), + async getTextContent(filePath) { reads.push(filePath); return { text: 'PRIVATE KEY' }; } + } }); + const view = doc.getElementById('view'); + const handle = await provider.mountTab({ title: 'id_rsa', payload: { filePath: 'C:\\Users\\me\\.ssh\\id_rsa' } }, view); + try { + assert.deepEqual(reads, [], 'nothing is read before the user agrees'); + assert.equal(handle.getCode(), ''); + assert.match(view.textContent, /不在任何已登记的工作区里/); + assert.match(view.textContent, /\.ssh/); + const consent = [...view.querySelectorAll('button')].find(b => b.textContent === '读取这个文件'); + consent.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + assert.deepEqual(reads, ['C:\\Users\\me\\.ssh\\id_rsa']); + assert.equal(handle.getCode(), 'PRIVATE KEY'); + + // 工作区里的文件照常直接读 + const inside = doc.createElement('section'); + doc.body.append(inside); + const insideHandle = await provider.mountTab({ title: 'a.js', payload: { filePath: 'C:\\proj\\a.js' } }, inside); + assert.equal(insideHandle.getCode(), 'in workspace'); + insideHandle.dispose(); + } finally { + handle.dispose(); + dom.window.close(); + } +}); From 43f6334b440a4b81b3b4945b2338ae33332c023b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:43:14 +0000 Subject: [PATCH 015/143] Git service: never run the repo's own core.fsmonitor command git status runs whatever command a repository's .git/config names in core.fsmonitor. A project unzipped with a prepared .git folder could therefore run code as soon as the Git page read its status. Every git call now passes -c core.fsmonitor=false, as DeepSeek Harness does in scripts/change-scope.ts. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- modules/services/gitService.js | 2 ++ tests/side-pane-git-real-backend.test.mjs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/modules/services/gitService.js b/modules/services/gitService.js index eee6f797a..ca3b01aba 100644 --- a/modules/services/gitService.js +++ b/modules/services/gitService.js @@ -64,6 +64,8 @@ function runGit(cwd, args, { timeout = DEFAULT_TIMEOUT, input = null } = {}) { const child = execFile('git', [ '-c', 'core.quotepath=false', '-c', 'color.ui=false', + // 仓库自带的 .git/config 可以把 core.fsmonitor 设成任意命令,只读的 status 也会执行它(同 DeepSeek Harness scripts/change-scope.ts) + '-c', 'core.fsmonitor=false', '--literal-pathspecs', // 路径按字面匹配,禁用 glob / 魔法前缀 ...args, ], { diff --git a/tests/side-pane-git-real-backend.test.mjs b/tests/side-pane-git-real-backend.test.mjs index 2cc68a541..416cafbc2 100644 --- a/tests/side-pane-git-real-backend.test.mjs +++ b/tests/side-pane-git-real-backend.test.mjs @@ -135,3 +135,21 @@ test('the Git view shows an add-workspace action when none are registered', asyn await handle.dispose(); } }); + +test('reading the status of a repo never runs the core.fsmonitor command its own config names', { skip: process.platform === 'win32' }, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vcp-fsmonitor-')); + try { + const marker = path.join(root, 'ran'); + const monitor = path.join(root, 'monitor.sh'); + fs.writeFileSync(monitor, `#!/bin/sh\ntouch "${marker}"\n`, { mode: 0o755 }); + const repo = path.join(root, 'repo'); + fs.mkdirSync(repo); + execFileSync('git', ['init', '-q'], { cwd: repo }); + execFileSync('git', ['config', 'core.fsmonitor', monitor], { cwd: repo }); + fs.writeFileSync(path.join(repo, 'a.txt'), 'x'); + await gitService.getStatus(repo); + assert.equal(fs.existsSync(marker), false, 'a prepared .git/config cannot run a command when the Git page opens'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); From 768de2015881a99430b2436d6a62c91b79b6ae76 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:22:28 +0000 Subject: [PATCH 016/143] UI system gate: scope the tool presentation styles and drop the stray literal colour check:ui-system was red on the side pane branch. tool-presentation.css had 101 selectors outside .vcp-ui-scope, an !important on the reduced-motion rule and two fixed 11px sizes; components.css (from upstream #234) fell back to a hard-coded indigo because --vcp-ui-primary is not a token. Every tool presentation selector now sits under .vcp-ui-scope (the chat messages container already carries it, and the file is alone in its layer, so the uniform specificity bump changes no cascade). The reduced-motion rule reuses the shimmer selector instead of !important, the 11px sizes use --vcp-ui-text-xs, and the pinned window button uses the accent token. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UCGGgmHsCToooXTgSXpmq9 --- styles/ui-system/components.css | 4 +- styles/ui-system/tool-presentation.css | 196 ++++++++++++------------- 2 files changed, 100 insertions(+), 100 deletions(-) diff --git a/styles/ui-system/components.css b/styles/ui-system/components.css index 418b7df8b..ddff52098 100644 --- a/styles/ui-system/components.css +++ b/styles/ui-system/components.css @@ -541,8 +541,8 @@ } :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned { - color: var(--vcp-ui-primary, #6366f1); - background: var(--vcp-ui-primary-bg, rgba(99, 102, 241, 0.16)); + color: var(--vcp-ui-accent); + background: color-mix(in srgb, var(--vcp-ui-accent) 16%, transparent); } :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned .vcp-ui-icon, diff --git a/styles/ui-system/tool-presentation.css b/styles/ui-system/tool-presentation.css index 436a2e39c..be8a90316 100644 --- a/styles/ui-system/tool-presentation.css +++ b/styles/ui-system/tool-presentation.css @@ -1,12 +1,12 @@ /* Optional presentation; Legacy is untouched. Theme and typography stay owned * by the existing appearance profile, including role wallpapers. */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="compact"], -html[data-ui-mode="next"] [data-vcp-tool-presentation="grouped"] { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="compact"], +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="grouped"] { --vcp-tool-row-height: 38px; } -html[data-ui-mode="next"][data-vcp-density="compact"] [data-vcp-tool-presentation] { --vcp-tool-row-height: 32px; } -html[data-ui-mode="next"][data-vcp-density="relaxed"] [data-vcp-tool-presentation] { --vcp-tool-row-height: 44px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented { +html[data-ui-mode="next"][data-vcp-density="compact"] .vcp-ui-scope [data-vcp-tool-presentation] { --vcp-tool-row-height: 32px; } +html[data-ui-mode="next"][data-vcp-density="relaxed"] .vcp-ui-scope [data-vcp-tool-presentation] { --vcp-tool-row-height: 44px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented { --vcp-tool-presented-font-family: var(--vcp-tool-card-font-family, var(--vcp-chat-font-family)); --vcp-tool-presented-font-size: var(--vcp-ui-text-caption, 13px); --vcp-tool-presented-white-space: normal; @@ -32,11 +32,11 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented { color: var(--vcp-ui-text-primary); font-family: var(--vcp-tool-card-font-family, inherit); } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented::before, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented::after { display: none; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-summary, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-header, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-header { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented::before, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented::after { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-summary, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-header, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-header { display: flex; gap: 5px; padding: 0; @@ -47,7 +47,7 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vc min-width: 0; flex-wrap: nowrap; } -html[data-ui-mode="next"] .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-tool-process-toggle { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { display: flex; width: 100%; min-width: 0; @@ -65,13 +65,13 @@ html[data-ui-mode="next"] .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-t cursor: pointer; text-align: left; } -html[data-ui-mode="next"] .vcp-tool-result-header > .vcp-tool-row-toggle { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-result-header > .vcp-tool-row-toggle { width: auto; flex: 1; } -html[data-ui-mode="next"] .vcp-tool-row-toggle:hover, html[data-ui-mode="next"] .vcp-tool-process-toggle:hover { background: var(--vcp-ui-bg-2); } -html[data-ui-mode="next"] .vcp-tool-row-toggle:focus-visible, html[data-ui-mode="next"] .vcp-tool-process-toggle:focus-visible, html[data-ui-mode="next"] .vcp-tool-process-artifact:focus-visible { outline: 2px solid var(--vcp-ui-accent); outline-offset: -2px; } -html[data-ui-mode="next"] .vcp-tool-row-chevron { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle:hover, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle:hover { background: var(--vcp-ui-bg-2); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle:focus-visible, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle:focus-visible, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-artifact:focus-visible { outline: 2px solid var(--vcp-ui-accent); outline-offset: -2px; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-chevron { width: 7px; height: 7px; flex: 0 0 7px; @@ -79,44 +79,44 @@ html[data-ui-mode="next"] .vcp-tool-row-chevron { border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); } -html[data-ui-mode="next"] [aria-expanded="true"] > .vcp-tool-row-chevron { transform: rotate(45deg); } -html[data-ui-mode="next"] .vcp-tool-row-title { overflow-wrap: anywhere; min-width: 0; color: var(--vcp-ui-text-primary); } -html[data-ui-mode="next"] .vcp-tool-row-resource { overflow-wrap: anywhere; min-width: 0; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] .vcp-tool-row-state { margin-left: auto; white-space: nowrap; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] [data-vcp-tool-state="success"] .vcp-tool-row-state { color: var(--vcp-ui-success); } -html[data-ui-mode="next"] [data-vcp-tool-state="failed"] .vcp-tool-row-state { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-state="waiting"] .vcp-tool-row-state, -html[data-ui-mode="next"] [data-vcp-tool-state="stopped"] .vcp-tool-row-state { color: var(--vcp-ui-warning, var(--vcp-ui-palette-amber)); } -html[data-ui-mode="next"] [data-vcp-tool-state="running"] .vcp-tool-row-state { color: var(--vcp-ui-accent); } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-delete-btn { flex: 0 0 24px; margin-right: 6px; opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:hover .vcp-tool-result-delete-btn, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:focus-within .vcp-tool-result-delete-btn { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="compact"] .vcp-role-divider, -html[data-ui-mode="next"] [data-vcp-tool-presentation="grouped"] .vcp-role-divider { margin: 4px 0; font-size: 11px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-details { max-height: 360px; overflow: auto; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-content { +html[data-ui-mode="next"] .vcp-ui-scope [aria-expanded="true"] > .vcp-tool-row-chevron { transform: rotate(45deg); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-title { overflow-wrap: anywhere; min-width: 0; color: var(--vcp-ui-text-primary); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-resource { overflow-wrap: anywhere; min-width: 0; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-state { margin-left: auto; white-space: nowrap; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="success"] .vcp-tool-row-state { color: var(--vcp-ui-success); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="failed"] .vcp-tool-row-state { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="waiting"] .vcp-tool-row-state, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="stopped"] .vcp-tool-row-state { color: var(--vcp-ui-warning, var(--vcp-ui-palette-amber)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="running"] .vcp-tool-row-state { color: var(--vcp-ui-accent); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-delete-btn { flex: 0 0 24px; margin-right: 6px; opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:hover .vcp-tool-result-delete-btn, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:focus-within .vcp-tool-result-delete-btn { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="compact"] .vcp-role-divider, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="grouped"] .vcp-role-divider { margin: 4px 0; font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-details { max-height: 360px; overflow: auto; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-content { margin: 0; padding: 6px 12px 12px 28px; background: transparent; border: 0; min-width: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-call-summary-content { display: none; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-call-summary-content { display: block; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented pre { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-call-summary-content { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-call-summary-content { display: block; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented pre { white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-height: 320px; overflow: auto; } -html[data-ui-mode="next"] .vcp-tool-process { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process { border: 1px solid var(--vcp-ui-border); border-radius: var(--vcp-ui-radius-md, 8px); margin: 10px 0; @@ -124,12 +124,12 @@ html[data-ui-mode="next"] .vcp-tool-process { overflow: hidden; background: var(--vcp-ui-bg-0); } -html[data-ui-mode="next"] .vcp-tool-process-toggle { background: var(--vcp-ui-bg-1); border-radius: 0; } -html[data-ui-mode="next"] .vcp-tool-process-title { min-width: 0; overflow-wrap: anywhere; } -html[data-ui-mode="next"] .vcp-tool-process-stats { margin-left: auto; font-size: var(--vcp-ui-text-sm, 12px); overflow-wrap: anywhere; } -html[data-ui-mode="next"] .vcp-tool-process-body[hidden] { display: none; } -html[data-ui-mode="next"] .vcp-tool-process-body > .vcp-tool-presented { margin: 0; border-top: 1px solid var(--vcp-ui-border); } -html[data-ui-mode="next"] .vcp-tool-process-artifact { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { background: var(--vcp-ui-bg-1); border-radius: 0; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-title { min-width: 0; overflow-wrap: anywhere; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-stats { margin-left: auto; font-size: var(--vcp-ui-text-sm, 12px); overflow-wrap: anywhere; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-body[hidden] { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-body > .vcp-tool-presented { margin: 0; border-top: 1px solid var(--vcp-ui-border); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-artifact { color: var(--vcp-ui-text-accent); background: transparent; border: 0; @@ -142,20 +142,20 @@ html[data-ui-mode="next"] .vcp-tool-process-artifact { cursor: pointer; } @media (max-width: 640px) { - html[data-ui-mode="next"] .vcp-tool-row-toggle { flex-wrap: wrap; gap: 6px; } - html[data-ui-mode="next"] .vcp-tool-row-state { margin-left: auto; } - html[data-ui-mode="next"] .vcp-tool-process-toggle { flex-wrap: wrap; } - html[data-ui-mode="next"] .vcp-tool-process-stats { margin-left: 16px; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle { flex-wrap: wrap; gap: 6px; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-state { margin-left: auto; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { flex-wrap: wrap; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-stats { margin-left: 16px; } } /* 单行合并 / 整轮折叠:无框单行,一次调用一行;紧跟的同名结果收进请求行,展开后才显示。 */ -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) { --vcp-tool-call-color: var(--vcp-ui-text-3, var(--vcp-ui-text-secondary)); --vcp-tool-call-hover: var(--vcp-ui-text-2, var(--vcp-ui-text-secondary)); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-role-divider { margin: 4px 0; font-size: 11px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented { margin: 2px 0; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-role-divider { margin: 4px 0; font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented { margin: 2px 0; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { width: auto; max-width: 100%; min-height: 24px; @@ -168,10 +168,10 @@ html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-t color: var(--vcp-tool-call-color); transition: color .1s; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):hover { background: transparent; color: var(--vcp-tool-call-hover); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { flex: none; color: inherit; white-space: nowrap; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-process-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-resource { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):hover { background: transparent; color: var(--vcp-tool-call-hover); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { flex: none; color: inherit; white-space: nowrap; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-process-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-resource { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -179,20 +179,20 @@ html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-t overflow-wrap: normal; font-size: inherit; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-state { flex: none; margin-left: 0; color: inherit; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon { display: inline-flex; flex: none; width: 16px; height: 16px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon svg { width: 100%; height: 100%; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-diff { flex: none; font-family: var(--vcp-ui-font-mono, ui-monospace, Consolas, monospace); font-size: 11px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-add { color: var(--vcp-ui-success); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-del { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-state { flex: none; margin-left: 0; color: inherit; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon { display: inline-flex; flex: none; width: 16px; height: 16px; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon svg { width: 100%; height: 100%; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-diff { flex: none; font-family: var(--vcp-ui-font-mono, ui-monospace, Consolas, monospace); font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-add { color: var(--vcp-ui-success); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-del { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } /* 并入请求行的结果:收起时不占位,展开后只留一个小标题“输出”。 - * 旧层给结果气泡写了 display:block !important,这里只能藏标题、去掉外边距,正文由上面的收起规则隐藏。 */ -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) { margin: 0; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) > .vcp-tool-result-header { display: none; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"] .vcp-tool-row-toggle { min-height: 20px; padding-left: 24px; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented > :is(.vcp-tool-details, .vcp-tool-result-collapsible-content, .vcp-tool-call-summary-content) { padding: 8px 0 4px 24px; } + * 旧层给结果气泡写了强制的 display:block(important 级),这里只能藏标题、去掉外边距,正文由上面的收起规则隐藏。 */ +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) { margin: 0; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) > .vcp-tool-result-header { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"] .vcp-tool-row-toggle { min-height: 20px; padding-left: 24px; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented > :is(.vcp-tool-details, .vcp-tool-result-collapsible-content, .vcp-tool-call-summary-content) { padding: 8px 0 4px 24px; } /* 等待结果时动作词的微光,只在回复生成中出现。 */ -html[data-ui-mode="next"] .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { +html[data-ui-mode="next"] .vcp-ui-scope .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { background: linear-gradient(90deg, var(--vcp-tool-call-color) 0%, var(--vcp-ui-text-primary) 50%, var(--vcp-tool-call-color) 100%); background-size: 200% 100%; -webkit-background-clip: text; @@ -202,33 +202,33 @@ html[data-ui-mode="next"] .message-item.streaming :is([data-vcp-tool-presentatio } @keyframes vcp-tool-call-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } } @media (prefers-reduced-motion: reduce) { - html[data-ui-mode="next"] [data-vcp-tool-pending="true"] .vcp-tool-row-title { animation: none !important; } + html[data-ui-mode="next"] .vcp-ui-scope .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { animation: none; } } /* 单行合并:动作词加粗,箭头在行尾、悬停或展开时出现;问题状态用虚线下划线,悬停看报错。 */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { font-size: var(--vcp-ui-text-body, 14px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { font-weight: 500; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) > .vcp-tool-row-chevron { order: 1; opacity: 0; transition: opacity .2s, transform .2s; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):is(:hover, [aria-expanded="true"]) > .vcp-tool-row-chevron { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] [data-vcp-tool-call-state] .vcp-tool-row-state { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] { margin: 2px 0; border: 0; border-radius: 0; background: transparent; overflow: visible; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] .vcp-tool-process-stats { margin-left: 0; font-size: inherit; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body { margin: 4px 0 4px 8px; padding-left: 14px; border-left: 1px solid var(--vcp-ui-border); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-artifact { border-top: 0; padding: 4px 0 4px 22px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { font-size: var(--vcp-ui-text-body, 14px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { font-weight: 500; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) > .vcp-tool-row-chevron { order: 1; opacity: 0; transition: opacity .2s, transform .2s; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):is(:hover, [aria-expanded="true"]) > .vcp-tool-row-chevron { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] [data-vcp-tool-call-state] .vcp-tool-row-state { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] { margin: 2px 0; border: 0; border-radius: 0; background: transparent; overflow: visible; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] .vcp-tool-process-stats { margin-left: 0; font-size: inherit; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body { margin: 4px 0 4px 8px; padding-left: 14px; border-left: 1px solid var(--vcp-ui-border); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-artifact { border-top: 0; padding: 4px 0 4px 22px; } /* 整轮折叠:图标悬停换成箭头,标题与摘要之间用小圆点分隔,失败时摘要显示红色报错首行。 */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle { gap: 0; font-size: var(--vcp-ui-text-caption, 13px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading { position: relative; display: inline-flex; flex: none; width: 16px; height: 16px; margin-right: 6px; align-items: center; justify-content: center; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-icon { width: 14px; height: 14px; transition: opacity .1s; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-chevron { position: absolute; inset: 0; margin: auto; opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-icon { opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-chevron { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-dot { flex: none; width: 2px; height: 2px; margin: 0 8px; border-radius: 1px; background: currentColor; opacity: .7; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle :is(.vcp-tool-row-state, .vcp-tool-row-diff) { margin-left: 10px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] [data-vcp-tool-call-state="failed"] .vcp-tool-row-toggle .vcp-tool-row-resource { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] { margin: 4px 0 10px; border: 0; border-radius: 0; background: transparent; overflow: visible; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle { gap: 0; font-size: var(--vcp-ui-text-caption, 13px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading { position: relative; display: inline-flex; flex: none; width: 16px; height: 16px; margin-right: 6px; align-items: center; justify-content: center; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-icon { width: 14px; height: 14px; transition: opacity .1s; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-chevron { position: absolute; inset: 0; margin: auto; opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-icon { opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-chevron { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-dot { flex: none; width: 2px; height: 2px; margin: 0 8px; border-radius: 1px; background: currentColor; opacity: .7; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle :is(.vcp-tool-row-state, .vcp-tool-row-diff) { margin-left: 10px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] [data-vcp-tool-call-state="failed"] .vcp-tool-row-toggle .vcp-tool-row-resource { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] { margin: 4px 0 10px; border: 0; border-radius: 0; background: transparent; overflow: visible; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle { width: 100%; min-height: 33px; padding: 0 0 8px; @@ -236,8 +236,8 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-proce border-bottom: .5px solid var(--vcp-ui-border); font-size: var(--vcp-ui-text-body, 14px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle > .vcp-tool-row-chevron { order: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] .vcp-tool-process-stats { margin-left: 0; color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body { padding-top: 8px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-artifact { border-top: 0; padding: 6px 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle > .vcp-tool-row-chevron { order: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] .vcp-tool-process-stats { margin-left: 0; color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body { padding-top: 8px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-artifact { border-top: 0; padding: 6px 0; } From cb413eb2572b79c95bf9491e8a451eeefbe217fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:44:31 +0000 Subject: [PATCH 017/143] Side chat and plan: closing asks before deleting, message buttons respect the busy gate, the filter accepts input-method typing - Closing a side chat that has messages deletes its child topic, so it now asks first (also for close others / close all); declining keeps the tab. - A second send while a reply streams is refused, so the running reply keeps its stop button. A message button no longer overwrites or sends a typed draft, references or attachments. - The plan history filter no longer rebuilds the page while an input method composes, so Chinese input works; the search runs once composition ends. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- modules/renderer/sideChatSurfaceOwner.js | 22 ++++- .../side-pane/planDetailSideProvider.js | 13 ++- tests/side-chat-interactive-submit.test.mjs | 90 +++++++++++++++++++ tests/side-chat-message-delete.test.mjs | 20 ++++- tests/side-pane-plan-topic.test.mjs | 31 +++++++ 5 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 tests/side-chat-interactive-submit.test.mjs diff --git a/modules/renderer/sideChatSurfaceOwner.js b/modules/renderer/sideChatSurfaceOwner.js index 2238522e5..aa2a63c33 100644 --- a/modules/renderer/sideChatSurfaceOwner.js +++ b/modules/renderer/sideChatSurfaceOwner.js @@ -358,7 +358,8 @@ export async function mountSideChatSurface(container, { const onSubmit = async (event) => { event?.preventDefault?.(); - if (isDisposed || !isHistoryLoaded || isDeletingMessage) return; + // 还在生成时不再起第二次发送:它失败后的清理会清掉正在进行的那次,停止按钮随之消失 + if (isDisposed || !isHistoryLoaded || isDeletingMessage || activeSendController) return; if (!currentModel) { updateStatus('请先选择模型', 'error'); return; @@ -535,8 +536,18 @@ export async function mountSideChatSurface(container, { }); } + // 消息里的交互按钮:和主聊一样只发这段文本,不覆盖、也不带上输入框里还没发出去的草稿、引用和附件 submitInteractiveContent = (text) => { if (isDisposed) return; + const notify = (message) => chatCapabilities?.uiHelper?.showToastNotification?.(message, 'warning'); + if (activeSendController || isDeletingMessage || !isHistoryLoaded) { + notify('辅助对话正在处理上一条消息,请稍后再点。'); + return; + } + if (textarea.value.trim() || references.length > 0 || attachmentsOwner.count > 0) { + notify('输入框里还有没发出的内容,请先发送或清空后再点。'); + return; + } textarea.value = String(text || ''); form.requestSubmit(); }; @@ -640,6 +651,15 @@ export async function mountSideChatSurface(container, { chatCapabilities?.uiHelper?.showToastNotification?.('无法关闭标签页:存在未保存的历史记录。请点击保存徽标重试,或右键点击徽标放弃更改。', 'warning'); return { closed: false, reason: 'UNSAVED_CHANGES' }; } + // 关闭会删掉子话题;有记录时先确认,免得误点(含「关闭其他 / 全部」)把对话永久删掉 + const uiHelper = chatCapabilities?.uiHelper; + const history = liveConversation?.historyRef?.get?.() || []; + if (typeof uiHelper?.showConfirmDialog === 'function' && history.length > 0) { + const confirmed = await uiHelper.showConfirmDialog( + `关闭「${descriptor.title || '辅助对话'}」会删除这段辅助对话的全部记录,无法恢复。`, + '关闭辅助对话', '关闭并删除', '取消', true); + if (!confirmed) return { closed: false, reason: 'USER_CANCELED' }; + } // Cancel active operation and wait for settlement if (activeSendController) { activeSendController.abort('side-chat-closed'); diff --git a/modules/ui-system/side-pane/planDetailSideProvider.js b/modules/ui-system/side-pane/planDetailSideProvider.js index 53ea53ea1..7a420ad01 100644 --- a/modules/ui-system/side-pane/planDetailSideProvider.js +++ b/modules/ui-system/side-pane/planDetailSideProvider.js @@ -248,6 +248,8 @@ export function createPlanDetailSideProvider({ let filterRows = null; let filterError = ''; let filterSeq = 0; + // 输入法组字期间整页重绘会替换输入框、打断组字:组字期间不画,组字结束按一次输入处理 + let composingFilter = false; let cancelFilter = null; let nodeView = null; const navigation = createPlanPageNavigation({ h, button, id: tab.id, onChange: selectPage }); @@ -539,15 +541,23 @@ export function createPlanDetailSideProvider({ el.setAttribute('aria-label', placeholder); el.dataset.filter = key; el.value = key === 'file' ? (filters.exactFile || filters.file) : filters[key]; - el.addEventListener('input', () => { + const onFilterInput = (event) => { if (key === 'file') { filters.file = el.value.trim(); filters.exactFile = ''; } else filters[key] = el.value.trim(); // Invalidate on intent, before the debounce allows an old read to settle. ++filterSeq; filterRows = null; filterError = ''; cancelFilter?.(); + cancelFilter = null; + if (event?.isComposing || composingFilter) return; cancelFilter = disposed() ? null : own.timeout(() => { cancelFilter = null; runSearch(); }, FILTER_DEBOUNCE_MS, 'filter-debounce'); render(); + }; + el.addEventListener('input', onFilterInput); + el.addEventListener('compositionstart', () => { composingFilter = true; }); + el.addEventListener('compositionend', () => { + composingFilter = false; + onFilterInput(null); }); return el; }; @@ -728,6 +738,7 @@ export function createPlanDetailSideProvider({ function render() { if (disposed() || nodeView) return; + if (composingFilter) return; // 输入框随整页重绘,记下焦点和光标位置 const active = doc.activeElement; const focusKey = active && body.contains(active) ? active.dataset?.filter : null; diff --git a/tests/side-chat-interactive-submit.test.mjs b/tests/side-chat-interactive-submit.test.mjs new file mode 100644 index 000000000..2992f0d47 --- /dev/null +++ b/tests/side-chat-interactive-submit.test.mjs @@ -0,0 +1,90 @@ +// 消息里的交互按钮(renderer 的 handleSendMessage)不能绕过辅助对话的忙碌闸门,也不能吞掉输入框里的草稿 +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { mountSideChatSurface } from '../modules/renderer/sideChatSurfaceOwner.js'; + +const settle = () => new Promise(resolve => setTimeout(resolve, 10)); + +async function fixture() { + const dom = new JSDOM('
'); + const doc = dom.window.document; + let history = []; + let sendFromMessage = null; + let release = null; + const sends = []; + const toasts = []; + const handle = await mountSideChatSurface(doc.getElementById('mount'), { + descriptor: { + id: 'side', title: '侧聊', model: 'model', contextMode: 'references-only', + parent: { itemId: 'agent', topicId: 'parent' }, child: { itemId: 'agent', topicId: 'child' } + }, + chatCapabilities: { + repository: { getHistory: async () => history, saveHistory: async () => ({ success: true }) }, + uiHelper: { showToastNotification: (message, type) => toasts.push({ message, type }) }, + manager: { + async sendMessage(request) { + sends.push(request); + request.onOperation?.({ cancel: async () => true }); + await new Promise(resolve => { release = resolve; }); + return { terminal: { event: { type: 'completed' } } }; + } + }, + createRenderer({ conversation, handleSendMessage }) { + sendFromMessage = handleSendMessage; + return { + renderer: { renderHistory: async () => {} }, + conversation: { + selectedItemRef: { get: () => conversation.selectedItem }, + topicIdRef: { get: () => conversation.topicId }, + historyRef: { get: () => history, set: next => { history = next; } }, + replaceHistory: next => { history = next; } + }, + dispose: async () => {} + }; + } + } + }); + await settle(); + const textarea = doc.querySelector('.side-chat-textarea'); + const stopBtn = doc.querySelector('.side-chat-stop-btn'); + return { + dom, handle, textarea, stopBtn, sends, toasts, + clickMessageButton: text => sendFromMessage(text), + typeAndSend(text) { textarea.value = text; doc.querySelector('form').requestSubmit(); }, + finish: () => release?.(), + async cleanup() { release?.(); await handle.dispose(); dom.window.close(); } + }; +} + +test('a message button clicked while a reply streams does not start a second send', async () => { + const f = await fixture(); + try { + f.typeAndSend('first question'); + await settle(); + assert.equal(f.sends.length, 1); + assert.equal(f.stopBtn.hidden, false); + + f.clickMessageButton('option A'); + await settle(); + assert.equal(f.sends.length, 1, 'no second send while the first one streams'); + assert.equal(f.stopBtn.hidden, false, 'the running reply can still be stopped'); + assert.ok(f.toasts.some(t => t.type === 'warning')); + } finally { await f.cleanup(); } +}); + +test('a message button does not overwrite or send a typed draft', async () => { + const f = await fixture(); + try { + f.textarea.value = 'half-written thought'; + f.clickMessageButton('option A'); + await settle(); + assert.equal(f.sends.length, 0); + assert.equal(f.textarea.value, 'half-written thought'); + + f.textarea.value = ''; + f.clickMessageButton('option A'); + await settle(); + assert.equal(f.sends.length, 1); + } finally { await f.cleanup(); } +}); diff --git a/tests/side-chat-message-delete.test.mjs b/tests/side-chat-message-delete.test.mjs index 45f6bc229..e83e2ee4c 100644 --- a/tests/side-chat-message-delete.test.mjs +++ b/tests/side-chat-message-delete.test.mjs @@ -28,7 +28,8 @@ async function fixture(t) { const historyPath = queue.getHistoryPath(child.itemId, child.topicId); const backup = historyPath + '.backup'; let blocked = false, gate, saveResult, history = [], sends = 0; - const toasts = [], removals = [], writes = []; + const toasts = [], removals = [], writes = [], confirms = []; + let confirmAnswer = true; const dom = new JSDOM('
'); const doc = dom.window.document; const repository = { @@ -45,7 +46,7 @@ async function fixture(t) { parent: { itemId: 'agent', topicId: 'parent' }, child: { itemId: 'agent', topicId: 'child' } }, chatCapabilities: { repository, - uiHelper: { showConfirmDialog: async () => true, + uiHelper: { showConfirmDialog: async message => { confirms.push(message); return confirmAnswer; }, showToastNotification: (message, type) => toasts.push({ message, type }) }, manager: { sendMessage() { sends++; throw new Error('Unexpected generation'); } }, createRenderer({ root: list, conversation }) { @@ -96,7 +97,8 @@ async function fixture(t) { new dom.window.MouseEvent('contextmenu', { bubbles: true, cancelable: true })); } function remove(id) { menu(id); doc.querySelector('[data-side-chat-action="delete"]').click(); } - return { doc, handle, initial, toasts, writes, removals, menu, remove, repair, + return { doc, handle, initial, toasts, writes, removals, menu, remove, repair, confirms, + answerConfirm: answer => { confirmAnswer = answer; }, history: () => history, read: () => queue.read(child), sends: () => sends, failWithResult(result) { saveResult = result; }, holdSave(promise) { gate = promise; }, @@ -169,3 +171,15 @@ test('pending side deletion keeps typed drafts, blocks send and close, then pers assert.deepEqual(f.removals, [{ id: 'answer', persist: false }, { id: 'question', persist: false }]); assert.equal(f.writes.length, 2, 'one acknowledged save per deletion, no hidden renderer write'); }); + +test('closing a side chat that has messages asks first, and keeps it when declined', async t => { + const f = await fixture(t); + f.answerConfirm(false); + assert.deepEqual(await f.handle.requestClose(), { closed: false, reason: 'USER_CANCELED' }); + assert.equal(f.confirms.length, 1); + assert.deepEqual(await f.read(), f.initial); + + f.answerConfirm(true); + assert.deepEqual(await f.handle.requestClose(), { closed: true }); + assert.equal(f.confirms.length, 2); +}); diff --git a/tests/side-pane-plan-topic.test.mjs b/tests/side-pane-plan-topic.test.mjs index 5ece4849f..92d4030b3 100644 --- a/tests/side-pane-plan-topic.test.mjs +++ b/tests/side-pane-plan-topic.test.mjs @@ -211,6 +211,37 @@ for (const settlement of ['resolve', 'reject']) { }); } +test('typing into the history filter with an input method keeps the same input element until composition ends', async () => { + const requests = []; + const { provider, tab, view, dom } = makeTopicEnv({ api: { + projectForgeSearchHistory(params) { + requests.push(params); + return Promise.resolve({ success: true, data: [] }); + } + } }); + const handle = await provider.mountTab(tab, view); + try { + const input = view.querySelector('[data-filter="keyword"]'); + input.focus(); + input.dispatchEvent(new dom.window.CompositionEvent('compositionstart')); + input.value = 'zhong'; + input.dispatchEvent(new dom.window.InputEvent('input', { isComposing: true })); + await new Promise(resolve => setTimeout(resolve, 400)); + assert.equal(view.querySelector('[data-filter="keyword"]'), input, 'the composing input is not replaced'); + assert.equal(requests.length, 0, 'no search while composing'); + + input.value = '中'; + input.dispatchEvent(new dom.window.CompositionEvent('compositionend')); + await new Promise(resolve => setTimeout(resolve, 400)); + assert.equal(requests.length, 1); + assert.equal(requests[0].keyword, '中'); + assert.equal(dom.window.document.activeElement.dataset.filter, 'keyword'); + } finally { + handle.dispose(); + dom.window.close(); + } +}); + test('a node opens its diff and can be reverted with a remembered signature; the revert joins the topic', async () => { const { provider, calls, tab, view, dom, storage } = makeTopicEnv(); const handle = await provider.mountTab(tab, view); From 195452dea6376ac1e4c961eb4fc65dac92721e8a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:45:44 +0000 Subject: [PATCH 018/143] Terminal tab: OSC 8 links open in the side browser, Ctrl+C with a selection copies - xterm's default OSC 8 handler showed a confirm and called window.open from the main window, which opened a bare window on the default session. The tab now passes a linkHandler that only opens http(s) through the side browser, as ZCode does. - Ctrl/Cmd+C with a selection copies it instead of sending ^C to the PTY shared with the AI tools; without a selection Ctrl+C still interrupts. - Ctrl/Cmd+Alt+B and Ctrl+PageUp/PageDown drive the side pane only and are no longer written to the shell as escape codes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- .../side-pane/terminalSideProvider.js | 29 +++++++++++- tests/side-pane-terminal-lifecycle.test.mjs | 45 +++++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/modules/ui-system/side-pane/terminalSideProvider.js b/modules/ui-system/side-pane/terminalSideProvider.js index 466adc795..492954f80 100644 --- a/modules/ui-system/side-pane/terminalSideProvider.js +++ b/modules/ui-system/side-pane/terminalSideProvider.js @@ -106,9 +106,36 @@ export function createTerminalSideProvider({ fontSize: 13, scrollback: 5000, allowProposedApi: false, - theme: initialTheme + theme: initialTheme, + // OSC 8 超链接:不走 xterm 默认的 confirm + window.open(会在主窗口外开一个默认 session 的窗口), + // 和普通链接一样只开 http(s),交给侧栏浏览器(对照 ZCode TerminalSession.tsx linkHandler) + linkHandler: { + allowNonHttpProtocols: false, + activate(event, text) { + event?.preventDefault?.(); + if (!onOpenUrl || !/^https?:\/\//i.test(String(text || ''))) return; + onOpenUrl(text); + } + } }); session.term = term; + // 有选区时 Ctrl/Cmd+C 复制选区,不给共享 PTY 发 ^C(会打断 AI 正在跑的命令); + // 副屏自己的快捷键(Ctrl/Cmd+Alt+B、Ctrl+PageUp/PageDown)不写进 shell(对照 ZCode attachCustomKeyEventHandler) + term.attachCustomKeyEventHandler?.((event) => { + if (event.type !== 'keydown') return true; + const mod = event.ctrlKey || event.metaKey; + if (!mod) return true; + const key = String(event.key || '').toLowerCase(); + if (key === 'c' && !event.altKey && !event.shiftKey && term.hasSelection?.()) { + const text = term.getSelection(); + const clipboard = doc.defaultView?.navigator?.clipboard; + clipboard?.writeText?.(text)?.catch?.(error => console.warn('[SideTerminal] Copy failed:', error)); + return false; + } + if (event.altKey && key === 'b') return false; + if (event.ctrlKey && (event.key === 'PageUp' || event.key === 'PageDown')) return false; + return true; + }); // 外框底色由 CSS 给出,xterm 从外框读取同一颜色,明暗主题切换时跟着换调色板。 const applyTheme = () => { const theme = buildTerminalTheme(doc, screen); diff --git a/tests/side-pane-terminal-lifecycle.test.mjs b/tests/side-pane-terminal-lifecycle.test.mjs index 4fec7cec6..5fc0eaf88 100644 --- a/tests/side-pane-terminal-lifecycle.test.mjs +++ b/tests/side-pane-terminal-lifecycle.test.mjs @@ -12,7 +12,7 @@ async function until(predicate) { } } -function fixture({ create = async () => ({ success: true, data: { id: 'view:1', pid: 42 } }), restart, dormancy } = {}) { +function fixture({ create = async () => ({ success: true, data: { id: 'view:1', pid: 42 } }), restart, dormancy, onOpenUrl = null } = {}) { const dom = new JSDOM(''); const doc = dom.window.document, root = doc.querySelector('aside'); const controller = createSidePaneController({ root, tabListElement: root.querySelector('.side-pane-tabs'), @@ -24,6 +24,9 @@ function fixture({ create = async () => ({ success: true, data: { id: 'view:1', constructor(options) { this.options = options; this.cols = 80; this.rows = 24; this.output = []; this.disposals = 0; terminals.push(this); } open(screen) { this.input = doc.createElement('textarea'); screen.append(this.input); } onData() {} onResize() {} loadAddon() {} + attachCustomKeyEventHandler(handler) { this.keyHandler = handler; } + hasSelection() { return Boolean(this.selection); } + getSelection() { return this.selection || ''; } focus() { this.input.focus(); } write(data) { this.output.push(data); } clear() {} reset() {} @@ -41,9 +44,9 @@ function fixture({ create = async () => ({ success: true, data: { id: 'view:1', onTerminalData: subscribe('data'), onTerminalClear: subscribe('clear'), onTerminalExit: subscribe('exit') }; const provider = createTerminalSideProvider({ document: doc, api, sidePaneController: controller, - xtermLoader: async () => ({ Terminal, FitAddon: null }) }); + xtermLoader: async () => ({ Terminal, FitAddon: null }), onOpenUrl }); controller.registerProvider('terminal', provider); - return { controller, provider, doc, terminals, killed, creates, restarts, listeners, + return { dom, controller, provider, doc, terminals, killed, creates, restarts, listeners, get unsubscriptions() { return unsubscriptions; }, get confirmations() { return confirmations; }, status: () => root.querySelector('.side-terminal-status'), retry: () => root.querySelector('[aria-label="重新启动终端"]'), @@ -233,3 +236,39 @@ test('a live terminal view holds its buttons and size observer through the view assert.deepEqual(h.killed, [], 'the shell itself keeps running'); } finally { await h.cleanup(); } }); + +test('OSC 8 hyperlinks open http(s) in the side browser and nothing else', async () => { + const opened = []; + const h = fixture({ onOpenUrl: url => opened.push(url) }); + try { + await h.provider.openTerminalTab(); + const { linkHandler } = h.terminals[0].options; + assert.equal(linkHandler.allowNonHttpProtocols, false); + const event = { prevented: false, preventDefault() { this.prevented = true; } }; + linkHandler.activate(event, 'https://example.com/a'); + linkHandler.activate(event, 'file:///etc/passwd'); + linkHandler.activate(event, 'javascript:alert(1)'); + assert.deepEqual(opened, ['https://example.com/a']); + assert.equal(event.prevented, true, 'xterm\'s confirm + window.open default never runs'); + } finally { await h.cleanup(); } +}); + +test('Ctrl+C with a selection copies instead of interrupting the shared shell; pane shortcuts stay out of the shell', async () => { + const h = fixture(); + const copied = []; + Object.defineProperty(h.dom.window.navigator, 'clipboard', { configurable: true, + value: { writeText: async text => { copied.push(text); } } }); + try { + await h.provider.openTerminalTab(); + const term = h.terminals[0]; + const key = (fields) => term.keyHandler({ type: 'keydown', ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, ...fields }); + + assert.equal(key({ key: 'c', ctrlKey: true }), true, 'without a selection Ctrl+C still reaches the shell'); + term.selection = 'npm test'; + assert.equal(key({ key: 'c', ctrlKey: true }), false); + assert.deepEqual(copied, ['npm test']); + assert.equal(key({ key: 'b', ctrlKey: true, altKey: true }), false); + assert.equal(key({ key: 'PageDown', ctrlKey: true }), false); + assert.equal(key({ key: 'a' }), true); + } finally { await h.cleanup(); } +}); From f4f61ba7a6bc80dc3c3d105b30c5ef8ae1bc2ef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:46:50 +0000 Subject: [PATCH 019/143] Side pane: leave tab lookup and strip keyboard focus to the parallel fix branch claude/project-thread-20fk2r (PR #3) already finds tabs by id without selectors and keeps arrow-key focus on the strip, with its own tests. Drop the duplicate here so the two branches do not conflict. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- .../side-pane/side-pane-controller.js | 5 +- .../side-pane/side-pane-tab-strip.js | 15 ++--- .../side-pane/side-pane-tab-utils.js | 10 --- tests/side-pane-tab-keyboard.test.mjs | 63 ------------------- 4 files changed, 8 insertions(+), 85 deletions(-) delete mode 100644 tests/side-pane-tab-keyboard.test.mjs diff --git a/modules/ui-system/side-pane/side-pane-controller.js b/modules/ui-system/side-pane/side-pane-controller.js index 3d2358adf..6aec0369e 100644 --- a/modules/ui-system/side-pane/side-pane-controller.js +++ b/modules/ui-system/side-pane/side-pane-controller.js @@ -15,7 +15,6 @@ import { createSidePaneShortcuts } from './side-pane-shortcuts.js'; import { createSidePaneLayoutStore, parseLayout, rememberBounded, serializeLayout } from './side-pane-persistence.js'; import { createSidePaneRootScope, createTabOccurrence } from './side-pane-occurrence.js'; import { selectDormantViews } from './side-pane-dormancy.js'; -import { findByTabId } from './side-pane-tab-utils.js'; /** @typedef {import('./side-pane-types.js').SidePaneTab} SidePaneTab */ /** @typedef {import('./side-pane-types.js').SidePaneTabType} SidePaneTabType */ @@ -246,7 +245,7 @@ export function createSidePaneController({ isClosable: isClosableTab, statusTabId: SidePaneState.NOTIFICATIONS_TAB_ID, getStatus: readConnectionStatus, - onActivate: (tabId, options) => controller.activateTab(tabId, options), + onActivate: (tabId) => controller.activateTab(tabId), onClose: (tabId) => controller.closeTab(tabId), onReorder: (activeId, overId) => controller.reorderTab(activeId, overId), onContextMenu: (tabId, x, y) => tabMenu?.show(tabId, x, y), @@ -479,7 +478,7 @@ export function createSidePaneController({ const pending = { promise: null, viewElement: null, canceled: false, onClosed }; const mounting = (async () => { - let view = findByTabId(contentContainer, '[data-tab-id]', tabId); + let view = contentContainer?.querySelector(`[data-tab-id="${tabId}"]`); if (!view && contentContainer) { view = doc.createElement('section'); view.className = 'side-pane-view'; diff --git a/modules/ui-system/side-pane/side-pane-tab-strip.js b/modules/ui-system/side-pane/side-pane-tab-strip.js index f5c279ccc..964f5fe82 100644 --- a/modules/ui-system/side-pane/side-pane-tab-strip.js +++ b/modules/ui-system/side-pane/side-pane-tab-strip.js @@ -2,7 +2,7 @@ 'use strict'; import { createTabSortable } from './side-pane-tab-dnd.js'; -import { findByTabId, getTabIconName, resolveTabsOverflow } from './side-pane-tab-utils.js'; +import { getTabIconName, resolveTabsOverflow } from './side-pane-tab-utils.js'; // 标签标题悬停提示、面板切换动画、可拖拽排序的标签 const TAB_TOOLTIP_DELAY_MS = 1500; @@ -133,7 +133,7 @@ export function createSidePaneTabStrip({ function syncStatus() { const current = statusTabId ? getStatus() : null; if (!current) return; - const btn = findByTabId(tabListElement, '.side-pane-tab[data-tab-id]', statusTabId); + const btn = tabListElement.querySelector(`.side-pane-tab[data-tab-id="${statusTabId}"]`); const dot = btn?.querySelector('.side-pane-tab-status'); if (!dot) return; const { status = 'unknown', text = '' } = current; @@ -268,8 +268,7 @@ export function createSidePaneTabStrip({ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { targetIndex = (currentIndex + 1) % tabButtons.length; } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { - // 当前是新标签页(不在标签条上)时,往左落到最后一个 - targetIndex = currentIndex < 0 ? tabButtons.length - 1 : (currentIndex - 1 + tabButtons.length) % tabButtons.length; + targetIndex = (currentIndex - 1 + tabButtons.length) % tabButtons.length; } else if (e.key === 'Home') { targetIndex = 0; } else if (e.key === 'End') { @@ -279,10 +278,8 @@ export function createSidePaneTabStrip({ } e.preventDefault(); if (targetIndex !== currentIndex && targetIndex >= 0 && targetIndex < tabButtons.length) { - // 激活会重建标签条,旧按钮随之离开文档;焦点留在标签上(WAI-ARIA Tabs),按 id 找重建后的按钮 - const targetId = tabButtons[targetIndex].getAttribute('data-tab-id'); - onActivate(targetId, { focus: false }); - findByTabId(tabListElement, '[role="tab"][data-tab-id]', targetId)?.focus?.(); + onActivate(tabButtons[targetIndex].getAttribute('data-tab-id')); + tabButtons[targetIndex].focus(); } }; tabListElement.addEventListener('keydown', onKeydown); @@ -297,7 +294,7 @@ export function createSidePaneTabStrip({ syncStatus, hideTooltip, focusTab(tabId) { - findByTabId(tabListElement, '[role="tab"][data-tab-id]', tabId)?.focus?.(); + tabListElement.querySelector(`[role="tab"][data-tab-id="${tabId}"]`)?.focus?.(); }, dispose() { disposed = true; diff --git a/modules/ui-system/side-pane/side-pane-tab-utils.js b/modules/ui-system/side-pane/side-pane-tab-utils.js index e5dd8f4aa..0da0acbcd 100644 --- a/modules/ui-system/side-pane/side-pane-tab-utils.js +++ b/modules/ui-system/side-pane/side-pane-tab-utils.js @@ -145,13 +145,3 @@ export function moveIdBefore(ids, activeId, overId) { next.splice(to, 0, activeId); return next; } - -// 按 data-tab-id 找元素,不把 id 拼进选择器:文件标签的 id 带 Windows 路径(反斜杠会被当成转义)或引号 -export function findByTabId(container, selector, tabId) { - if (!container || tabId == null) return null; - const id = String(tabId); - for (const element of container.querySelectorAll(selector)) { - if (element.getAttribute('data-tab-id') === id) return element; - } - return null; -} diff --git a/tests/side-pane-tab-keyboard.test.mjs b/tests/side-pane-tab-keyboard.test.mjs deleted file mode 100644 index 8caba9c23..000000000 --- a/tests/side-pane-tab-keyboard.test.mjs +++ /dev/null @@ -1,63 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { JSDOM } from 'jsdom'; -import { createSidePaneController } from '../modules/ui-system/side-pane/side-pane-controller.js'; - -const settle = () => new Promise(resolve => setImmediate(resolve)); - -function createController() { - const dom = new JSDOM('
'); - const doc = dom.window.document; - const provider = { - mountTab(_payload, view) { - const input = doc.createElement('input'); - view.append(input); - return { focus: () => input.focus(), dispose() {} }; - } - }; - const ctrl = createSidePaneController({ - root: doc.getElementById('pane'), - tabListElement: doc.getElementById('tabs'), - contentContainer: doc.getElementById('content') - }); - ctrl.registerTabType({ kind: 'probe', label: 'Probe', provider }); - const tab = id => ({ id, kind: 'probe', title: id, closable: true, scopeMode: 'global' }); - const button = id => [...doc.querySelectorAll('#tabs [role="tab"]')].find(el => el.dataset.tabId === id); - const press = key => doc.activeElement.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key, bubbles: true })); - return { dom, doc, ctrl, tab, button, press }; -} - -test('arrow keys move the active tab and keep focus on the tab strip', async () => { - const { doc, ctrl, tab, button, press } = createController(); - try { - await ctrl.openTab(tab('probe:a')); - await ctrl.openTab(tab('probe:b')); - button('probe:b').focus(); - - press('ArrowLeft'); - await settle(); - assert.equal(ctrl.getSnapshot().activeTabId, 'probe:a'); - assert.equal(doc.activeElement, button('probe:a')); - - press('ArrowRight'); - await settle(); - assert.equal(ctrl.getSnapshot().activeTabId, 'probe:b'); - assert.equal(doc.activeElement, button('probe:b')); - } finally { await ctrl.dispose(); } -}); - -test('tab ids carrying Windows paths and quotes mount, focus and close', async () => { - const { doc, ctrl, tab, button } = createController(); - const windowsId = 'code-viewer:C:\\Users\\me\\proj\\a.js'; - const quotedId = 'code-viewer:/tmp/a"b.js'; - try { - await ctrl.openTab(tab(windowsId)); - await ctrl.openTab(tab(quotedId)); - assert.deepEqual(ctrl.getViewResidency().live.sort(), [quotedId, windowsId].sort()); - - await ctrl.closeTab(quotedId); - await settle(); - assert.equal(ctrl.getSnapshot().activeTabId, windowsId); - assert.equal(doc.activeElement, button(windowsId), 'focus lands on the remaining file tab'); - } finally { await ctrl.dispose(); } -}); From 7b4d7659299edfb8ab6899f035e20f7ee8f2bd77 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:47:17 +0000 Subject: [PATCH 020/143] Plan detail: move the composition guard below the focus bookkeeping Keeps the change clear of the focus-restore lines the parallel fix branch edits. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- modules/ui-system/side-pane/planDetailSideProvider.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ui-system/side-pane/planDetailSideProvider.js b/modules/ui-system/side-pane/planDetailSideProvider.js index 7a420ad01..1f024a043 100644 --- a/modules/ui-system/side-pane/planDetailSideProvider.js +++ b/modules/ui-system/side-pane/planDetailSideProvider.js @@ -738,13 +738,13 @@ export function createPlanDetailSideProvider({ function render() { if (disposed() || nodeView) return; - if (composingFilter) return; // 输入框随整页重绘,记下焦点和光标位置 const active = doc.activeElement; const focusKey = active && body.contains(active) ? active.dataset?.filter : null; const pageFocus = active && chrome.contains(active) ? active.dataset?.planPage : null; const caret = focusKey && typeof active.selectionStart === 'number' ? [active.selectionStart, active.selectionEnd] : null; const scrollTop = body.scrollTop; + if (composingFilter) return; body.innerHTML = ''; chrome.innerHTML = ''; chrome.hidden = false; From d490d9cff9e4e8bc5882ed99a44d71877eb98a90 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:48:29 +0000 Subject: [PATCH 021/143] Side pane: share findByTabId's signature with PR #4 PR #4 adds the same tab lookup as findByTabId(container, selector, tabId). Use the same signature, text and keyboard comments so the two branches merge without a semantic clash, and rename this branch's keyboard test so the files don't collide. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- docs/contracts/generated/chat-event-graph.json | 4 ++-- modules/ui-system/side-pane/side-pane-controller.js | 2 +- modules/ui-system/side-pane/side-pane-tab-strip.js | 11 ++++++----- modules/ui-system/side-pane/side-pane-tab-utils.js | 13 +++++-------- tests/side-pane-tab-id-lookup.test.mjs | 6 +++--- ...st.mjs => side-pane-tab-strip-keyboard.test.mjs} | 0 6 files changed, 17 insertions(+), 19 deletions(-) rename tests/{side-pane-tab-keyboard.test.mjs => side-pane-tab-strip-keyboard.test.mjs} (100%) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 057f50dc0..02390f040 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -15555,7 +15555,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 302, + "line": 303, "kind": "custom-event-listener" }, { @@ -15932,7 +15932,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 302, + "line": 303, "kind": "custom-event-listener", "match": "tabListElement.addEventListener('keydown'" }, diff --git a/modules/ui-system/side-pane/side-pane-controller.js b/modules/ui-system/side-pane/side-pane-controller.js index c24baef4d..5c6b514cc 100644 --- a/modules/ui-system/side-pane/side-pane-controller.js +++ b/modules/ui-system/side-pane/side-pane-controller.js @@ -524,7 +524,7 @@ export function createSidePaneController({ const mounting = (async () => { // 上次挂载失败留下的提示页不复用,换一个干净的视图重新挂 clearMountFailure(tabId); - let view = findByTabId(contentContainer, tabId); + let view = findByTabId(contentContainer, '[data-tab-id]', tabId); if (!view && contentContainer) { view = doc.createElement('section'); view.className = 'side-pane-view'; diff --git a/modules/ui-system/side-pane/side-pane-tab-strip.js b/modules/ui-system/side-pane/side-pane-tab-strip.js index 8d046f0e6..9fd3a6ec8 100644 --- a/modules/ui-system/side-pane/side-pane-tab-strip.js +++ b/modules/ui-system/side-pane/side-pane-tab-strip.js @@ -134,7 +134,7 @@ export function createSidePaneTabStrip({ function syncStatus() { const current = statusTabId ? getStatus() : null; if (!current) return; - const btn = findByTabId(tabListElement, statusTabId, '.side-pane-tab[data-tab-id]'); + const btn = findByTabId(tabListElement, '.side-pane-tab[data-tab-id]', statusTabId); const dot = btn?.querySelector('.side-pane-tab-status'); if (!dot) return; const { status = 'unknown', text = '' } = current; @@ -283,7 +283,8 @@ export function createSidePaneTabStrip({ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { targetIndex = (currentIndex + 1) % tabButtons.length; } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { - targetIndex = (currentIndex - 1 + tabButtons.length) % tabButtons.length; + // 当前是新标签页(不在标签条上)时,往左落到最后一个 + targetIndex = currentIndex < 0 ? tabButtons.length - 1 : (currentIndex - 1 + tabButtons.length) % tabButtons.length; } else if (e.key === 'Home') { targetIndex = 0; } else if (e.key === 'End') { @@ -293,10 +294,10 @@ export function createSidePaneTabStrip({ } e.preventDefault(); if (targetIndex !== currentIndex && targetIndex >= 0 && targetIndex < tabButtons.length) { + // 激活会重建标签条,旧按钮随之离开文档;焦点留在标签上(WAI-ARIA Tabs),按 id 找重建后的按钮 const targetId = tabButtons[targetIndex].getAttribute('data-tab-id'); onActivate(targetId, { focus: false }); - // 激活会重画标签条,原来的按钮已经不在了,按 id 找新的 - findByTabId(tabListElement, targetId, '[role="tab"][data-tab-id]')?.focus?.(); + findByTabId(tabListElement, '[role="tab"][data-tab-id]', targetId)?.focus?.(); } }; tabListElement.addEventListener('keydown', onKeydown); @@ -311,7 +312,7 @@ export function createSidePaneTabStrip({ syncStatus, hideTooltip, focusTab(tabId) { - findByTabId(tabListElement, tabId, '[role="tab"][data-tab-id]')?.focus?.(); + findByTabId(tabListElement, '[role="tab"][data-tab-id]', tabId)?.focus?.(); }, dispose() { disposed = true; diff --git a/modules/ui-system/side-pane/side-pane-tab-utils.js b/modules/ui-system/side-pane/side-pane-tab-utils.js index 0c908b81c..e5dd8f4aa 100644 --- a/modules/ui-system/side-pane/side-pane-tab-utils.js +++ b/modules/ui-system/side-pane/side-pane-tab-utils.js @@ -146,15 +146,12 @@ export function moveIdBefore(ids, activeId, overId) { return next; } -/** - * 按 data-tab-id 找元素。标签 id 里可能有 Windows 路径的反斜杠或引号(code-viewer:C:\proj\a.js), - * 拼进属性选择器会匹配不到甚至抛 SyntaxError,所以逐个比对属性值。 - */ -export function findByTabId(container, tabId, selector = '[data-tab-id]') { +// 按 data-tab-id 找元素,不把 id 拼进选择器:文件标签的 id 带 Windows 路径(反斜杠会被当成转义)或引号 +export function findByTabId(container, selector, tabId) { if (!container || tabId == null) return null; - const want = String(tabId); - for (const el of container.querySelectorAll(selector)) { - if (el.getAttribute('data-tab-id') === want) return el; + const id = String(tabId); + for (const element of container.querySelectorAll(selector)) { + if (element.getAttribute('data-tab-id') === id) return element; } return null; } diff --git a/tests/side-pane-tab-id-lookup.test.mjs b/tests/side-pane-tab-id-lookup.test.mjs index 080a54146..fd85233b1 100644 --- a/tests/side-pane-tab-id-lookup.test.mjs +++ b/tests/side-pane-tab-id-lookup.test.mjs @@ -38,9 +38,9 @@ test('findByTabId matches ids with backslashes and quotes literally', () => { const el = doc.createElement('button'); el.setAttribute('data-tab-id', id); doc.body.append(el); - assert.equal(findByTabId(doc.body, id), el); + assert.equal(findByTabId(doc.body, '[data-tab-id]', id), el); } - assert.equal(findByTabId(doc.body, 'code-viewer:C:Usersroxyproja.js'), null); + assert.equal(findByTabId(doc.body, '[data-tab-id]', 'code-viewer:C:Usersroxyproja.js'), null); dom.window.close(); }); @@ -56,7 +56,7 @@ for (const id of [WINDOWS_ID, QUOTED_ID]) { try { await controller.openTab({ id: 'other', kind: 'probe', title: 'Other', closable: true, scopeMode: 'global' }); await controller.openTab({ id, kind: 'probe', title: 'a.js', closable: true, scopeMode: 'global' }); - assert.equal(findByTabId(root.querySelector('.side-pane-content-container'), id)?.textContent, 'a.js'); + assert.equal(findByTabId(root.querySelector('.side-pane-content-container'), '[data-tab-id]', id)?.textContent, 'a.js'); controller.activateTab('other'); controller.cycleTab(1); diff --git a/tests/side-pane-tab-keyboard.test.mjs b/tests/side-pane-tab-strip-keyboard.test.mjs similarity index 100% rename from tests/side-pane-tab-keyboard.test.mjs rename to tests/side-pane-tab-strip-keyboard.test.mjs From bb9a89acdff2cf729f61302b06f96ceb112fc730 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:51:04 +0000 Subject: [PATCH 022/143] Contracts: regenerate the chat event graph after merging #3 and #4 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UCGGgmHsCToooXTgSXpmq9 --- .../contracts/generated/chat-event-graph.json | 142 ++++++++++-------- 1 file changed, 82 insertions(+), 60 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 02390f040..44c400962 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -664,7 +664,7 @@ }, { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 243, + "line": 270, "kind": "custom-event-listener" }, { @@ -729,7 +729,7 @@ }, { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 243, + "line": 270, "kind": "custom-event-listener", "match": "signal?.addEventListener('abort'" }, @@ -2379,7 +2379,7 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 569, + "line": 579, "kind": "custom-event-listener" } ], @@ -2554,7 +2554,7 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 569, + "line": 579, "kind": "custom-event-listener", "match": "el.addEventListener('change'" } @@ -3685,12 +3685,12 @@ }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 551, + "line": 562, "kind": "custom-event-listener" }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 554, + "line": 565, "kind": "custom-event-listener" }, { @@ -4255,87 +4255,87 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 335, + "line": 337, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 371, + "line": 373, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 408, + "line": 410, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 417, + "line": 419, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 530, + "line": 532, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 585, + "line": 595, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 591, + "line": 601, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 664, + "line": 674, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 691, + "line": 701, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 710, + "line": 720, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 730, + "line": 740, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 760, + "line": 771, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 775, + "line": 786, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 497, + "line": 498, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1076, + "line": 1077, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1082, + "line": 1083, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1096, + "line": 1097, "kind": "custom-event-listener" }, { @@ -5150,13 +5150,13 @@ }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 551, + "line": 562, "kind": "custom-event-listener", "match": "stopBtn.addEventListener('click'" }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 554, + "line": 565, "kind": "custom-event-listener", "match": "persistenceBadge.addEventListener('click'" }, @@ -5834,103 +5834,103 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 335, + "line": 337, "kind": "custom-event-listener", "match": "btn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 371, + "line": 373, "kind": "custom-event-listener", "match": "crumbs.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 408, + "line": 410, "kind": "custom-event-listener", "match": "refreshBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 417, + "line": 419, "kind": "custom-event-listener", "match": "forgeBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 530, + "line": 532, "kind": "custom-event-listener", "match": "row.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 585, + "line": 595, "kind": "custom-event-listener", "match": "toggle.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 591, + "line": 601, "kind": "custom-event-listener", "match": "clear.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 664, + "line": 674, "kind": "custom-event-listener", "match": "headBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 691, + "line": 701, "kind": "custom-event-listener", "match": "pick.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 710, + "line": 720, "kind": "custom-event-listener", "match": "pick.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 730, + "line": 740, "kind": "custom-event-listener", "match": "link.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 760, + "line": 771, "kind": "custom-event-listener", "match": "retry.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 775, + "line": 786, "kind": "custom-event-listener", "match": "retry.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 497, + "line": 498, "kind": "custom-event-listener", "match": "retry.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1076, + "line": 1077, "kind": "custom-event-listener", "match": "toggleNotificationsBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1082, + "line": 1083, "kind": "custom-event-listener", "match": "expandButton.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1096, + "line": 1097, "kind": "custom-event-listener", "match": "closeSidePaneBtn.addEventListener('click'" }, @@ -7107,6 +7107,11 @@ "file": "modules/renderer/sideChatSurfaceOwner.js", "line": 344, "kind": "custom-event-listener" + }, + { + "file": "modules/ui-system/side-pane/planDetailSideProvider.js", + "line": 560, + "kind": "custom-event-listener" } ], "evidence": [ @@ -7115,6 +7120,12 @@ "line": 344, "kind": "custom-event-listener", "match": "textarea.addEventListener('compositionend'" + }, + { + "file": "modules/ui-system/side-pane/planDetailSideProvider.js", + "line": 560, + "kind": "custom-event-listener", + "match": "el.addEventListener('compositionend'" } ] }, @@ -7126,6 +7137,11 @@ "file": "modules/renderer/sideChatSurfaceOwner.js", "line": 343, "kind": "custom-event-listener" + }, + { + "file": "modules/ui-system/side-pane/planDetailSideProvider.js", + "line": 559, + "kind": "custom-event-listener" } ], "evidence": [ @@ -7134,6 +7150,12 @@ "line": 343, "kind": "custom-event-listener", "match": "textarea.addEventListener('compositionstart'" + }, + { + "file": "modules/ui-system/side-pane/planDetailSideProvider.js", + "line": 559, + "kind": "custom-event-listener", + "match": "el.addEventListener('compositionstart'" } ] }, @@ -7268,7 +7290,7 @@ }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 558, + "line": 569, "kind": "custom-event-listener" }, { @@ -7400,7 +7422,7 @@ }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 558, + "line": 569, "kind": "custom-event-listener", "match": "persistenceBadge.addEventListener('contextmenu'" }, @@ -12655,14 +12677,14 @@ "consumers": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 152, + "line": 179, "kind": "custom-event-listener" } ], "evidence": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 152, + "line": 179, "kind": "custom-event-listener", "match": "screen.addEventListener('focusin'" } @@ -14834,7 +14856,7 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 544, + "line": 558, "kind": "custom-event-listener" }, { @@ -15026,7 +15048,7 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 544, + "line": 558, "kind": "custom-event-listener", "match": "el.addEventListener('input'" }, @@ -21631,14 +21653,14 @@ "consumers": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 157, + "line": 184, "kind": "preload-subscription" } ], "evidence": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 157, + "line": 184, "kind": "preload-subscription", "match": "api.onTerminalClear(" } @@ -21669,14 +21691,14 @@ "consumers": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 154, + "line": 181, "kind": "preload-subscription" } ], "evidence": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 154, + "line": 181, "kind": "preload-subscription", "match": "api.onTerminalData(" } @@ -21688,14 +21710,14 @@ "consumers": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 160, + "line": 187, "kind": "preload-subscription" } ], "evidence": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 160, + "line": 187, "kind": "preload-subscription", "match": "api.onTerminalExit(" } @@ -24947,7 +24969,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1116, + "line": 1117, "kind": "custom-event-listener" }, { @@ -25041,7 +25063,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1116, + "line": 1117, "kind": "custom-event-listener", "match": "win?.addEventListener?.('resize'" }, @@ -28037,7 +28059,7 @@ }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 550, + "line": 561, "kind": "custom-event-listener" }, { @@ -28097,7 +28119,7 @@ }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 550, + "line": 561, "kind": "custom-event-listener", "match": "form.addEventListener('submit'" }, @@ -29229,7 +29251,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-visibility.js", - "line": 104, + "line": 122, "kind": "custom-event-listener" }, { @@ -29247,7 +29269,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-visibility.js", - "line": 104, + "line": 122, "kind": "custom-event-listener", "match": "root.addEventListener('transitionend'" }, From 4292711e526f14c1b1833c14d67a03eb2abe990e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:52:09 +0000 Subject: [PATCH 023/143] Side pane: tab context menu returns focus to the tab Choosing an item hid the focused menu button and dropped focus to body; closing a background tab left nothing to return to. Tab now closes the menu like Radix Menu (ZCode uses Radix ContextMenu), and after an action focus goes back to the tab, or to the active tab if that one was closed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../contracts/generated/chat-event-graph.json | 20 +++++++++---------- .../side-pane/side-pane-controller.js | 2 +- .../ui-system/side-pane/side-pane-tab-menu.js | 14 +++++++++++-- tests/side-pane-advanced-parity.test.mjs | 16 +++++++++++++++ 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 02390f040..d7661e608 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -1715,7 +1715,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-menu.js", - "line": 95, + "line": 105, "kind": "custom-event-listener" }, { @@ -1825,7 +1825,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-menu.js", - "line": 95, + "line": 105, "kind": "custom-event-listener", "match": "win?.addEventListener('blur'" }, @@ -4370,7 +4370,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-menu.js", - "line": 60, + "line": 63, "kind": "custom-event-listener" }, { @@ -5972,7 +5972,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-menu.js", - "line": 60, + "line": 63, "kind": "custom-event-listener", "match": "menu.addEventListener('click'" }, @@ -15535,12 +15535,12 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-menu.js", - "line": 76, + "line": 86, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-tab-menu.js", - "line": 94, + "line": 104, "kind": "custom-event-listener" }, { @@ -15908,13 +15908,13 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-menu.js", - "line": 76, + "line": 86, "kind": "custom-event-listener", "match": "menu.addEventListener('keydown'" }, { "file": "modules/ui-system/side-pane/side-pane-tab-menu.js", - "line": 94, + "line": 104, "kind": "custom-event-listener", "match": "doc.addEventListener('keydown'" }, @@ -22991,7 +22991,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-menu.js", - "line": 93, + "line": 103, "kind": "custom-event-listener" }, { @@ -23048,7 +23048,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-menu.js", - "line": 93, + "line": 103, "kind": "custom-event-listener", "match": "doc.addEventListener('pointerdown'" }, diff --git a/modules/ui-system/side-pane/side-pane-controller.js b/modules/ui-system/side-pane/side-pane-controller.js index 5c6b514cc..cf09f2f6c 100644 --- a/modules/ui-system/side-pane/side-pane-controller.js +++ b/modules/ui-system/side-pane/side-pane-controller.js @@ -280,7 +280,7 @@ export function createSidePaneController({ menu: resolvedTabContextMenu, getClosableTabs: () => SidePaneState.getClosableVisibleTabs(state), onShow: () => overview?.hide(), - focusTab: (tabId) => strip?.focusTab(tabId), + focusTab: (tabId) => strip?.focusTab(state.tabs.some(t => t.id === tabId) ? tabId : state.activeTabId), onAction: async (action, tabId) => { if (action === 'close-tab' && tabId) await controller.closeTab(tabId); else if (action === 'close-others' && tabId) await controller.closeOtherTabs(tabId); diff --git a/modules/ui-system/side-pane/side-pane-tab-menu.js b/modules/ui-system/side-pane/side-pane-tab-menu.js index a72ddd2e9..d786d9f0a 100644 --- a/modules/ui-system/side-pane/side-pane-tab-menu.js +++ b/modules/ui-system/side-pane/side-pane-tab-menu.js @@ -8,7 +8,7 @@ import { placeMenuAt } from './menu-position.js'; * getClosableTabs() 当前对话里可关的标签,用来决定哪些项可点 * onAction(action, tabId) * onShow() 菜单打开前调用,用来收起别的浮层 - * focusTab(tabId) Esc 关闭后把焦点还给标签 + * focusTab(tabId) Esc/Tab 关闭或执行后把焦点还给标签;标签已关时由调用方退回当前标签 */ export function createSidePaneTabMenu({ menu, getClosableTabs, onAction, onShow = () => {}, focusTab = () => {} }) { const doc = menu.ownerDocument; @@ -56,12 +56,22 @@ export function createSidePaneTabMenu({ menu, getClosableTabs, onAction, onShow const tabId = targetTabId; hide(); await onAction(action, tabId); + // 被点的菜单项已经藏起来了,焦点会掉到 body:还给标签(标签被关了就给当前标签) + const active = doc.activeElement; + if (!active || active === doc.body || !active.isConnected || menu.contains(active)) focusTab(tabId); }; menu.addEventListener('click', onClick); cleanups.push(() => menu.removeEventListener('click', onClick)); - // 菜单内上下键移动焦点 + // 菜单内上下键移动焦点;Tab 和 Radix Menu 一样直接收起,焦点回到标签 const onMenuKeydown = (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + const returnTo = targetTabId; + hide(); + focusTab(returnTo); + return; + } if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return; const items = Array.from(menu.querySelectorAll('[role="menuitem"]:not([disabled])')); if (!items.length) return; diff --git a/tests/side-pane-advanced-parity.test.mjs b/tests/side-pane-advanced-parity.test.mjs index e66a08a70..99874d48a 100644 --- a/tests/side-pane-advanced-parity.test.mjs +++ b/tests/side-pane-advanced-parity.test.mjs @@ -662,6 +662,22 @@ test('Parity: tab context menu is scoped, keyboard friendly and closes on Escape assert.equal(contextMenu.hidden, true); assert.deepEqual(ctrl.getSnapshot().tabs.map(t => t.id), ['notifications', 's1']); assert.equal(ctrl.getSnapshot().activeTabId, 's1'); + assert.equal(doc.activeElement, tabList.querySelector('[role="tab"][data-tab-id="s1"]'), 'focus goes back to the tab, not body'); + + // Tab 收起菜单,焦点回到标签 + openMenuOn('s1'); + contextMenu.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })); + assert.equal(contextMenu.hidden, true); + assert.equal(doc.activeElement, tabList.querySelector('[role="tab"][data-tab-id="s1"]')); + + // 关掉后台标签:那个标签没了,焦点落到当前标签 + await ctrl.openTab({ kind: 'chat', descriptor: createDesc('s3', 'c3') }); + await ctrl.activateTab('s1'); + openMenuOn('s3'); + contextMenu.querySelector('[data-action="close-tab"]').click(); + await tick(); + assert.deepEqual(ctrl.getSnapshot().tabs.map(t => t.id), ['notifications', 's1']); + assert.equal(doc.activeElement, tabList.querySelector('[role="tab"][data-tab-id="s1"]')); await ctrl.dispose(); assert.notEqual(contextMenu.parentNode, doc.body, 'dispose puts the menu back'); From a1bc4c30ab52905eee67c309c9bdeffb64a01899 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:52:53 +0000 Subject: [PATCH 024/143] Git page: line counts and diffs follow later edits, subdirectory paths resolve, Linux skips the object store - git status now carries per-file added/removed counts from git diff --numstat (as ZCode's git repo does). A file that is already modified and then edited again changes the status key, so cached diffs are dropped and the open file re-reads its diff while staying open. Rows no longer fetch one diff per file just to show counts. Diff requests started before an invalidation no longer write back into the cache. - Status paths are relative to the repository root. In a workspace that is a subdirectory, "copy absolute path" and "show in file manager" now resolve against that root instead of doubling the subdirectory. - On Linux the watcher no longer watches the whole git directory recursively, which made Node walk every object synchronously and use one inotify watch per file. It watches the directory itself and refs/ instead. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- modules/ipc/gitHandlers.js | 7 +- modules/services/gitService.js | 60 ++++++++++++++- modules/services/gitWatcher.js | 19 ++++- modules/ui-system/side-pane/git/cards.js | 22 ++++-- .../ui-system/side-pane/git/context-menu.js | 9 ++- modules/ui-system/side-pane/git/git-view.js | 5 +- tests/git-service.test.js | 31 ++++++++ tests/git-watcher.test.js | 19 ++++- tests/side-pane-git-stale-diff.test.mjs | 76 +++++++++++++++++++ 9 files changed, 226 insertions(+), 22 deletions(-) create mode 100644 tests/side-pane-git-stale-diff.test.mjs diff --git a/modules/ipc/gitHandlers.js b/modules/ipc/gitHandlers.js index 13ff70f0f..57c4d1d00 100644 --- a/modules/ipc/gitHandlers.js +++ b/modules/ipc/gitHandlers.js @@ -9,7 +9,6 @@ // 监听降级(只看得到 .git 或挂不上)时推一次 degraded,订阅回执里也带着,渲染端退回到窗口获得焦点时补读。 'use strict'; -const path = require('path'); const { ipcMain, shell } = require('electron'); const gitService = require('../services/gitService'); const { createGitWatcher } = require('../services/gitWatcher'); @@ -162,10 +161,8 @@ function initialize({ workspaceService = null, mainWindow = null, getMainWindow: ), { mutates: true }); // 在系统文件管理器中定位文件:只接受工作区内的相对路径,越界一律拒绝。 - handle('git:reveal-path', (workspaceId, relPath) => { - const root = path.resolve(resolveWorkspaceRoot(workspaceId)); - const target = path.resolve(root, typeof relPath === 'string' ? relPath : ''); - if (target !== root && !target.startsWith(root + path.sep)) throw new Error('路径不在工作区内。'); + handle('git:reveal-path', async (workspaceId, relPath) => { + const target = await gitService.resolveRevealTarget(resolveWorkspaceRoot(workspaceId), relPath); shell.showItemInFolder(target); return { revealed: true }; }); diff --git a/modules/services/gitService.js b/modules/services/gitService.js index eee6f797a..6c13550ec 100644 --- a/modules/services/gitService.js +++ b/modules/services/gitService.js @@ -341,13 +341,54 @@ async function listRemotes(repo) { // ============================ 状态 ============================ +/** 解析 `git diff --numstat -z --no-renames`:路径 → { added, removed };二进制文件两项都是 null */ +function parseNumstat(buffer) { + const counts = new Map(); + const text = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer || ''); + for (const record of text.split('\0')) { + const match = /^(-|\d+)\t(-|\d+)\t(.+)$/s.exec(record); + if (!match) continue; + counts.set(match[3], { + added: match[1] === '-' ? null : Number(match[1]), + removed: match[2] === '-' ? null : Number(match[2]), + }); + } + return counts; +} + +/** + * 每个已跟踪文件的增删行数,和状态同一次读出(对照 ZCode gitCliRepo 的 diff --numstat)。 + * 状态码不变、内容又被改了时,行数跟着变,渲染端据此知道哪些 diff 过期,也不用逐个拉 diff 来算行数。 + */ +async function readDiffCounts(repo) { + const scope = repo.prefix ? ['--', repo.prefix] : []; + const base = ['diff', '--numstat', '-z', '--no-renames', '--no-ext-diff']; + const [unstaged, staged] = await Promise.all([ + runGit(repo.toplevel, [...base, ...scope]).then(r => parseNumstat(r.stdout), () => new Map()), + runGit(repo.toplevel, [...base, '--cached', ...scope]).then(r => parseNumstat(r.stdout), () => new Map()), + ]); + return { unstaged, staged }; +} + +function withCounts(list, counts) { + return list.map(item => { + const count = item.untracked ? null : counts.get(item.path); + return count ? { ...item, added: count.added, removed: count.removed } : item; + }); +} + async function readStatus(repo) { const args = ['status', '--porcelain=v2', '-z', '--branch', '--untracked-files=all']; if (repo.prefix) args.push('--', repo.prefix); - const [{ stdout }, remotes] = await Promise.all([runGit(repo.toplevel, args), listRemotes(repo)]); + const [{ stdout }, remotes, counts] = await Promise.all([runGit(repo.toplevel, args), listRemotes(repo), readDiffCounts(repo)]); const { branch, entries } = parsePorcelainV2(stdout); const truncated = entries.length > MAX_ENTRIES; - const groups = groupEntries(truncated ? entries.slice(0, MAX_ENTRIES) : entries); + const grouped = groupEntries(truncated ? entries.slice(0, MAX_ENTRIES) : entries); + const groups = { + staged: withCounts(grouped.staged, counts.staged), + changes: withCounts(grouped.changes, counts.unstaged), + conflicts: grouped.conflicts, + }; return { isRepo: true, root: repo.root, @@ -369,6 +410,19 @@ async function readStatus(repo) { }; } +/** + * 「在文件管理器中打开」的目标:状态条目的路径相对仓库根,工作区可能只是仓库的子目录, + * 所以按仓库根解析(对照 ZCode gitService.ts toAbsolutePath),再校验仍在工作区内。 + */ +async function resolveRevealTarget(workspaceRoot, relPath) { + const repo = await openRepository(workspaceRoot); + if (repo) return absoluteInRepo(repo, resolveRepoPath(repo, relPath)); + const root = path.resolve(workspaceRoot); + const target = path.resolve(root, typeof relPath === 'string' ? relPath : ''); + if (target !== root && !target.startsWith(root + path.sep)) throw new Error('路径不在工作区内。'); + return target; +} + async function getStatus(workspaceRoot) { const repo = await openRepository(workspaceRoot); if (!repo) return { isRepo: false, root: path.resolve(workspaceRoot) }; @@ -727,6 +781,7 @@ async function getChangeSummary(workspaceRoot) { module.exports = { getStatus, getWatchTargets, + resolveRevealTarget, getDiff, stage, @@ -741,6 +796,7 @@ module.exports = { getChangeSummary, // 供测试使用 parsePorcelainV2, + parseNumstat, groupEntries, resolveRepoPath, chunkPaths, diff --git a/modules/services/gitWatcher.js b/modules/services/gitWatcher.js index c84b4dfe8..ddf451282 100644 --- a/modules/services/gitWatcher.js +++ b/modules/services/gitWatcher.js @@ -5,6 +5,9 @@ // - 工作区文件:Windows / macOS 上递归监听整个工作区(系统原生递归监听,开销小); // Linux 上递归监听要给每个子目录单独挂 watcher,大仓库代价太高,只监听元数据, // 文件内容的改动靠窗口获得焦点、操作后刷新和 V工程 推送补上; +// - Linux 上元数据目录也不整棵递归:Node 在 Linux 上的递归监听是 JS 实现,会同步遍历整棵 .git、给每个文件挂一个 +// inotify(objects 多的仓库一次挂上万个,主线程卡几百毫秒,还可能把系统的 inotify 名额用光)。只监听元数据目录本身 +// (HEAD、index、packed-refs……)加递归的 refs/(对照 DSH fs-local 的 depth:0); // - 一批连续写入只触发一次:每来一个事件往后推一次。元数据从这批第一个元数据事件起最多等 maxWaitMs, // 文件内容最多等 contentMaxWaitMs(编辑器、构建一直在写时不用每几秒读一遍状态);两者谁先到就报一次; // - 应用自己改了仓库(暂存、提交……)会马上通知,absorb(id) 让监听不再为同一件事重复报: @@ -49,6 +52,7 @@ function createGitWatcher({ onChange, onDegraded = null, watch = fs.watch, + exists = fs.existsSync, platform = process.platform, delays = DEFAULT_DELAYS, logger = console, @@ -56,9 +60,10 @@ function createGitWatcher({ const { metadataMs, contentMs, maxWaitMs, contentMaxWaitMs, absorbMs } = { ...DEFAULT_DELAYS, ...delays }; const entries = new Map(); // id → entry - function open(entry, dir, kind) { + function open(entry, dir, kind, { recursive = true, prefix = '' } = {}) { try { - const watcher = watch(dir, { recursive: true, persistent: false }, (_type, filename) => onEvent(entry, kind, filename)); + const watcher = watch(dir, { recursive, persistent: false }, + (_type, filename) => onEvent(entry, kind, filename && prefix ? `${prefix}${filename}` : filename)); watcher.on?.('error', error => { // 目录被删或失去权限:关掉这一路,其余照常 try { watcher.close(); } catch (_e) { /* 已关闭 */ } @@ -140,7 +145,15 @@ function createGitWatcher({ if (entry.error) reportDegraded(entry); return; } - for (const dir of outermostDirs(targets.gitDirs || [])) open(entry, dir, 'metadata'); + for (const dir of outermostDirs(targets.gitDirs || [])) { + if (platform === 'linux') { + open(entry, dir, 'metadata', { recursive: false }); + const refs = path.join(dir, 'refs'); + if (exists(refs)) open(entry, refs, 'metadata', { prefix: 'refs/' }); + } else { + open(entry, dir, 'metadata'); + } + } const watchContent = platform !== 'linux' && targets.root; if (watchContent) open(entry, targets.root, 'content'); const kinds = new Set(entry.watchers.map(item => item.kind)); diff --git a/modules/ui-system/side-pane/git/cards.js b/modules/ui-system/side-pane/git/cards.js index 0cd302821..2027ff42a 100644 --- a/modules/ui-system/side-pane/git/cards.js +++ b/modules/ui-system/side-pane/git/cards.js @@ -34,6 +34,8 @@ export function createGitCards({ const diffCache = new Map(); let countQueue = []; let countWorkers = 0; + // 缓存作废一次加一;作废前发出的请求回来时不再写缓存(对照 ZCode GitPane diffGenerationRef) + let generation = 0; async function fetchDiff(item) { const key = keyOf(item); @@ -41,6 +43,7 @@ export function createGitCards({ if (cached && cached.state !== 'loading') return cached; if (cached?.promise) return cached.promise; const requestedWorkspace = store.currentWorkspaceId; + const requestedGeneration = generation; const promise = (async () => { let result; try { @@ -64,17 +67,23 @@ export function createGitCards({ } catch (err) { result = { state: 'unavailable', message: err.message || '暂时无法预览这个 Diff。' }; } - if (requestedWorkspace === store.currentWorkspaceId && !disposed && !store.isDisposed) diffCache.set(key, result); + if (requestedWorkspace === store.currentWorkspaceId && requestedGeneration === generation && !disposed && !store.isDisposed) diffCache.set(key, result); return result; })(); diffCache.set(key, { state: 'loading', promise }); return promise; } + // 已跟踪文件的行数随状态一起来(git diff --numstat);未跟踪文件和二进制文件才靠拉 diff 算 + const hasStatusCounts = item => Number.isFinite(item.added) && Number.isFinite(item.removed); + function paintCounts(item, card) { - const cached = diffCache.get(keyOf(item)); const countsEl = card.querySelector('.side-git-counts'); - if (!countsEl || cached?.state !== 'ready') return; + if (!countsEl) return; + const cached = hasStatusCounts(item) + ? { state: 'ready', added: item.added, removed: item.removed, approximate: false } + : diffCache.get(keyOf(item)); + if (cached?.state !== 'ready') return; countsEl.innerHTML = ''; const add = doc.createElement('span'); add.className = 'text-diff-added'; @@ -243,14 +252,15 @@ export function createGitCards({ } function prefetch(items) { - countQueue = items.slice(0, COUNT_PREFETCH_LIMIT).filter(item => !diffCache.has(keyOf(item))); + countQueue = items.filter(item => !hasStatusCounts(item)).slice(0, COUNT_PREFETCH_LIMIT).filter(item => !diffCache.has(keyOf(item))); pumpCountQueue(); } function clearExpanded() { expanded.clear(); } - function clearDiff() { expanded.clear(); diffCache.clear(); } + function clearDiff() { expanded.clear(); invalidate(); } + function invalidate() { generation += 1; diffCache.clear(); countQueue = []; } function reset() { clearDiff(); countQueue = []; } function expand(item) { expanded.clear(); expanded.add(keyOf(item)); } function hasExpanded() { return expanded.size > 0; } - return Object.freeze({ buildCard, cardFor, prefetch, reset, clearDiff, clearExpanded, expand, hasExpanded, dispose() { disposed = true; countQueue = []; expanded.clear(); diffCache.clear(); } }); + return Object.freeze({ buildCard, cardFor, prefetch, reset, clearDiff, invalidate, clearExpanded, expand, hasExpanded, dispose() { disposed = true; countQueue = []; expanded.clear(); diffCache.clear(); } }); } diff --git a/modules/ui-system/side-pane/git/context-menu.js b/modules/ui-system/side-pane/git/context-menu.js index 61b4f045c..315735fdc 100644 --- a/modules/ui-system/side-pane/git/context-menu.js +++ b/modules/ui-system/side-pane/git/context-menu.js @@ -40,10 +40,11 @@ export function createGitContextMenu({ } function absolutePathOf(item) { - const ws = workspaceOf(store.currentWorkspaceId); - if (!ws) return item.path; - const sep = ws.path.includes('\\') ? '\\' : '/'; - return ws.path.replace(/[\\/]+$/, '') + sep + item.path.split('/').join(sep); + // 条目路径相对仓库根(工作区可能是子目录),用仓库根拼;拿不到时才退回工作区根 + const base = store.currentToplevel || workspaceOf(store.currentWorkspaceId)?.path; + if (!base) return item.path; + const sep = base.includes('\\') ? '\\' : '/'; + return base.replace(/[\\/]+$/, '') + sep + item.path.split('/').join(sep); } async function revealInFileManager(item) { diff --git a/modules/ui-system/side-pane/git/git-view.js b/modules/ui-system/side-pane/git/git-view.js index 4fe3d20dd..f2c71a562 100644 --- a/modules/ui-system/side-pane/git/git-view.js +++ b/modules/ui-system/side-pane/git/git-view.js @@ -156,6 +156,8 @@ export function mountGitView(host, { const store = Object.freeze({ get currentWorkspaceId() { return currentWorkspaceId; }, + // 状态条目的路径相对仓库根;工作区可能只是仓库的一个子目录 + get currentToplevel() { return currentStatus?.toplevel || null; }, get isDisposed() { return disposed(); } }); @@ -315,9 +317,10 @@ export function mountGitView(host, { if (currentSource === AI_SOURCE && !aiBatchLoaded) await loadAiBatch(); if (disposed() || requestedId !== currentWorkspaceId) return; // 推送触发的静默刷新:状态没变就不重绘(避免闪烁、丢 hover),有展开的 diff 时照常重绘。 + // 状态里带着每个文件的增删行数,内容变了 key 也会变;变了就让缓存的 diff 作废(展开的那个保持展开,重新取) const statusKey = JSON.stringify(res.data); if (quiet && statusKey === lastStatusKey && !cardsOwner.hasExpanded()) { skipRender = true; return; } - if (statusKey !== lastStatusKey) { cardsOwner.clearDiff(); } + if (statusKey !== lastStatusKey) { cardsOwner.invalidate(); } lastStatusKey = statusKey; } catch (err) { if (quiet) return; diff --git a/tests/git-service.test.js b/tests/git-service.test.js index 09624728d..388ca2e10 100644 --- a/tests/git-service.test.js +++ b/tests/git-service.test.js @@ -384,3 +384,34 @@ test('git IPC accepts only exact application pages, including embedded query par assert.equal(isAllowedSenderUrl(mainPage),true); for(const url of ['file:///C:/attacker/main.html','file:///H:/VCP/VCPMain/VCPChat/ProjectForgemodules/projectforge.html','https://evil.example/main.html','']) assert.equal(isAllowedSenderUrl(url),false,url); }); + +test('status carries per-file line counts that follow a second edit of an already modified file', { skip: SKIP_GIT }, async t => { + const root = createRepo(t); + write(root, 'src/app.js', 'const a = 1;\nconst b = 2;\n'); + let status = await gitService.getStatus(root); + const first = status.changes.find(item => item.path === 'src/app.js'); + assert.deepEqual([first.status, first.added, first.removed], ['M', 1, 0]); + + write(root, 'src/app.js', 'const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\n'); + status = await gitService.getStatus(root); + const second = status.changes.find(item => item.path === 'src/app.js'); + assert.deepEqual([second.status, second.added, second.removed], ['M', 3, 0]); + + git(root, ['add', 'src/app.js']); + status = await gitService.getStatus(root); + const staged = status.staged.find(item => item.path === 'src/app.js'); + assert.deepEqual([staged.added, staged.removed], [3, 0]); +}); + +test('reveal targets resolve against the repository root when the workspace is a subdirectory', { skip: SKIP_GIT }, async t => { + const root = createRepo(t); + const workspace = path.join(root, 'pkg'); + write(root, 'pkg/sub/keep.txt', 'changed\n'); + const status = await gitService.getStatus(workspace); + const item = status.changes.find(entry => entry.path.endsWith('keep.txt')); + assert.equal(item.path, 'pkg/sub/keep.txt'); + + const target = await gitService.resolveRevealTarget(workspace, item.path); + assert.equal(fs.realpathSync(target), fs.realpathSync(path.join(root, 'pkg', 'sub', 'keep.txt'))); + await assert.rejects(gitService.resolveRevealTarget(workspace, 'README.md'), /不在工作区内/); +}); diff --git a/tests/git-watcher.test.js b/tests/git-watcher.test.js index 82f919399..567ff1074 100644 --- a/tests/git-watcher.test.js +++ b/tests/git-watcher.test.js @@ -116,7 +116,7 @@ test('on Linux only the git directories are watched, and that is reported as deg getTargets: async () => ({ root, gitDirs: [path.join(root, '.git')] }), onChange: () => {}, onDegraded: (id, info) => degraded.push([id, info.mode]), - watch, platform: 'linux', logger: quiet, + watch, platform: 'linux', logger: quiet, exists: () => false, }); await watcher.start('ws1'); assert.deepEqual(opened.map(item => item.dir), [path.join(root, '.git')]); @@ -394,3 +394,20 @@ test('a repository the app can only partly watch tells subscribed windows, and l const b = fakeWindow(); assert.deepEqual(subscriptions.subscribe(b.sender, gitHandlers.STATUS_TOPIC, 'ws1'), { success: true, state: { degraded: true } }); }); + +test('on Linux the git directory is watched shallowly and refs/ recursively, never the whole object store', async () => { + const { watch, opened } = fakeWatch(); + const root = path.resolve('/repo'); + const gitDir = path.join(root, '.git'); + const watcher = createGitWatcher({ + getTargets: async () => ({ root, gitDirs: [gitDir] }), + onChange: () => {}, + watch, platform: 'linux', logger: quiet, exists: dir => dir === path.join(gitDir, 'refs'), + }); + await watcher.start('ws1'); + assert.deepEqual(opened.map(item => [item.dir, item.options?.recursive]), [ + [gitDir, false], + [path.join(gitDir, 'refs'), true] + ]); + watcher.dispose(); +}); diff --git a/tests/side-pane-git-stale-diff.test.mjs b/tests/side-pane-git-stale-diff.test.mjs new file mode 100644 index 000000000..e5055e441 --- /dev/null +++ b/tests/side-pane-git-stale-diff.test.mjs @@ -0,0 +1,76 @@ +// Git 页:已修改的文件被再次编辑(状态码不变)时,行数和展开的 diff 要跟着变;作废前发出的 diff 请求不能写回缓存 +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { mountGitView } from '../modules/ui-system/side-pane/git/git-view.js'; +import { getGitChangesSource } from '../modules/ui-system/sources/git-changes.js'; + +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); + +function fixture() { + const dom = new JSDOM('
', { pretendToBeVisual: true }); + const doc = dom.window.document; + const listeners = new Set(); + const state = { after: 'a\n', added: 1, diffs: 0, toplevel: '/repo' }; + const api = { + async gitListWorkspaces() { return { success: true, data: { workspaces: [{ id: 'w', path: '/repo/pkg' }] } }; }, + async gitStatus() { + return { success: true, data: { isRepo: true, toplevel: state.toplevel, prefix: 'pkg', staged: [], conflicts: [], + changes: [{ path: 'pkg/x.js', status: 'M', added: state.added, removed: 0 }] } }; + }, + async gitDiff() { state.diffs += 1; return { success: true, data: { before: { text: '' }, after: { text: state.after } } }; }, + onGitChanged(cb) { listeners.add(cb); return () => listeners.delete(cb); }, + async subscribeMainState() { return {}; }, + async unsubscribeMainState() { return {}; } + }; + getGitChangesSource(api, 'w', { graceMs: 0 }); + const view = mountGitView(doc.getElementById('view'), { api }); + Object.defineProperty(view.element, 'offsetParent', { get: () => doc.body }); + return { + dom, doc, view, state, + counts: () => view.element.querySelector('.side-git-counts').textContent, + push: () => listeners.forEach(cb => cb({ workspaceId: 'w', reason: 'files' })), + cleanup() { view.dispose(); dom.window.close(); } + }; +} + +test('line counts and the expanded diff follow a second edit of a modified file', async () => { + const f = fixture(); + try { + await f.view.ready; + await wait(20); + assert.equal(f.counts(), '+1-0'); + assert.equal(f.state.diffs, 0, 'counts come with the status, not from one diff per file'); + + f.view.element.querySelector('.side-git-row').click(); + await wait(20); + assert.equal(f.view.element.querySelectorAll('.side-git-diff-table .diff-line.add').length, 1); + + f.state.after = 'a\nb\nc\nd\n'; + f.state.added = 4; + f.push(); + await wait(50); + assert.equal(f.counts(), '+4-0'); + assert.equal(f.view.element.querySelector('.side-git-row').getAttribute('aria-expanded'), 'true', 'the open file stays open'); + assert.equal(f.view.element.querySelectorAll('.side-git-diff-table .diff-line.add').length, 4); + } finally { f.cleanup(); } +}); + +test('copying the absolute path of a file in a subdirectory workspace uses the repository root', async () => { + const f = fixture(); + const copied = []; + Object.defineProperty(f.dom.window.navigator, 'clipboard', { configurable: true, + value: { writeText: async text => { copied.push(text); } } }); + try { + await f.view.ready; + await wait(20); + const row = f.view.element.querySelector('.side-git-row'); + row.dispatchEvent(new f.dom.window.MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 5, clientY: 5 })); + const item = [...f.doc.querySelectorAll('[role="menuitem"], .side-git-context-menu button')] + .find(el => el.textContent.includes('复制绝对路径')); + assert.ok(item, 'the context menu offers the absolute path'); + item.click(); + await wait(10); + assert.deepEqual(copied, ['/repo/pkg/x.js']); + } finally { f.cleanup(); } +}); From efbdd43f111c4b1c080fc2aea9ab8d4d9f386d1d Mon Sep 17 00:00:00 2001 From: RoxyAsahi Date: Wed, 7 Oct 2026 02:03:14 +0200 Subject: [PATCH 025/143] Contracts: follow the dynamic listener sites the tool card presentation moved The listener helpers in messageRenderer and appearance-studio are unchanged; only their line numbers shifted, which left the contract gate failing. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 1dd579e01e9b2ad896a459ad1942b414b1dbab7d) --- docs/contracts/chat-contracts.json | 12 +-- .../contracts/generated/chat-event-graph.json | 81 +++++++++++-------- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/docs/contracts/chat-contracts.json b/docs/contracts/chat-contracts.json index d87c2196f..fce2fa754 100644 --- a/docs/contracts/chat-contracts.json +++ b/docs/contracts/chat-contracts.json @@ -588,19 +588,19 @@ "dynamicSites": [ { "file": "modules/messageRenderer.js", - "line": 599, + "line": 600, "kind": "custom-event-listener", "match": "target.addEventListener(type" }, { "file": "modules/messageRenderer.js", - "line": 2585, + "line": 2589, "kind": "custom-event-listener", "match": "target.addEventListener(type" }, { "file": "modules/messageRenderer.js", - "line": 3315, + "line": 3328, "kind": "custom-event-listener", "match": "target.addEventListener(type" } @@ -776,19 +776,19 @@ "dynamicSites": [ { "file": "modules/ui-system/appearance-studio.js", - "line": 516, + "line": 524, "kind": "custom-event-listener", "match": "target.addEventListener(type" }, { "file": "modules/ui-system/appearance-studio.js", - "line": 792, + "line": 815, "kind": "custom-event-listener", "match": "root.addEventListener(type" }, { "file": "modules/ui-system/appearance-studio.js", - "line": 1552, + "line": 1579, "kind": "custom-event-listener", "match": "target.addEventListener(type" } diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 841f2d5cd..4b5489dbb 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -32523,6 +32523,30 @@ "match": "new CustomEvent(RESPONSE_EVENT", "contractId": "loom.comfyui.agent-response-bridge" }, + { + "file": "modules/messageRenderer.js", + "line": 600, + "reason": "dynamic listener name: type", + "kind": "custom-event-listener", + "match": "target.addEventListener(type", + "contractId": "source.dynamic.modules.messagerenderer" + }, + { + "file": "modules/messageRenderer.js", + "line": 2589, + "reason": "dynamic listener name: type", + "kind": "custom-event-listener", + "match": "target.addEventListener(type", + "contractId": "source.dynamic.modules.messagerenderer" + }, + { + "file": "modules/messageRenderer.js", + "line": 3328, + "reason": "dynamic listener name: type", + "kind": "custom-event-listener", + "match": "target.addEventListener(type", + "contractId": "source.dynamic.modules.messagerenderer" + }, { "file": "modules/notificationCenter.js", "line": 324, @@ -32611,6 +32635,30 @@ "match": "target?.addEventListener?.(type", "contractId": "source.dynamic.modules.topiclistmanager" }, + { + "file": "modules/ui-system/appearance-studio.js", + "line": 524, + "reason": "dynamic listener name: type", + "kind": "custom-event-listener", + "match": "target.addEventListener(type", + "contractId": "source.dynamic.modules.ui-system.appearance-studio" + }, + { + "file": "modules/ui-system/appearance-studio.js", + "line": 815, + "reason": "dynamic listener name: type", + "kind": "custom-event-listener", + "match": "root.addEventListener(type", + "contractId": "source.dynamic.modules.ui-system.appearance-studio" + }, + { + "file": "modules/ui-system/appearance-studio.js", + "line": 1579, + "reason": "dynamic listener name: type", + "kind": "custom-event-listener", + "match": "target.addEventListener(type", + "contractId": "source.dynamic.modules.ui-system.appearance-studio" + }, { "file": "modules/ui-system/ask-nova-modal.js", "line": 545, @@ -33004,36 +33052,5 @@ "contractId": "source.dynamic.preloads.loom" } ], - "undiscovered": [ - { - "file": "modules/messageRenderer.js", - "line": 600, - "reason": "dynamic listener name: type" - }, - { - "file": "modules/messageRenderer.js", - "line": 2589, - "reason": "dynamic listener name: type" - }, - { - "file": "modules/messageRenderer.js", - "line": 3328, - "reason": "dynamic listener name: type" - }, - { - "file": "modules/ui-system/appearance-studio.js", - "line": 524, - "reason": "dynamic listener name: type" - }, - { - "file": "modules/ui-system/appearance-studio.js", - "line": 815, - "reason": "dynamic listener name: type" - }, - { - "file": "modules/ui-system/appearance-studio.js", - "line": 1579, - "reason": "dynamic listener name: type" - } - ] + "undiscovered": [] } From 3b559e9550bd7f1ee11a15ede1e29d7e4bc72260 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:53:36 +0000 Subject: [PATCH 026/143] Code viewer: keep chunk placeholder height in the stylesheet, not inline The ui-system guard rejects inline style mutation outside its allowlist. Every chunk but the last holds exactly 200 lines, so the placeholder height is fixed in CSS and the last chunk is laid out normally. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017bCbRB3E9yajQ3HbdMrjiJ --- modules/ui-system/side-pane/code-viewer/editor.js | 8 +++----- styles/ui-system/side-pane-code-viewer.css | 9 +++++++-- tests/side-pane-code-viewer.test.mjs | 1 - 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/modules/ui-system/side-pane/code-viewer/editor.js b/modules/ui-system/side-pane/code-viewer/editor.js index 9444ebd8e..a0c9a70ac 100644 --- a/modules/ui-system/side-pane/code-viewer/editor.js +++ b/modules/ui-system/side-pane/code-viewer/editor.js @@ -8,7 +8,8 @@ // 整段高亮和逐行行号的成本随文件大小线性增长;超过这个字符数只预览开头,复制和插入仍用完整内容 export const PREVIEW_CHAR_LIMIT = 256 * 1024; -// 每块的行数:块越小滚动时补排越勤,越大切回标签时要排的越多;200 行约 3600px,比侧栏高出几屏 +// 每块的行数:块越小滚动时补排越勤,越大切回标签时要排的越多;200 行约 3600px,比侧栏高出几屏。 +// 样式表里块的占位高度按 200 × 18px 写死(side-pane-code-viewer.css 的 .side-code-chunk),改这里要一起改 export const CODE_CHUNK_LINES = 200; /** @@ -129,7 +130,7 @@ export function createCodeViewerEditor({ // 行号栏和代码各按 CODE_CHUNK_LINES 行分块,块用 content-visibility:auto:滚出视口的块不参与样式和布局。 // 大文件切回这个标签、聚焦时,浏览器只需要排视口里的那几块,开销不再随文件行数增长。 - // 行高固定,两栏的块高度一致,行号仍然和代码对齐。 + // 行高固定,两栏的块高度一致,行号仍然和代码对齐。最后一块不满 200 行,总是正常排版,不用占位高度。 const gutter = doc.createElement('div'); gutter.className = 'side-code-gutter'; gutter.setAttribute('aria-hidden', 'true'); @@ -140,11 +141,9 @@ export function createCodeViewerEditor({ for (let start = 0; start < lineCount; start += CODE_CHUNK_LINES) { const end = Math.min(lineCount, start + CODE_CHUNK_LINES); - const lines = String(end - start); const numbers = doc.createElement('div'); numbers.className = 'side-code-chunk'; - numbers.style.setProperty('--side-code-chunk-lines', lines); for (let idx = start + 1; idx <= end; idx++) { const lineNum = doc.createElement('div'); lineNum.className = 'side-code-line-number'; @@ -155,7 +154,6 @@ export function createCodeViewerEditor({ const text = doc.createElement('div'); text.className = 'side-code-chunk'; - text.style.setProperty('--side-code-chunk-lines', lines); // 块末尾补一个换行:块尾的换行不会多画一行,但 textContent 和复制出来的文本保持原样 const tail = end < lineCount ? '\n' : ''; if (lineHtml) text.innerHTML = lineHtml.slice(start, end).join('\n') + tail; diff --git a/styles/ui-system/side-pane-code-viewer.css b/styles/ui-system/side-pane-code-viewer.css index b0dcc3efd..d95bcb61c 100644 --- a/styles/ui-system/side-pane-code-viewer.css +++ b/styles/ui-system/side-pane-code-viewer.css @@ -188,11 +188,16 @@ html body[data-vcp-theme="light"] .side-code-gutter:where(.vcp-ui-scope, .vcp-ui color: var(--secondary-text, var(--vcp-ui-port-color-rgba-0-0-0-0-35)); } -/* 行号栏和代码的分块(见 editor.js 的 CODE_CHUNK_LINES):滚出视口时浏览器跳过它的样式和布局,占位高度按行数算 */ +/* 行号栏和代码的分块(见 editor.js 的 CODE_CHUNK_LINES = 200):滚出视口时浏览器跳过它的样式和布局, + 占位高度 = 200 行 × 18px 行高。最后一块行数不定,照常排版。 */ html .side-code-chunk:where(.vcp-ui-scope, .vcp-ui-scope *) { display: block; content-visibility: auto; - contain-intrinsic-block-size: auto calc(var(--side-code-chunk-lines, 1) * 18px); + contain-intrinsic-block-size: auto 3600px; +} + +html .side-code-chunk:last-child:where(.vcp-ui-scope, .vcp-ui-scope *) { + content-visibility: visible; } html .side-code-line-number:where(.vcp-ui-scope, .vcp-ui-scope *) { diff --git a/tests/side-pane-code-viewer.test.mjs b/tests/side-pane-code-viewer.test.mjs index 04d42eea8..ecc56e46f 100644 --- a/tests/side-pane-code-viewer.test.mjs +++ b/tests/side-pane-code-viewer.test.mjs @@ -323,7 +323,6 @@ test('code is rendered in chunks whose text and line numbers stay continuous', a const gutterChunks = view.querySelectorAll('.side-code-gutter .side-code-chunk'); assert.equal(codeChunks.length, 3); assert.deepEqual([...gutterChunks].map(chunk => chunk.children.length), [CODE_CHUNK_LINES, CODE_CHUNK_LINES, 50]); - assert.deepEqual([...codeChunks].map(chunk => chunk.style.getPropertyValue('--side-code-chunk-lines')), [String(CODE_CHUNK_LINES), String(CODE_CHUNK_LINES), '50']); assert.equal(view.querySelector('.side-code-pre').textContent, code); assert.equal(view.querySelectorAll('.side-code-line-number').length, lineCount); assert.equal([...view.querySelectorAll('.side-code-line-number')].at(-1).textContent, String(lineCount)); From b956a7087bee23e6d3556b95d6d03ad059fee9a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:53:36 +0000 Subject: [PATCH 027/143] Side pane: stop restyling the whole page on every DOM change Two things made the collapsed side pane cost the whole app style recalcs: - The tab strip's add button was moved in and out of the strip every frame while the pane was collapsed (the strip keeps 4px of padding, so its width was not 0 and each placement judged the other one right). Measured 11 moves per second at idle. A viewport narrower than the button now counts as unknown and leaves the button where it is. - Two body-level :has() rules (hide the bell, hide the resizer) made every node insertion anywhere schedule a whole-document style invalidation. The bell rule always matched in main.html; the resizer rule now asks its next sibling, the pane. Selecting an agent with 1000 topics: style recalc 780ms -> 34ms, long tasks ~600ms -> 0 (traced). Side-pane self time during renderer boot 380ms -> 12ms. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017bCbRB3E9yajQ3HbdMrjiJ --- .../side-pane/side-pane-tab-strip.js | 12 ++++--- styles/ui-system/side-pane-shell.css | 13 +++++--- tests/side-pane-tab-strip.test.mjs | 31 +++++++++++++++++++ 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/modules/ui-system/side-pane/side-pane-tab-strip.js b/modules/ui-system/side-pane/side-pane-tab-strip.js index 964f5fe82..8f56bbf9a 100644 --- a/modules/ui-system/side-pane/side-pane-tab-strip.js +++ b/modules/ui-system/side-pane/side-pane-tab-strip.js @@ -85,15 +85,17 @@ export function createSidePaneTabStrip({ let overflowing = true; if (addButton && addButtonHome) { const addInside = addButton.parentElement === tabListElement; - const measured = addButton.getBoundingClientRect?.().width || 0; + const addButtonWidth = addButton.getBoundingClientRect?.().width || 28; + const viewportWidth = tabListElement.clientWidth || 0; const overflow = resolveTabsOverflow({ addButtonInside: addInside, - addButtonWidth: measured || 28, + addButtonWidth, tabCount: items.length, - viewportWidth: tabListElement.clientWidth || 0 + viewportWidth }); - // 视口宽度未知(隐藏或无布局)时保持在右侧操作区,避免来回搬动 - overflowing = tabListElement.clientWidth ? overflow : true; + // 视口放不下一个新增按钮(面板收起时只剩内边距那几像素,或没有布局)就当宽度未知,按钮留在右侧操作区。 + // 否则收起状态下两种摆法互相判成对方,按钮每帧搬来搬去,每次搬动都让整页样式重算。 + overflowing = viewportWidth > addButtonWidth ? overflow : true; if (!overflowing && !addInside) { tabListElement.appendChild(addButton); } else if (overflowing && addInside) { diff --git a/styles/ui-system/side-pane-shell.css b/styles/ui-system/side-pane-shell.css index 08368e2c4..586f2f649 100644 --- a/styles/ui-system/side-pane-shell.css +++ b/styles/ui-system/side-pane-shell.css @@ -9,8 +9,11 @@ html body #floatingSelectionSideChatBtn[hidden]:where(.vcp-ui-scope, .vcp-ui-sco display: none; } -/* 侧栏按钮取代铃铛:通知在新标签页里,右键监控面板和待审批角标都挪到侧栏按钮上 */ -html body:has(#vcpSidePane) #toggleNotificationsBtn:where(.vcp-ui-scope, .vcp-ui-scope *) { +/* 侧栏按钮取代铃铛:通知在新标签页里,右键监控面板和待审批角标都挪到侧栏按钮上。 + 本样式只由带 #vcpSidePane 的 main.html 引入,不再用 body:has(#vcpSidePane) 判断: + 挂在 body 上的 :has() 让页面里任何节点的增删都触发整页样式失效(切换助手时多出约 0.7s 的样式重算)。 + :is(#vcpSidePane, *) 总能匹配 body,只为保留原来的优先级。 */ +html body:is(#vcpSidePane, *) #toggleNotificationsBtn:where(.vcp-ui-scope, .vcp-ui-scope *) { display: none; } @@ -68,8 +71,10 @@ html #resizerRight.resizer.active:where(.vcp-ui-scope, .vcp-ui-scope *)::after { background-color: var(--vcp-ui-accent); } -/* Without an open (or animating) side pane the handle has nothing to resize. */ -html body:not(:has(#vcpSidePane.active)):not(:has(#vcpSidePane.is-animating)) #resizerRight:not(.is-animating):where(.vcp-ui-scope, .vcp-ui-scope *) { +/* Without an open (or animating) side pane the handle has nothing to resize. + The pane is the handle's next sibling, so ask the sibling instead of body:has(): a body-level :has() + makes every node insertion anywhere invalidate the whole page's styles. */ +html body #resizerRight:not(.is-animating):has(+ #vcpSidePane:not(.active):not(.is-animating)):where(.vcp-ui-scope, .vcp-ui-scope *) { display: none; } diff --git a/tests/side-pane-tab-strip.test.mjs b/tests/side-pane-tab-strip.test.mjs index 0993a161a..c29976ab4 100644 --- a/tests/side-pane-tab-strip.test.mjs +++ b/tests/side-pane-tab-strip.test.mjs @@ -250,3 +250,34 @@ test('controller: the 通知 tab mirrors the VCPLog connection status', async () controller.dispose(); dom.window.close(); }); + +test('tab strip: a collapsed pane (only padding left) keeps the add button home instead of moving it back and forth', async () => { + const { createSidePaneTabStrip } = await import('../modules/ui-system/side-pane/side-pane-tab-strip.js'); + const dom = new JSDOM('
'); + const doc = dom.window.document; + const tabList = doc.getElementById('tabs'); + const addButton = doc.getElementById('add'); + const home = doc.getElementById('actions'); + // 收起的面板宽度为 0,标签条只剩左右内边距那几像素 + Object.defineProperty(tabList, 'clientWidth', { configurable: true, get: () => 4 }); + addButton.getBoundingClientRect = () => ({ width: 28 }); + let moves = 0; + new dom.window.MutationObserver(records => { moves += records.length; }).observe(home, { childList: true }); + const strip = createSidePaneTabStrip({ + tabListElement: tabList, + addButton, + getTabs: () => [], + getActiveTabId: () => null, + isClosable: () => true, + onActivate() {}, onClose() {}, onReorder() {}, onContextMenu() {} + }); + try { + for (let i = 0; i < 6; i++) strip.layout(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(addButton.parentElement, home); + assert.equal(moves, 0); + } finally { + strip.dispose(); + dom.window.close(); + } +}); From 39c3e00a2288cbe4b7f442e6f5fd6c34623f913d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:53:43 +0000 Subject: [PATCH 028/143] Source IPC: use the shared top-level window sender guard source:* (including source:write-file) only checked the URL a frame reported, so a subframe or webview claiming an app page URL passed. It now uses the Git handlers' createApplicationSenderGuard, like DSH apps/desktop checks the sender before privileged IPC. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../contracts/generated/chat-event-graph.json | 32 +++++------ modules/ipc/gitHandlers.js | 1 + modules/ipc/sourceHandlers.js | 8 +-- tests/source-handlers-sender.test.js | 56 +++++++++++++++++++ 4 files changed, 74 insertions(+), 23 deletions(-) create mode 100644 tests/source-handlers-sender.test.js diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index d7661e608..bbce95b77 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -27589,14 +27589,14 @@ "consumers": [ { "file": "modules/ipc/sourceHandlers.js", - "line": 74, + "line": 70, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/sourceHandlers.js", - "line": 74, + "line": 70, "kind": "event-listener", "match": "handle('source:check'" }, @@ -27620,14 +27620,14 @@ "consumers": [ { "file": "modules/ipc/sourceHandlers.js", - "line": 56, + "line": 52, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/sourceHandlers.js", - "line": 56, + "line": 52, "kind": "event-listener", "match": "handle('source:list-files'" }, @@ -27651,14 +27651,14 @@ "consumers": [ { "file": "modules/ipc/sourceHandlers.js", - "line": 58, + "line": 54, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/sourceHandlers.js", - "line": 58, + "line": 54, "kind": "event-listener", "match": "handle('source:read-file'" }, @@ -27682,14 +27682,14 @@ "consumers": [ { "file": "modules/ipc/sourceHandlers.js", - "line": 63, + "line": 59, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/sourceHandlers.js", - "line": 63, + "line": 59, "kind": "event-listener", "match": "handle('source:write-file'" }, @@ -32477,14 +32477,6 @@ "match": "event.sender.send(channel", "contractId": "source.dynamic.modules.ipc.musichandlers" }, - { - "file": "modules/ipc/sourceHandlers.js", - "line": 36, - "reason": "dynamic event listener name: channel", - "kind": "event-listener", - "match": "ipcMain.handle(channel", - "contractId": "source.dynamic.modules.ipc.sourcehandlers" - }, { "file": "modules/ipc/stateSubscriptions.js", "line": 132, @@ -33086,5 +33078,11 @@ "contractId": "source.dynamic.preloads.loom" } ], - "undiscovered": [] + "undiscovered": [ + { + "file": "modules/ipc/sourceHandlers.js", + "line": 32, + "reason": "dynamic event listener name: channel" + } + ] } diff --git a/modules/ipc/gitHandlers.js b/modules/ipc/gitHandlers.js index 13ff70f0f..88d99be99 100644 --- a/modules/ipc/gitHandlers.js +++ b/modules/ipc/gitHandlers.js @@ -204,6 +204,7 @@ module.exports = { CHANNELS, STATUS_TOPIC, initialize, + isAllowedSender, isAllowedSenderUrl, watchSnapshot, dispose, diff --git a/modules/ipc/sourceHandlers.js b/modules/ipc/sourceHandlers.js index 305ce7e13..4a6b87872 100644 --- a/modules/ipc/sourceHandlers.js +++ b/modules/ipc/sourceHandlers.js @@ -7,7 +7,8 @@ const { ipcMain } = require('electron'); const sourceService = require('../services/sourceService'); -const { isAllowedSenderUrl } = require('./gitHandlers'); +// 和 Git 侧栏共用同一个守卫:只认应用页面的真实顶层窗口,子 frame 或 webview 自报的 URL 不算 +const { isAllowedSender } = require('./gitHandlers'); const CHANNELS = [ 'source:list-files', @@ -18,11 +19,6 @@ const CHANNELS = [ let workspaceServiceRef = null; -function isAllowedSender(event) { - const raw = event?.senderFrame?.url || event?.sender?.getURL?.() || ''; - return isAllowedSenderUrl(raw); -} - function resolveWorkspaceRoot(workspaceId) { if (!workspaceServiceRef) throw new Error('工作区服务未初始化。'); if (typeof workspaceId !== 'string' || !workspaceId) throw new Error('请先选择工作区。'); diff --git a/tests/source-handlers-sender.test.js b/tests/source-handlers-sender.test.js new file mode 100644 index 000000000..9ce512de0 --- /dev/null +++ b/tests/source-handlers-sender.test.js @@ -0,0 +1,56 @@ +'use strict'; + +// 源码侧栏能写文件,来源校验必须和 Git 侧栏一样严:子 frame、webview 自报应用页面 URL 都不能调用。 +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const Module = require('node:module'); +const { createTrustedMainSender } = require('./helpers/trusted-main-sender.cjs'); + +const handlers = new Map(); +const originalLoad = Module._load; +Module._load = function loadWithElectronMock(request, parent, isMain) { + if (request === 'electron') { + return { + ipcMain: { + handle: (channel, fn) => handlers.set(channel, fn), + removeHandler: (channel) => handlers.delete(channel), + on: () => {}, + }, + BrowserWindow: class {}, + shell: {}, + }; + } + return originalLoad.call(this, request, parent, isMain); +}; +const sourceHandlers = require('../modules/ipc/sourceHandlers'); +Module._load = originalLoad; + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vcp-source-ws-')); +fs.writeFileSync(path.join(root, 'a.txt'), 'hello'); +test.before(() => sourceHandlers.initialize({ workspaceService: { list: () => [{ id: 'ws', alias: 'ws', path: root, enabled: true }] } })); +test.after(() => fs.rmSync(root, { recursive: true, force: true })); + +const read = event => handlers.get('source:read-file')(event, 'ws', 'a.txt'); + +test('source handlers accept the real top-level app window', async () => { + const { event } = createTrustedMainSender(); + const result = await read(event); + assert.equal(result.success, true, result.error); +}); + +test('source handlers reject a subframe that reports an app page URL', async () => { + const { sender } = createTrustedMainSender(); + const subframe = { url: sender.mainFrame.url, detached: false }; + const result = await read({ sender, senderFrame: subframe }); + assert.equal(result.success, false); +}); + +test('source handlers reject a webview guest that reports an app page URL', async () => { + const { sender, event } = createTrustedMainSender(); + sender.getType = () => 'webview'; + const result = await read(event); + assert.equal(result.success, false); +}); From ca369fa6a057b8db023be6f966fac482e18ecc89 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:54:34 +0000 Subject: [PATCH 029/143] Contracts: follow the sourceHandlers dynamic site to its new line Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- docs/contracts/chat-contracts.json | 2 +- docs/contracts/generated/chat-event-graph.json | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/contracts/chat-contracts.json b/docs/contracts/chat-contracts.json index c6e520d4a..37e3113db 100644 --- a/docs/contracts/chat-contracts.json +++ b/docs/contracts/chat-contracts.json @@ -469,7 +469,7 @@ "dynamicSites": [ { "file": "modules/ipc/sourceHandlers.js", - "line": 36, + "line": 32, "kind": "event-listener", "match": "ipcMain.handle(channel" } diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index bbce95b77..26850224b 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -32477,6 +32477,14 @@ "match": "event.sender.send(channel", "contractId": "source.dynamic.modules.ipc.musichandlers" }, + { + "file": "modules/ipc/sourceHandlers.js", + "line": 32, + "reason": "dynamic event listener name: channel", + "kind": "event-listener", + "match": "ipcMain.handle(channel", + "contractId": "source.dynamic.modules.ipc.sourcehandlers" + }, { "file": "modules/ipc/stateSubscriptions.js", "line": 132, @@ -33078,11 +33086,5 @@ "contractId": "source.dynamic.preloads.loom" } ], - "undiscovered": [ - { - "file": "modules/ipc/sourceHandlers.js", - "line": 32, - "reason": "dynamic event listener name: channel" - } - ] + "undiscovered": [] } From b0e4a0275e6f52077c6ac814e0d481d4707c91a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:56:02 +0000 Subject: [PATCH 030/143] Stop the tooltip bridge and turn navigator from keeping removed nodes alive The native tooltip bridge remembered every element that ever had a title in a Map that is only cleared when the shell unmounts, so every side-pane tab that was opened and closed left its whole subtree behind (53-275 DOM nodes per cycle in a real Electron run). It now keys the original attributes in a WeakMap and restores them from the elements still in the page on unmount. The turn navigator's dirty set held system and removed messages that are never read back; it is now a WeakSet. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SADzqzusVz3S3z7qBUSy8M --- modules/ui-system/conversation-turn-navigator.js | 4 ++-- modules/ui-system/next-shell/next-shell-controller.js | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/modules/ui-system/conversation-turn-navigator.js b/modules/ui-system/conversation-turn-navigator.js index ef4398681..6c60c513d 100644 --- a/modules/ui-system/conversation-turn-navigator.js +++ b/modules/ui-system/conversation-turn-navigator.js @@ -171,7 +171,7 @@ function readMessageText(messageItem) { /** * 把消息列表读成 buildTurnNavigatorItems 需要的 entries。 - * textCache(WeakMap)+ dirty(Set)让没有变化的消息不必每次都克隆一遍 DOM: + * textCache(WeakMap)+ dirty(WeakSet,不留住已经移出列表的消息)让没有变化的消息不必每次都克隆一遍 DOM: * 只有被标记为 dirty 的消息才会重新读取文字。 */ export function collectTurnEntries(messagesRoot, { textCache = null, dirty = null } = {}) { @@ -243,7 +243,7 @@ export function createConversationTurnNavigator({ let frame = null; let reducedMotion = false; const textCache = new WeakMap(); - const dirty = new Set(); + const dirty = new WeakSet(); const h = (tag, className) => { const node = doc.createElement(tag); diff --git a/modules/ui-system/next-shell/next-shell-controller.js b/modules/ui-system/next-shell/next-shell-controller.js index 05b90a637..a144bb1c1 100644 --- a/modules/ui-system/next-shell/next-shell-controller.js +++ b/modules/ui-system/next-shell/next-shell-controller.js @@ -598,7 +598,8 @@ function mountNativeTooltipBridge(scope) { if (!scope) return; - const converted = new Map(); + // 只按元素记原始属性,不持有元素:被移出页面的节点要能回收。卸载时从页面上还在的节点里还原 + const converted = new WeakMap(); const isExcluded = element => element.closest?.('#nextUiInternalAppHost, #vchatAppTray') || (element.matches?.('input, select, textarea') && element.closest?.('.vcp-settings-schema-surface, .settings-sidebar-surface-view')); const convert = element => { if (!(element instanceof Element) || isExcluded(element) || !element.hasAttribute('title')) return; @@ -702,8 +703,11 @@ hide(); host.remove(); observer.disconnect(); - converted.forEach(({ title, originalDataTooltip, originalAriaLabel, addedAriaLabel }, element) => { - if (!element.isConnected) return; + document.querySelectorAll('[data-tooltip]').forEach(element => { + const record = converted.get(element); + if (!record) return; + converted.delete(element); + const { title, originalDataTooltip, originalAriaLabel, addedAriaLabel } = record; element.setAttribute('title', title); if (originalDataTooltip === null) delete element.dataset.tooltip; else element.setAttribute('data-tooltip', originalDataTooltip); @@ -712,7 +716,6 @@ else element.setAttribute('aria-label', originalAriaLabel); } }); - converted.clear(); }, 'next:native-tooltip-bridge', 'observer'); } From 2621979b80184ca1f6296b14efc01f2386b066c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:22:28 +0000 Subject: [PATCH 031/143] UI system gate: scope the tool presentation styles and drop the stray literal colour check:ui-system was red on the side pane branch. tool-presentation.css had 101 selectors outside .vcp-ui-scope, an !important on the reduced-motion rule and two fixed 11px sizes; components.css (from upstream #234) fell back to a hard-coded indigo because --vcp-ui-primary is not a token. Every tool presentation selector now sits under .vcp-ui-scope (the chat messages container already carries it, and the file is alone in its layer, so the uniform specificity bump changes no cascade). The reduced-motion rule reuses the shimmer selector instead of !important, the 11px sizes use --vcp-ui-text-xs, and the pinned window button uses the accent token. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UCGGgmHsCToooXTgSXpmq9 --- styles/ui-system/tool-presentation.css | 196 ++++++++++++------------- 1 file changed, 98 insertions(+), 98 deletions(-) diff --git a/styles/ui-system/tool-presentation.css b/styles/ui-system/tool-presentation.css index 436a2e39c..be8a90316 100644 --- a/styles/ui-system/tool-presentation.css +++ b/styles/ui-system/tool-presentation.css @@ -1,12 +1,12 @@ /* Optional presentation; Legacy is untouched. Theme and typography stay owned * by the existing appearance profile, including role wallpapers. */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="compact"], -html[data-ui-mode="next"] [data-vcp-tool-presentation="grouped"] { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="compact"], +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="grouped"] { --vcp-tool-row-height: 38px; } -html[data-ui-mode="next"][data-vcp-density="compact"] [data-vcp-tool-presentation] { --vcp-tool-row-height: 32px; } -html[data-ui-mode="next"][data-vcp-density="relaxed"] [data-vcp-tool-presentation] { --vcp-tool-row-height: 44px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented { +html[data-ui-mode="next"][data-vcp-density="compact"] .vcp-ui-scope [data-vcp-tool-presentation] { --vcp-tool-row-height: 32px; } +html[data-ui-mode="next"][data-vcp-density="relaxed"] .vcp-ui-scope [data-vcp-tool-presentation] { --vcp-tool-row-height: 44px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented { --vcp-tool-presented-font-family: var(--vcp-tool-card-font-family, var(--vcp-chat-font-family)); --vcp-tool-presented-font-size: var(--vcp-ui-text-caption, 13px); --vcp-tool-presented-white-space: normal; @@ -32,11 +32,11 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented { color: var(--vcp-ui-text-primary); font-family: var(--vcp-tool-card-font-family, inherit); } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented::before, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented::after { display: none; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-summary, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-header, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-header { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented::before, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented::after { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-summary, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-header, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-header { display: flex; gap: 5px; padding: 0; @@ -47,7 +47,7 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vc min-width: 0; flex-wrap: nowrap; } -html[data-ui-mode="next"] .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-tool-process-toggle { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { display: flex; width: 100%; min-width: 0; @@ -65,13 +65,13 @@ html[data-ui-mode="next"] .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-t cursor: pointer; text-align: left; } -html[data-ui-mode="next"] .vcp-tool-result-header > .vcp-tool-row-toggle { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-result-header > .vcp-tool-row-toggle { width: auto; flex: 1; } -html[data-ui-mode="next"] .vcp-tool-row-toggle:hover, html[data-ui-mode="next"] .vcp-tool-process-toggle:hover { background: var(--vcp-ui-bg-2); } -html[data-ui-mode="next"] .vcp-tool-row-toggle:focus-visible, html[data-ui-mode="next"] .vcp-tool-process-toggle:focus-visible, html[data-ui-mode="next"] .vcp-tool-process-artifact:focus-visible { outline: 2px solid var(--vcp-ui-accent); outline-offset: -2px; } -html[data-ui-mode="next"] .vcp-tool-row-chevron { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle:hover, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle:hover { background: var(--vcp-ui-bg-2); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle:focus-visible, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle:focus-visible, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-artifact:focus-visible { outline: 2px solid var(--vcp-ui-accent); outline-offset: -2px; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-chevron { width: 7px; height: 7px; flex: 0 0 7px; @@ -79,44 +79,44 @@ html[data-ui-mode="next"] .vcp-tool-row-chevron { border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); } -html[data-ui-mode="next"] [aria-expanded="true"] > .vcp-tool-row-chevron { transform: rotate(45deg); } -html[data-ui-mode="next"] .vcp-tool-row-title { overflow-wrap: anywhere; min-width: 0; color: var(--vcp-ui-text-primary); } -html[data-ui-mode="next"] .vcp-tool-row-resource { overflow-wrap: anywhere; min-width: 0; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] .vcp-tool-row-state { margin-left: auto; white-space: nowrap; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] [data-vcp-tool-state="success"] .vcp-tool-row-state { color: var(--vcp-ui-success); } -html[data-ui-mode="next"] [data-vcp-tool-state="failed"] .vcp-tool-row-state { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-state="waiting"] .vcp-tool-row-state, -html[data-ui-mode="next"] [data-vcp-tool-state="stopped"] .vcp-tool-row-state { color: var(--vcp-ui-warning, var(--vcp-ui-palette-amber)); } -html[data-ui-mode="next"] [data-vcp-tool-state="running"] .vcp-tool-row-state { color: var(--vcp-ui-accent); } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-delete-btn { flex: 0 0 24px; margin-right: 6px; opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:hover .vcp-tool-result-delete-btn, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:focus-within .vcp-tool-result-delete-btn { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="compact"] .vcp-role-divider, -html[data-ui-mode="next"] [data-vcp-tool-presentation="grouped"] .vcp-role-divider { margin: 4px 0; font-size: 11px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-details { max-height: 360px; overflow: auto; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-content { +html[data-ui-mode="next"] .vcp-ui-scope [aria-expanded="true"] > .vcp-tool-row-chevron { transform: rotate(45deg); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-title { overflow-wrap: anywhere; min-width: 0; color: var(--vcp-ui-text-primary); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-resource { overflow-wrap: anywhere; min-width: 0; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-state { margin-left: auto; white-space: nowrap; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="success"] .vcp-tool-row-state { color: var(--vcp-ui-success); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="failed"] .vcp-tool-row-state { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="waiting"] .vcp-tool-row-state, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="stopped"] .vcp-tool-row-state { color: var(--vcp-ui-warning, var(--vcp-ui-palette-amber)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="running"] .vcp-tool-row-state { color: var(--vcp-ui-accent); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-delete-btn { flex: 0 0 24px; margin-right: 6px; opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:hover .vcp-tool-result-delete-btn, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:focus-within .vcp-tool-result-delete-btn { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="compact"] .vcp-role-divider, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="grouped"] .vcp-role-divider { margin: 4px 0; font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-details { max-height: 360px; overflow: auto; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-content { margin: 0; padding: 6px 12px 12px 28px; background: transparent; border: 0; min-width: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-call-summary-content { display: none; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-call-summary-content { display: block; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented pre { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-call-summary-content { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-call-summary-content { display: block; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented pre { white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-height: 320px; overflow: auto; } -html[data-ui-mode="next"] .vcp-tool-process { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process { border: 1px solid var(--vcp-ui-border); border-radius: var(--vcp-ui-radius-md, 8px); margin: 10px 0; @@ -124,12 +124,12 @@ html[data-ui-mode="next"] .vcp-tool-process { overflow: hidden; background: var(--vcp-ui-bg-0); } -html[data-ui-mode="next"] .vcp-tool-process-toggle { background: var(--vcp-ui-bg-1); border-radius: 0; } -html[data-ui-mode="next"] .vcp-tool-process-title { min-width: 0; overflow-wrap: anywhere; } -html[data-ui-mode="next"] .vcp-tool-process-stats { margin-left: auto; font-size: var(--vcp-ui-text-sm, 12px); overflow-wrap: anywhere; } -html[data-ui-mode="next"] .vcp-tool-process-body[hidden] { display: none; } -html[data-ui-mode="next"] .vcp-tool-process-body > .vcp-tool-presented { margin: 0; border-top: 1px solid var(--vcp-ui-border); } -html[data-ui-mode="next"] .vcp-tool-process-artifact { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { background: var(--vcp-ui-bg-1); border-radius: 0; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-title { min-width: 0; overflow-wrap: anywhere; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-stats { margin-left: auto; font-size: var(--vcp-ui-text-sm, 12px); overflow-wrap: anywhere; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-body[hidden] { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-body > .vcp-tool-presented { margin: 0; border-top: 1px solid var(--vcp-ui-border); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-artifact { color: var(--vcp-ui-text-accent); background: transparent; border: 0; @@ -142,20 +142,20 @@ html[data-ui-mode="next"] .vcp-tool-process-artifact { cursor: pointer; } @media (max-width: 640px) { - html[data-ui-mode="next"] .vcp-tool-row-toggle { flex-wrap: wrap; gap: 6px; } - html[data-ui-mode="next"] .vcp-tool-row-state { margin-left: auto; } - html[data-ui-mode="next"] .vcp-tool-process-toggle { flex-wrap: wrap; } - html[data-ui-mode="next"] .vcp-tool-process-stats { margin-left: 16px; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle { flex-wrap: wrap; gap: 6px; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-state { margin-left: auto; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { flex-wrap: wrap; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-stats { margin-left: 16px; } } /* 单行合并 / 整轮折叠:无框单行,一次调用一行;紧跟的同名结果收进请求行,展开后才显示。 */ -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) { --vcp-tool-call-color: var(--vcp-ui-text-3, var(--vcp-ui-text-secondary)); --vcp-tool-call-hover: var(--vcp-ui-text-2, var(--vcp-ui-text-secondary)); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-role-divider { margin: 4px 0; font-size: 11px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented { margin: 2px 0; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-role-divider { margin: 4px 0; font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented { margin: 2px 0; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { width: auto; max-width: 100%; min-height: 24px; @@ -168,10 +168,10 @@ html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-t color: var(--vcp-tool-call-color); transition: color .1s; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):hover { background: transparent; color: var(--vcp-tool-call-hover); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { flex: none; color: inherit; white-space: nowrap; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-process-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-resource { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):hover { background: transparent; color: var(--vcp-tool-call-hover); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { flex: none; color: inherit; white-space: nowrap; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-process-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-resource { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -179,20 +179,20 @@ html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-t overflow-wrap: normal; font-size: inherit; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-state { flex: none; margin-left: 0; color: inherit; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon { display: inline-flex; flex: none; width: 16px; height: 16px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon svg { width: 100%; height: 100%; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-diff { flex: none; font-family: var(--vcp-ui-font-mono, ui-monospace, Consolas, monospace); font-size: 11px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-add { color: var(--vcp-ui-success); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-del { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-state { flex: none; margin-left: 0; color: inherit; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon { display: inline-flex; flex: none; width: 16px; height: 16px; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon svg { width: 100%; height: 100%; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-diff { flex: none; font-family: var(--vcp-ui-font-mono, ui-monospace, Consolas, monospace); font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-add { color: var(--vcp-ui-success); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-del { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } /* 并入请求行的结果:收起时不占位,展开后只留一个小标题“输出”。 - * 旧层给结果气泡写了 display:block !important,这里只能藏标题、去掉外边距,正文由上面的收起规则隐藏。 */ -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) { margin: 0; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) > .vcp-tool-result-header { display: none; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"] .vcp-tool-row-toggle { min-height: 20px; padding-left: 24px; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented > :is(.vcp-tool-details, .vcp-tool-result-collapsible-content, .vcp-tool-call-summary-content) { padding: 8px 0 4px 24px; } + * 旧层给结果气泡写了强制的 display:block(important 级),这里只能藏标题、去掉外边距,正文由上面的收起规则隐藏。 */ +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) { margin: 0; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) > .vcp-tool-result-header { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"] .vcp-tool-row-toggle { min-height: 20px; padding-left: 24px; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented > :is(.vcp-tool-details, .vcp-tool-result-collapsible-content, .vcp-tool-call-summary-content) { padding: 8px 0 4px 24px; } /* 等待结果时动作词的微光,只在回复生成中出现。 */ -html[data-ui-mode="next"] .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { +html[data-ui-mode="next"] .vcp-ui-scope .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { background: linear-gradient(90deg, var(--vcp-tool-call-color) 0%, var(--vcp-ui-text-primary) 50%, var(--vcp-tool-call-color) 100%); background-size: 200% 100%; -webkit-background-clip: text; @@ -202,33 +202,33 @@ html[data-ui-mode="next"] .message-item.streaming :is([data-vcp-tool-presentatio } @keyframes vcp-tool-call-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } } @media (prefers-reduced-motion: reduce) { - html[data-ui-mode="next"] [data-vcp-tool-pending="true"] .vcp-tool-row-title { animation: none !important; } + html[data-ui-mode="next"] .vcp-ui-scope .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { animation: none; } } /* 单行合并:动作词加粗,箭头在行尾、悬停或展开时出现;问题状态用虚线下划线,悬停看报错。 */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { font-size: var(--vcp-ui-text-body, 14px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { font-weight: 500; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) > .vcp-tool-row-chevron { order: 1; opacity: 0; transition: opacity .2s, transform .2s; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):is(:hover, [aria-expanded="true"]) > .vcp-tool-row-chevron { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] [data-vcp-tool-call-state] .vcp-tool-row-state { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] { margin: 2px 0; border: 0; border-radius: 0; background: transparent; overflow: visible; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] .vcp-tool-process-stats { margin-left: 0; font-size: inherit; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body { margin: 4px 0 4px 8px; padding-left: 14px; border-left: 1px solid var(--vcp-ui-border); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-artifact { border-top: 0; padding: 4px 0 4px 22px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { font-size: var(--vcp-ui-text-body, 14px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { font-weight: 500; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) > .vcp-tool-row-chevron { order: 1; opacity: 0; transition: opacity .2s, transform .2s; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):is(:hover, [aria-expanded="true"]) > .vcp-tool-row-chevron { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] [data-vcp-tool-call-state] .vcp-tool-row-state { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] { margin: 2px 0; border: 0; border-radius: 0; background: transparent; overflow: visible; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] .vcp-tool-process-stats { margin-left: 0; font-size: inherit; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body { margin: 4px 0 4px 8px; padding-left: 14px; border-left: 1px solid var(--vcp-ui-border); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-artifact { border-top: 0; padding: 4px 0 4px 22px; } /* 整轮折叠:图标悬停换成箭头,标题与摘要之间用小圆点分隔,失败时摘要显示红色报错首行。 */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle { gap: 0; font-size: var(--vcp-ui-text-caption, 13px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading { position: relative; display: inline-flex; flex: none; width: 16px; height: 16px; margin-right: 6px; align-items: center; justify-content: center; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-icon { width: 14px; height: 14px; transition: opacity .1s; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-chevron { position: absolute; inset: 0; margin: auto; opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-icon { opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-chevron { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-dot { flex: none; width: 2px; height: 2px; margin: 0 8px; border-radius: 1px; background: currentColor; opacity: .7; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle :is(.vcp-tool-row-state, .vcp-tool-row-diff) { margin-left: 10px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] [data-vcp-tool-call-state="failed"] .vcp-tool-row-toggle .vcp-tool-row-resource { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] { margin: 4px 0 10px; border: 0; border-radius: 0; background: transparent; overflow: visible; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle { gap: 0; font-size: var(--vcp-ui-text-caption, 13px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading { position: relative; display: inline-flex; flex: none; width: 16px; height: 16px; margin-right: 6px; align-items: center; justify-content: center; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-icon { width: 14px; height: 14px; transition: opacity .1s; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-chevron { position: absolute; inset: 0; margin: auto; opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-icon { opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-chevron { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-dot { flex: none; width: 2px; height: 2px; margin: 0 8px; border-radius: 1px; background: currentColor; opacity: .7; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle :is(.vcp-tool-row-state, .vcp-tool-row-diff) { margin-left: 10px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] [data-vcp-tool-call-state="failed"] .vcp-tool-row-toggle .vcp-tool-row-resource { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] { margin: 4px 0 10px; border: 0; border-radius: 0; background: transparent; overflow: visible; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle { width: 100%; min-height: 33px; padding: 0 0 8px; @@ -236,8 +236,8 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-proce border-bottom: .5px solid var(--vcp-ui-border); font-size: var(--vcp-ui-text-body, 14px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle > .vcp-tool-row-chevron { order: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] .vcp-tool-process-stats { margin-left: 0; color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body { padding-top: 8px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-artifact { border-top: 0; padding: 6px 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle > .vcp-tool-row-chevron { order: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] .vcp-tool-process-stats { margin-left: 0; color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body { padding-top: 8px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-artifact { border-top: 0; padding: 6px 0; } From 0753805a95c23f90ba8badd942bd5e92e20bdb51 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:22:28 +0000 Subject: [PATCH 032/143] UI system gate: scope the tool presentation styles and drop the stray literal colour check:ui-system was red on the side pane branch. tool-presentation.css had 101 selectors outside .vcp-ui-scope, an !important on the reduced-motion rule and two fixed 11px sizes; components.css (from upstream #234) fell back to a hard-coded indigo because --vcp-ui-primary is not a token. Every tool presentation selector now sits under .vcp-ui-scope (the chat messages container already carries it, and the file is alone in its layer, so the uniform specificity bump changes no cascade). The reduced-motion rule reuses the shimmer selector instead of !important, the 11px sizes use --vcp-ui-text-xs, and the pinned window button uses the accent token. (Ported from 2200d128 on the integration PR; components.css part dropped because the pin rule it touched is not on this branch.) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SADzqzusVz3S3z7qBUSy8M --- styles/ui-system/tool-presentation.css | 196 ++++++++++++------------- 1 file changed, 98 insertions(+), 98 deletions(-) diff --git a/styles/ui-system/tool-presentation.css b/styles/ui-system/tool-presentation.css index 436a2e39c..be8a90316 100644 --- a/styles/ui-system/tool-presentation.css +++ b/styles/ui-system/tool-presentation.css @@ -1,12 +1,12 @@ /* Optional presentation; Legacy is untouched. Theme and typography stay owned * by the existing appearance profile, including role wallpapers. */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="compact"], -html[data-ui-mode="next"] [data-vcp-tool-presentation="grouped"] { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="compact"], +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="grouped"] { --vcp-tool-row-height: 38px; } -html[data-ui-mode="next"][data-vcp-density="compact"] [data-vcp-tool-presentation] { --vcp-tool-row-height: 32px; } -html[data-ui-mode="next"][data-vcp-density="relaxed"] [data-vcp-tool-presentation] { --vcp-tool-row-height: 44px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented { +html[data-ui-mode="next"][data-vcp-density="compact"] .vcp-ui-scope [data-vcp-tool-presentation] { --vcp-tool-row-height: 32px; } +html[data-ui-mode="next"][data-vcp-density="relaxed"] .vcp-ui-scope [data-vcp-tool-presentation] { --vcp-tool-row-height: 44px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented { --vcp-tool-presented-font-family: var(--vcp-tool-card-font-family, var(--vcp-chat-font-family)); --vcp-tool-presented-font-size: var(--vcp-ui-text-caption, 13px); --vcp-tool-presented-white-space: normal; @@ -32,11 +32,11 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented { color: var(--vcp-ui-text-primary); font-family: var(--vcp-tool-card-font-family, inherit); } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented::before, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented::after { display: none; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-summary, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-header, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-header { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented::before, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented::after { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-summary, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-header, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-header { display: flex; gap: 5px; padding: 0; @@ -47,7 +47,7 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vc min-width: 0; flex-wrap: nowrap; } -html[data-ui-mode="next"] .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-tool-process-toggle { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { display: flex; width: 100%; min-width: 0; @@ -65,13 +65,13 @@ html[data-ui-mode="next"] .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-t cursor: pointer; text-align: left; } -html[data-ui-mode="next"] .vcp-tool-result-header > .vcp-tool-row-toggle { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-result-header > .vcp-tool-row-toggle { width: auto; flex: 1; } -html[data-ui-mode="next"] .vcp-tool-row-toggle:hover, html[data-ui-mode="next"] .vcp-tool-process-toggle:hover { background: var(--vcp-ui-bg-2); } -html[data-ui-mode="next"] .vcp-tool-row-toggle:focus-visible, html[data-ui-mode="next"] .vcp-tool-process-toggle:focus-visible, html[data-ui-mode="next"] .vcp-tool-process-artifact:focus-visible { outline: 2px solid var(--vcp-ui-accent); outline-offset: -2px; } -html[data-ui-mode="next"] .vcp-tool-row-chevron { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle:hover, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle:hover { background: var(--vcp-ui-bg-2); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle:focus-visible, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle:focus-visible, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-artifact:focus-visible { outline: 2px solid var(--vcp-ui-accent); outline-offset: -2px; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-chevron { width: 7px; height: 7px; flex: 0 0 7px; @@ -79,44 +79,44 @@ html[data-ui-mode="next"] .vcp-tool-row-chevron { border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); } -html[data-ui-mode="next"] [aria-expanded="true"] > .vcp-tool-row-chevron { transform: rotate(45deg); } -html[data-ui-mode="next"] .vcp-tool-row-title { overflow-wrap: anywhere; min-width: 0; color: var(--vcp-ui-text-primary); } -html[data-ui-mode="next"] .vcp-tool-row-resource { overflow-wrap: anywhere; min-width: 0; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] .vcp-tool-row-state { margin-left: auto; white-space: nowrap; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] [data-vcp-tool-state="success"] .vcp-tool-row-state { color: var(--vcp-ui-success); } -html[data-ui-mode="next"] [data-vcp-tool-state="failed"] .vcp-tool-row-state { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-state="waiting"] .vcp-tool-row-state, -html[data-ui-mode="next"] [data-vcp-tool-state="stopped"] .vcp-tool-row-state { color: var(--vcp-ui-warning, var(--vcp-ui-palette-amber)); } -html[data-ui-mode="next"] [data-vcp-tool-state="running"] .vcp-tool-row-state { color: var(--vcp-ui-accent); } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-delete-btn { flex: 0 0 24px; margin-right: 6px; opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:hover .vcp-tool-result-delete-btn, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:focus-within .vcp-tool-result-delete-btn { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="compact"] .vcp-role-divider, -html[data-ui-mode="next"] [data-vcp-tool-presentation="grouped"] .vcp-role-divider { margin: 4px 0; font-size: 11px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-details { max-height: 360px; overflow: auto; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-content { +html[data-ui-mode="next"] .vcp-ui-scope [aria-expanded="true"] > .vcp-tool-row-chevron { transform: rotate(45deg); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-title { overflow-wrap: anywhere; min-width: 0; color: var(--vcp-ui-text-primary); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-resource { overflow-wrap: anywhere; min-width: 0; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-state { margin-left: auto; white-space: nowrap; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="success"] .vcp-tool-row-state { color: var(--vcp-ui-success); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="failed"] .vcp-tool-row-state { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="waiting"] .vcp-tool-row-state, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="stopped"] .vcp-tool-row-state { color: var(--vcp-ui-warning, var(--vcp-ui-palette-amber)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="running"] .vcp-tool-row-state { color: var(--vcp-ui-accent); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-delete-btn { flex: 0 0 24px; margin-right: 6px; opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:hover .vcp-tool-result-delete-btn, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:focus-within .vcp-tool-result-delete-btn { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="compact"] .vcp-role-divider, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="grouped"] .vcp-role-divider { margin: 4px 0; font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-details { max-height: 360px; overflow: auto; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-content { margin: 0; padding: 6px 12px 12px 28px; background: transparent; border: 0; min-width: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-call-summary-content { display: none; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-call-summary-content { display: block; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented pre { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-call-summary-content { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-call-summary-content { display: block; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented pre { white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-height: 320px; overflow: auto; } -html[data-ui-mode="next"] .vcp-tool-process { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process { border: 1px solid var(--vcp-ui-border); border-radius: var(--vcp-ui-radius-md, 8px); margin: 10px 0; @@ -124,12 +124,12 @@ html[data-ui-mode="next"] .vcp-tool-process { overflow: hidden; background: var(--vcp-ui-bg-0); } -html[data-ui-mode="next"] .vcp-tool-process-toggle { background: var(--vcp-ui-bg-1); border-radius: 0; } -html[data-ui-mode="next"] .vcp-tool-process-title { min-width: 0; overflow-wrap: anywhere; } -html[data-ui-mode="next"] .vcp-tool-process-stats { margin-left: auto; font-size: var(--vcp-ui-text-sm, 12px); overflow-wrap: anywhere; } -html[data-ui-mode="next"] .vcp-tool-process-body[hidden] { display: none; } -html[data-ui-mode="next"] .vcp-tool-process-body > .vcp-tool-presented { margin: 0; border-top: 1px solid var(--vcp-ui-border); } -html[data-ui-mode="next"] .vcp-tool-process-artifact { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { background: var(--vcp-ui-bg-1); border-radius: 0; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-title { min-width: 0; overflow-wrap: anywhere; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-stats { margin-left: auto; font-size: var(--vcp-ui-text-sm, 12px); overflow-wrap: anywhere; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-body[hidden] { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-body > .vcp-tool-presented { margin: 0; border-top: 1px solid var(--vcp-ui-border); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-artifact { color: var(--vcp-ui-text-accent); background: transparent; border: 0; @@ -142,20 +142,20 @@ html[data-ui-mode="next"] .vcp-tool-process-artifact { cursor: pointer; } @media (max-width: 640px) { - html[data-ui-mode="next"] .vcp-tool-row-toggle { flex-wrap: wrap; gap: 6px; } - html[data-ui-mode="next"] .vcp-tool-row-state { margin-left: auto; } - html[data-ui-mode="next"] .vcp-tool-process-toggle { flex-wrap: wrap; } - html[data-ui-mode="next"] .vcp-tool-process-stats { margin-left: 16px; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle { flex-wrap: wrap; gap: 6px; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-state { margin-left: auto; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { flex-wrap: wrap; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-stats { margin-left: 16px; } } /* 单行合并 / 整轮折叠:无框单行,一次调用一行;紧跟的同名结果收进请求行,展开后才显示。 */ -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) { --vcp-tool-call-color: var(--vcp-ui-text-3, var(--vcp-ui-text-secondary)); --vcp-tool-call-hover: var(--vcp-ui-text-2, var(--vcp-ui-text-secondary)); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-role-divider { margin: 4px 0; font-size: 11px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented { margin: 2px 0; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-role-divider { margin: 4px 0; font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented { margin: 2px 0; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { width: auto; max-width: 100%; min-height: 24px; @@ -168,10 +168,10 @@ html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-t color: var(--vcp-tool-call-color); transition: color .1s; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):hover { background: transparent; color: var(--vcp-tool-call-hover); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { flex: none; color: inherit; white-space: nowrap; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-process-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-resource { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):hover { background: transparent; color: var(--vcp-tool-call-hover); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { flex: none; color: inherit; white-space: nowrap; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-process-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-resource { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -179,20 +179,20 @@ html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-t overflow-wrap: normal; font-size: inherit; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-state { flex: none; margin-left: 0; color: inherit; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon { display: inline-flex; flex: none; width: 16px; height: 16px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon svg { width: 100%; height: 100%; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-diff { flex: none; font-family: var(--vcp-ui-font-mono, ui-monospace, Consolas, monospace); font-size: 11px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-add { color: var(--vcp-ui-success); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-del { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-state { flex: none; margin-left: 0; color: inherit; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon { display: inline-flex; flex: none; width: 16px; height: 16px; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon svg { width: 100%; height: 100%; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-diff { flex: none; font-family: var(--vcp-ui-font-mono, ui-monospace, Consolas, monospace); font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-add { color: var(--vcp-ui-success); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-del { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } /* 并入请求行的结果:收起时不占位,展开后只留一个小标题“输出”。 - * 旧层给结果气泡写了 display:block !important,这里只能藏标题、去掉外边距,正文由上面的收起规则隐藏。 */ -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) { margin: 0; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) > .vcp-tool-result-header { display: none; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"] .vcp-tool-row-toggle { min-height: 20px; padding-left: 24px; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented > :is(.vcp-tool-details, .vcp-tool-result-collapsible-content, .vcp-tool-call-summary-content) { padding: 8px 0 4px 24px; } + * 旧层给结果气泡写了强制的 display:block(important 级),这里只能藏标题、去掉外边距,正文由上面的收起规则隐藏。 */ +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) { margin: 0; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) > .vcp-tool-result-header { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"] .vcp-tool-row-toggle { min-height: 20px; padding-left: 24px; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented > :is(.vcp-tool-details, .vcp-tool-result-collapsible-content, .vcp-tool-call-summary-content) { padding: 8px 0 4px 24px; } /* 等待结果时动作词的微光,只在回复生成中出现。 */ -html[data-ui-mode="next"] .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { +html[data-ui-mode="next"] .vcp-ui-scope .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { background: linear-gradient(90deg, var(--vcp-tool-call-color) 0%, var(--vcp-ui-text-primary) 50%, var(--vcp-tool-call-color) 100%); background-size: 200% 100%; -webkit-background-clip: text; @@ -202,33 +202,33 @@ html[data-ui-mode="next"] .message-item.streaming :is([data-vcp-tool-presentatio } @keyframes vcp-tool-call-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } } @media (prefers-reduced-motion: reduce) { - html[data-ui-mode="next"] [data-vcp-tool-pending="true"] .vcp-tool-row-title { animation: none !important; } + html[data-ui-mode="next"] .vcp-ui-scope .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { animation: none; } } /* 单行合并:动作词加粗,箭头在行尾、悬停或展开时出现;问题状态用虚线下划线,悬停看报错。 */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { font-size: var(--vcp-ui-text-body, 14px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { font-weight: 500; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) > .vcp-tool-row-chevron { order: 1; opacity: 0; transition: opacity .2s, transform .2s; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):is(:hover, [aria-expanded="true"]) > .vcp-tool-row-chevron { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] [data-vcp-tool-call-state] .vcp-tool-row-state { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] { margin: 2px 0; border: 0; border-radius: 0; background: transparent; overflow: visible; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] .vcp-tool-process-stats { margin-left: 0; font-size: inherit; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body { margin: 4px 0 4px 8px; padding-left: 14px; border-left: 1px solid var(--vcp-ui-border); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-artifact { border-top: 0; padding: 4px 0 4px 22px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { font-size: var(--vcp-ui-text-body, 14px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { font-weight: 500; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) > .vcp-tool-row-chevron { order: 1; opacity: 0; transition: opacity .2s, transform .2s; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):is(:hover, [aria-expanded="true"]) > .vcp-tool-row-chevron { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] [data-vcp-tool-call-state] .vcp-tool-row-state { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] { margin: 2px 0; border: 0; border-radius: 0; background: transparent; overflow: visible; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] .vcp-tool-process-stats { margin-left: 0; font-size: inherit; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body { margin: 4px 0 4px 8px; padding-left: 14px; border-left: 1px solid var(--vcp-ui-border); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-artifact { border-top: 0; padding: 4px 0 4px 22px; } /* 整轮折叠:图标悬停换成箭头,标题与摘要之间用小圆点分隔,失败时摘要显示红色报错首行。 */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle { gap: 0; font-size: var(--vcp-ui-text-caption, 13px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading { position: relative; display: inline-flex; flex: none; width: 16px; height: 16px; margin-right: 6px; align-items: center; justify-content: center; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-icon { width: 14px; height: 14px; transition: opacity .1s; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-chevron { position: absolute; inset: 0; margin: auto; opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-icon { opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-chevron { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-dot { flex: none; width: 2px; height: 2px; margin: 0 8px; border-radius: 1px; background: currentColor; opacity: .7; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle :is(.vcp-tool-row-state, .vcp-tool-row-diff) { margin-left: 10px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] [data-vcp-tool-call-state="failed"] .vcp-tool-row-toggle .vcp-tool-row-resource { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] { margin: 4px 0 10px; border: 0; border-radius: 0; background: transparent; overflow: visible; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle { gap: 0; font-size: var(--vcp-ui-text-caption, 13px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading { position: relative; display: inline-flex; flex: none; width: 16px; height: 16px; margin-right: 6px; align-items: center; justify-content: center; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-icon { width: 14px; height: 14px; transition: opacity .1s; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-chevron { position: absolute; inset: 0; margin: auto; opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-icon { opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-chevron { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-dot { flex: none; width: 2px; height: 2px; margin: 0 8px; border-radius: 1px; background: currentColor; opacity: .7; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle :is(.vcp-tool-row-state, .vcp-tool-row-diff) { margin-left: 10px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] [data-vcp-tool-call-state="failed"] .vcp-tool-row-toggle .vcp-tool-row-resource { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] { margin: 4px 0 10px; border: 0; border-radius: 0; background: transparent; overflow: visible; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle { width: 100%; min-height: 33px; padding: 0 0 8px; @@ -236,8 +236,8 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-proce border-bottom: .5px solid var(--vcp-ui-border); font-size: var(--vcp-ui-text-body, 14px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle > .vcp-tool-row-chevron { order: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] .vcp-tool-process-stats { margin-left: 0; color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body { padding-top: 8px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-artifact { border-top: 0; padding: 6px 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle > .vcp-tool-row-chevron { order: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] .vcp-tool-process-stats { margin-left: 0; color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body { padding-top: 8px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-artifact { border-top: 0; padding: 6px 0; } From 55a37fbb2b2151bd26a67dc44d093b14239c4b45 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:01:57 +0000 Subject: [PATCH 033/143] Command output tab: a printing command is re-read once a second with one read in flight Each push from a command that keeps printing made the tab re-read its output about every 180ms, and every read made the main process re-clean up to 512KB of terminal output (30-45ms measured). The tab now re-reads a running command at most once a second and keeps a single read in flight, as ZCode's background output view does. The store reuses the cleaned output while the raw output is unchanged. Also regenerates the chat event graph for the moved source lines and the contracts entry for the git handler's dynamic site. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- .../PowerShellExecutor/commandRunStore.js | 15 +- docs/contracts/chat-contracts.json | 2 +- .../contracts/generated/chat-event-graph.json | 228 ++++++++++-------- .../side-pane/toolOutputSideProvider.js | 24 +- tests/side-pane-tool-output.test.mjs | 21 +- 5 files changed, 179 insertions(+), 111 deletions(-) diff --git a/VCPDistributedServer/Plugin/PowerShellExecutor/commandRunStore.js b/VCPDistributedServer/Plugin/PowerShellExecutor/commandRunStore.js index 81e20618b..0d39833c3 100644 --- a/VCPDistributedServer/Plugin/PowerShellExecutor/commandRunStore.js +++ b/VCPDistributedServer/Plugin/PowerShellExecutor/commandRunStore.js @@ -78,13 +78,24 @@ function listCommandRuns() { return commandRuns.map(summarizeCommandRun).reverse(); } +// 清洗是逐字符的屏幕模拟,几百 KB 要几十毫秒,而且在主进程里同步跑:输出没变时直接用上次的结果 +const cleanedOutput = new WeakMap(); // run -> { raw, clean } + +function cleanRunOutput(run) { + const cached = cleanedOutput.get(run); + if (cached && cached.raw === run.raw) return cached.clean; + // 起始标记那一行留下的换行不算输出 + const clean = sanitizeTerminalOutput(run.raw).replace(/\r\n/g, '\n').replace(/\r/g, '').replace(/^\n/, ''); + cleanedOutput.set(run, { raw: run.raw, clean }); + return clean; +} + /** 读取一条记录,输出为清洗后的纯文本,只保留末尾 maxChars 个字符。 */ function getCommandRun(id, { maxChars = COMMAND_RUN_READ_LIMIT } = {}) { const run = commandRuns.find(item => item.id === id); if (!run) return null; const limit = Math.max(1, Math.min(COMMAND_RUN_READ_LIMIT, Math.floor(Number(maxChars)) || COMMAND_RUN_READ_LIMIT)); - // 起始标记那一行留下的换行不算输出 - const clean = sanitizeTerminalOutput(run.raw).replace(/\r\n/g, '\n').replace(/\r/g, '').replace(/^\n/, ''); + const clean = cleanRunOutput(run); const truncated = run.rawTruncated || clean.length > limit; return { ...summarizeCommandRun(run), diff --git a/docs/contracts/chat-contracts.json b/docs/contracts/chat-contracts.json index fce2fa754..d8ee3e1f8 100644 --- a/docs/contracts/chat-contracts.json +++ b/docs/contracts/chat-contracts.json @@ -431,7 +431,7 @@ "dynamicSites": [ { "file": "modules/ipc/gitHandlers.js", - "line": 97, + "line": 96, "kind": "event-listener", "match": "ipcMain.handle(channel" } diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 4b5489dbb..923abb305 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -664,7 +664,7 @@ }, { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 243, + "line": 270, "kind": "custom-event-listener" }, { @@ -729,7 +729,7 @@ }, { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 243, + "line": 270, "kind": "custom-event-listener", "match": "signal?.addEventListener('abort'" }, @@ -1695,7 +1695,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 98, + "line": 99, "kind": "custom-event-listener" }, { @@ -1796,7 +1796,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 98, + "line": 99, "kind": "custom-event-listener", "match": "win.addEventListener('blur'" }, @@ -2387,7 +2387,7 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 567, + "line": 577, "kind": "custom-event-listener" } ], @@ -2562,7 +2562,7 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 567, + "line": 577, "kind": "custom-event-listener", "match": "el.addEventListener('change'" } @@ -3693,12 +3693,12 @@ }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 551, + "line": 562, "kind": "custom-event-listener" }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 554, + "line": 565, "kind": "custom-event-listener" }, { @@ -4193,17 +4193,17 @@ }, { "file": "modules/ui-system/side-pane/git/cards.js", - "line": 220, + "line": 229, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 90, + "line": 91, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/git/git-view.js", - "line": 217, + "line": 219, "kind": "custom-event-listener" }, { @@ -4258,82 +4258,82 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 334, + "line": 336, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 369, + "line": 371, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 406, + "line": 408, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 415, + "line": 417, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 528, + "line": 530, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 582, + "line": 592, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 586, + "line": 596, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 658, + "line": 668, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 685, + "line": 695, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 703, + "line": 713, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 723, + "line": 733, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 751, + "line": 762, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 765, + "line": 776, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1023, + "line": 1024, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1029, + "line": 1030, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1043, + "line": 1044, "kind": "custom-event-listener" }, { @@ -5148,13 +5148,13 @@ }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 551, + "line": 562, "kind": "custom-event-listener", "match": "stopBtn.addEventListener('click'" }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 554, + "line": 565, "kind": "custom-event-listener", "match": "persistenceBadge.addEventListener('click'" }, @@ -5748,19 +5748,19 @@ }, { "file": "modules/ui-system/side-pane/git/cards.js", - "line": 220, + "line": 229, "kind": "custom-event-listener", "match": "row.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 90, + "line": 91, "kind": "custom-event-listener", "match": "btn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/git/git-view.js", - "line": 217, + "line": 219, "kind": "custom-event-listener", "match": "btn.addEventListener('click'" }, @@ -5826,97 +5826,97 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 334, + "line": 336, "kind": "custom-event-listener", "match": "btn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 369, + "line": 371, "kind": "custom-event-listener", "match": "crumbs.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 406, + "line": 408, "kind": "custom-event-listener", "match": "refreshBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 415, + "line": 417, "kind": "custom-event-listener", "match": "forgeBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 528, + "line": 530, "kind": "custom-event-listener", "match": "row.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 582, + "line": 592, "kind": "custom-event-listener", "match": "toggle.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 586, + "line": 596, "kind": "custom-event-listener", "match": "clear.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 658, + "line": 668, "kind": "custom-event-listener", "match": "headBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 685, + "line": 695, "kind": "custom-event-listener", "match": "pick.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 703, + "line": 713, "kind": "custom-event-listener", "match": "pick.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 723, + "line": 733, "kind": "custom-event-listener", "match": "link.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 751, + "line": 762, "kind": "custom-event-listener", "match": "retry.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 765, + "line": 776, "kind": "custom-event-listener", "match": "retry.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1023, + "line": 1024, "kind": "custom-event-listener", "match": "toggleNotificationsBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1029, + "line": 1030, "kind": "custom-event-listener", "match": "expandButton.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1043, + "line": 1044, "kind": "custom-event-listener", "match": "closeSidePaneBtn.addEventListener('click'" }, @@ -7093,6 +7093,11 @@ "file": "modules/renderer/sideChatSurfaceOwner.js", "line": 344, "kind": "custom-event-listener" + }, + { + "file": "modules/ui-system/side-pane/planDetailSideProvider.js", + "line": 558, + "kind": "custom-event-listener" } ], "evidence": [ @@ -7101,6 +7106,12 @@ "line": 344, "kind": "custom-event-listener", "match": "textarea.addEventListener('compositionend'" + }, + { + "file": "modules/ui-system/side-pane/planDetailSideProvider.js", + "line": 558, + "kind": "custom-event-listener", + "match": "el.addEventListener('compositionend'" } ] }, @@ -7112,6 +7123,11 @@ "file": "modules/renderer/sideChatSurfaceOwner.js", "line": 343, "kind": "custom-event-listener" + }, + { + "file": "modules/ui-system/side-pane/planDetailSideProvider.js", + "line": 557, + "kind": "custom-event-listener" } ], "evidence": [ @@ -7120,6 +7136,12 @@ "line": 343, "kind": "custom-event-listener", "match": "textarea.addEventListener('compositionstart'" + }, + { + "file": "modules/ui-system/side-pane/planDetailSideProvider.js", + "line": 557, + "kind": "custom-event-listener", + "match": "el.addEventListener('compositionstart'" } ] }, @@ -7273,7 +7295,7 @@ }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 558, + "line": 569, "kind": "custom-event-listener" }, { @@ -7298,7 +7320,7 @@ }, { "file": "modules/ui-system/side-pane/git/cards.js", - "line": 238, + "line": 247, "kind": "custom-event-listener" }, { @@ -7405,7 +7427,7 @@ }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 558, + "line": 569, "kind": "custom-event-listener", "match": "persistenceBadge.addEventListener('contextmenu'" }, @@ -7435,7 +7457,7 @@ }, { "file": "modules/ui-system/side-pane/git/cards.js", - "line": 238, + "line": 247, "kind": "custom-event-listener", "match": "row.addEventListener('contextmenu'" }, @@ -11785,7 +11807,7 @@ }, { "file": "modules/services/gitWatcher.js", - "line": 62, + "line": 67, "kind": "event-listener" }, { @@ -12111,7 +12133,7 @@ }, { "file": "modules/services/gitWatcher.js", - "line": 62, + "line": 67, "kind": "event-listener", "match": "watcher.on?.('error'" }, @@ -12682,14 +12704,14 @@ "consumers": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 152, + "line": 179, "kind": "custom-event-listener" } ], "evidence": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 152, + "line": 179, "kind": "custom-event-listener", "match": "screen.addEventListener('focusin'" } @@ -13835,14 +13857,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 191, + "line": 188, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 191, + "line": 188, "kind": "event-listener", "match": "handle('git:change-summary'" }, @@ -13885,14 +13907,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 154, + "line": 153, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 154, + "line": 153, "kind": "event-listener", "match": "handle('git:commit'" }, @@ -13916,14 +13938,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 186, + "line": 183, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 186, + "line": 183, "kind": "event-listener", "match": "handle('git:commit-graph'" }, @@ -13947,14 +13969,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 180, + "line": 177, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 180, + "line": 177, "kind": "event-listener", "match": "handle('git:create-branch'" }, @@ -13978,14 +14000,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 135, + "line": 134, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 135, + "line": 134, "kind": "event-listener", "match": "handle('git:diff'" }, @@ -14009,14 +14031,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 148, + "line": 147, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 148, + "line": 147, "kind": "event-listener", "match": "handle('git:discard'" }, @@ -14040,14 +14062,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 173, + "line": 170, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 173, + "line": 170, "kind": "event-listener", "match": "handle('git:list-branches'" }, @@ -14071,14 +14093,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 128, + "line": 127, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 128, + "line": 127, "kind": "event-listener", "match": "handle('git:list-workspaces'" }, @@ -14102,14 +14124,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 159, + "line": 158, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 159, + "line": 158, "kind": "event-listener", "match": "handle('git:push'" }, @@ -14133,14 +14155,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 165, + "line": 164, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 165, + "line": 164, "kind": "event-listener", "match": "handle('git:reveal-path'" }, @@ -14164,14 +14186,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 144, + "line": 143, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 144, + "line": 143, "kind": "event-listener", "match": "handle('git:stage'" }, @@ -14195,14 +14217,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 133, + "line": 132, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 133, + "line": 132, "kind": "event-listener", "match": "handle('git:status'" }, @@ -14226,14 +14248,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 175, + "line": 172, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 175, + "line": 172, "kind": "event-listener", "match": "handle('git:switch-branch'" }, @@ -14257,14 +14279,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 146, + "line": 145, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 146, + "line": 145, "kind": "event-listener", "match": "handle('git:unstage'" }, @@ -14861,7 +14883,7 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 542, + "line": 556, "kind": "custom-event-listener" }, { @@ -15053,7 +15075,7 @@ }, { "file": "modules/ui-system/side-pane/planDetailSideProvider.js", - "line": 542, + "line": 556, "kind": "custom-event-listener", "match": "el.addEventListener('input'" }, @@ -15512,7 +15534,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 97, + "line": 98, "kind": "custom-event-listener" }, { @@ -15875,7 +15897,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 97, + "line": 98, "kind": "custom-event-listener", "match": "doc.addEventListener('keydown'" }, @@ -21669,14 +21691,14 @@ "consumers": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 157, + "line": 184, "kind": "preload-subscription" } ], "evidence": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 157, + "line": 184, "kind": "preload-subscription", "match": "api.onTerminalClear(" } @@ -21707,14 +21729,14 @@ "consumers": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 154, + "line": 181, "kind": "preload-subscription" } ], "evidence": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 154, + "line": 181, "kind": "preload-subscription", "match": "api.onTerminalData(" } @@ -21726,14 +21748,14 @@ "consumers": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 160, + "line": 187, "kind": "preload-subscription" } ], "evidence": [ { "file": "modules/ui-system/side-pane/terminalSideProvider.js", - "line": 160, + "line": 187, "kind": "preload-subscription", "match": "api.onTerminalExit(" } @@ -23003,7 +23025,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 96, + "line": 97, "kind": "custom-event-listener" }, { @@ -23057,7 +23079,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 96, + "line": 97, "kind": "custom-event-listener", "match": "doc.addEventListener('pointerdown'" }, @@ -24974,7 +24996,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1063, + "line": 1064, "kind": "custom-event-listener" }, { @@ -25068,7 +25090,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1063, + "line": 1064, "kind": "custom-event-listener", "match": "win?.addEventListener?.('resize'" }, @@ -28064,7 +28086,7 @@ }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 550, + "line": 561, "kind": "custom-event-listener" }, { @@ -28124,7 +28146,7 @@ }, { "file": "modules/renderer/sideChatSurfaceOwner.js", - "line": 550, + "line": 561, "kind": "custom-event-listener", "match": "form.addEventListener('submit'" }, @@ -29214,7 +29236,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-visibility.js", - "line": 104, + "line": 122, "kind": "custom-event-listener" }, { @@ -29232,7 +29254,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-visibility.js", - "line": 104, + "line": 122, "kind": "custom-event-listener", "match": "root.addEventListener('transitionend'" }, @@ -32429,7 +32451,7 @@ }, { "file": "modules/ipc/gitHandlers.js", - "line": 97, + "line": 96, "reason": "dynamic event listener name: channel", "kind": "event-listener", "match": "ipcMain.handle(channel", diff --git a/modules/ui-system/side-pane/toolOutputSideProvider.js b/modules/ui-system/side-pane/toolOutputSideProvider.js index 8b4afe644..aa36601dd 100644 --- a/modules/ui-system/side-pane/toolOutputSideProvider.js +++ b/modules/ui-system/side-pane/toolOutputSideProvider.js @@ -13,6 +13,8 @@ import { formatRelativeTime } from './side-pane-tab-utils.js'; import { getCommandRunsSource } from '../sources/terminal-command-runs.js'; import { createSidePaneRootScope } from './side-pane-occurrence.js'; +// 运行中的命令持续出输出时,重读输出的最短间隔 +const RUNNING_RELOAD_MS = 1000; const TAB_ID = 'tool-output:main'; const FOLLOW_THRESHOLD_PX = 24; const TICK_MS = 1000; @@ -52,7 +54,8 @@ export function createToolOutputSideProvider({ sidePaneController = null, uiHelper = null, // 和状态面板共用的命令运行记录源 - commandRunsSource = getCommandRunsSource(api) + commandRunsSource = getCommandRunsSource(api), + runningReloadMs = RUNNING_RELOAD_MS } = {}) { const kind = 'tool-output'; const win = doc.defaultView || window; @@ -253,10 +256,22 @@ export function createToolOutputSideProvider({ if (!running && stopTicker) { stopTicker(); stopTicker = null; } }; + // 同一时间只有一个读取在路上;读的时候又有新输出,读完再补一次(对照 ZCode useBackgroundBashOutput 的 inFlight) + let loadInFlight = false; + let loadAgain = false; const loadSelected = async () => { if (!selectedId) { detail = null; renderAll(); return; } + if (loadInFlight) { loadAgain = true; return; } + loadInFlight = true; + loadAgain = false; const seq = ++loadSeq; - const res = await api?.terminalGetCommandRun?.(selectedId); + let res; + try { + res = await api?.terminalGetCommandRun?.(selectedId); + } finally { + loadInFlight = false; + } + if (loadAgain && !disposed()) { loadAgain = false; void loadSelected(); } if (disposed() || seq !== loadSeq) return; if (res?.success) { detail = res.data; @@ -326,8 +341,11 @@ export function createToolOutputSideProvider({ renderPicker(); const before = previous.find(run => run.id === selectedId); if (selectedSummary() !== before) { + // 命令还在刷屏时最多一秒读一次:每次读都要主进程把整段输出清洗一遍 + const streaming = before && selectedSummary()?.status === 'running' && before.status === 'running'; + if (streaming && cancelReload) return; cancelReload?.(); - cancelReload = own.timeout(() => { cancelReload = null; void loadSelected(); }, 60, 'reload-output'); + cancelReload = own.timeout(() => { cancelReload = null; void loadSelected(); }, streaming ? runningReloadMs : 60, 'reload-output'); } else { renderStatus(); } diff --git a/tests/side-pane-tool-output.test.mjs b/tests/side-pane-tool-output.test.mjs index 4ee389b36..be0f34181 100644 --- a/tests/side-pane-tool-output.test.mjs +++ b/tests/side-pane-tool-output.test.mjs @@ -14,7 +14,7 @@ test('formatRunDuration and status labels', () => { assert.equal(commandRunStatusLabel('timed_out'), '已超时'); }); -function makeEnv({ runs, details }) { +function makeEnv({ runs, details, runningReloadMs = 60 }) { const dom = new JSDOM('
', { pretendToBeVisual: true }); const doc = dom.window.document; const state = { runs, details, gets: [], opened: [], toasts: [], watch: 0, copied: [] }; @@ -36,7 +36,7 @@ function makeEnv({ runs, details }) { openTab: async (tab) => { state.opened.push(tab); return { focus() {} }; }, setVisible() {} }; - const provider = createToolOutputSideProvider({ document: doc, api, sidePaneController, uiHelper: { showToastNotification: (m) => state.toasts.push(m) } }); + const provider = createToolOutputSideProvider({ document: doc, api, sidePaneController, runningReloadMs, uiHelper: { showToastNotification: (m) => state.toasts.push(m) } }); return { dom, doc, provider, state, view: doc.getElementById('view'), fire: (s) => changed?.(s), wasUnsubscribed: () => unsubscribed }; } @@ -211,3 +211,20 @@ test('a hidden tab keeps only the latest update and catches up when shown again' assert.deepEqual(state.gets, ['r2', 'r3']); await handle.dispose(); }); + +test('a command that keeps printing is re-read at most once per interval, with one read in flight', async () => { + const { provider, state, view, fire } = makeEnv({ runs: RUNS, details: { r2: { ...RUNS[0], output: 'a\n', truncated: false } }, runningReloadMs: 800 }); + const handle = await provider.mountTab({ id: 'tool-output:main' }, view); + await wait(20); + const before = state.gets.length; + for (let i = 0; i < 6; i += 1) { + state.details.r2 = { ...RUNS[0], output: `line ${i}\n`, truncated: false }; + fire({ id: 'r2', command: 'npm test', status: 'running', startedAt: RUNS[0].startedAt, endedAt: null }); + await wait(100); + } + assert.equal(state.gets.length, before, 'no re-read inside the interval'); + await wait(400); + assert.equal(state.gets.length, before + 1, 'one re-read after the interval'); + assert.equal(view.querySelector('.side-tool-output-text').textContent, 'line 5\n', 'and it shows the latest output'); + await handle.dispose(); +}); From 31c042812fafb53a5dbd4ab46ae853d6934e0aaf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:02:38 +0000 Subject: [PATCH 034/143] UI system gate: use the accent token for the pinned window control Second half of 2200d128 from the integration PR: the pin rule arrived with the upstream #234 merge, so it now applies here. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SADzqzusVz3S3z7qBUSy8M --- styles/ui-system/components.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/styles/ui-system/components.css b/styles/ui-system/components.css index 418b7df8b..ddff52098 100644 --- a/styles/ui-system/components.css +++ b/styles/ui-system/components.css @@ -541,8 +541,8 @@ } :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned { - color: var(--vcp-ui-primary, #6366f1); - background: var(--vcp-ui-primary-bg, rgba(99, 102, 241, 0.16)); + color: var(--vcp-ui-accent); + background: color-mix(in srgb, var(--vcp-ui-accent) 16%, transparent); } :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned .vcp-ui-icon, From 5b943b8b4d3c52d68766dece4b933b5ddd4eb0fe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:04:21 +0000 Subject: [PATCH 035/143] Contracts: regenerate the chat event graph after merging the latest #3, #4 and #5 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UCGGgmHsCToooXTgSXpmq9 --- .../contracts/generated/chat-event-graph.json | 134 +++++++++--------- 1 file changed, 67 insertions(+), 67 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 58d41386e..041c1689c 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -1244,7 +1244,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 202, + "line": 204, "kind": "custom-event-listener" } ], @@ -1263,7 +1263,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 202, + "line": 204, "kind": "custom-event-listener", "match": "tabItem.addEventListener('auxclick'" } @@ -1695,7 +1695,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 132, + "line": 133, "kind": "custom-event-listener" }, { @@ -1801,7 +1801,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 132, + "line": 133, "kind": "custom-event-listener", "match": "win.addEventListener('blur'" }, @@ -4185,22 +4185,22 @@ }, { "file": "modules/ui-system/side-pane/code-viewer/editor.js", - "line": 54, + "line": 87, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/git/cards.js", - "line": 220, + "line": 229, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 114, + "line": 115, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/git/git-view.js", - "line": 217, + "line": 219, "kind": "custom-event-listener" }, { @@ -4395,12 +4395,12 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 179, + "line": 181, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 191, + "line": 193, "kind": "custom-event-listener" }, { @@ -5750,25 +5750,25 @@ }, { "file": "modules/ui-system/side-pane/code-viewer/editor.js", - "line": 54, + "line": 87, "kind": "custom-event-listener", "match": "read.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/git/cards.js", - "line": 220, + "line": 229, "kind": "custom-event-listener", "match": "row.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 114, + "line": 115, "kind": "custom-event-listener", "match": "btn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/git/git-view.js", - "line": 217, + "line": 219, "kind": "custom-event-listener", "match": "btn.addEventListener('click'" }, @@ -6002,13 +6002,13 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 179, + "line": 181, "kind": "custom-event-listener", "match": "btn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 191, + "line": 193, "kind": "custom-event-listener", "match": "closeBtn.addEventListener('click'" }, @@ -7315,12 +7315,12 @@ }, { "file": "modules/ui-system/side-pane/git/cards.js", - "line": 238, + "line": 247, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 214, + "line": 216, "kind": "custom-event-listener" }, { @@ -7452,13 +7452,13 @@ }, { "file": "modules/ui-system/side-pane/git/cards.js", - "line": 238, + "line": 247, "kind": "custom-event-listener", "match": "row.addEventListener('contextmenu'" }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 214, + "line": 216, "kind": "custom-event-listener", "match": "tabItem.addEventListener('contextmenu'" }, @@ -11780,7 +11780,7 @@ }, { "file": "modules/services/gitWatcher.js", - "line": 62, + "line": 67, "kind": "event-listener" }, { @@ -12106,7 +12106,7 @@ }, { "file": "modules/services/gitWatcher.js", - "line": 62, + "line": 67, "kind": "event-listener", "match": "watcher.on?.('error'" }, @@ -13830,14 +13830,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 191, + "line": 188, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 191, + "line": 188, "kind": "event-listener", "match": "handle('git:change-summary'" }, @@ -13880,14 +13880,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 154, + "line": 153, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 154, + "line": 153, "kind": "event-listener", "match": "handle('git:commit'" }, @@ -13911,14 +13911,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 186, + "line": 183, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 186, + "line": 183, "kind": "event-listener", "match": "handle('git:commit-graph'" }, @@ -13942,14 +13942,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 180, + "line": 177, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 180, + "line": 177, "kind": "event-listener", "match": "handle('git:create-branch'" }, @@ -13973,14 +13973,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 135, + "line": 134, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 135, + "line": 134, "kind": "event-listener", "match": "handle('git:diff'" }, @@ -14004,14 +14004,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 148, + "line": 147, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 148, + "line": 147, "kind": "event-listener", "match": "handle('git:discard'" }, @@ -14035,14 +14035,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 173, + "line": 170, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 173, + "line": 170, "kind": "event-listener", "match": "handle('git:list-branches'" }, @@ -14066,14 +14066,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 128, + "line": 127, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 128, + "line": 127, "kind": "event-listener", "match": "handle('git:list-workspaces'" }, @@ -14097,14 +14097,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 159, + "line": 158, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 159, + "line": 158, "kind": "event-listener", "match": "handle('git:push'" }, @@ -14128,14 +14128,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 165, + "line": 164, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 165, + "line": 164, "kind": "event-listener", "match": "handle('git:reveal-path'" }, @@ -14159,14 +14159,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 144, + "line": 143, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 144, + "line": 143, "kind": "event-listener", "match": "handle('git:stage'" }, @@ -14190,14 +14190,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 133, + "line": 132, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 133, + "line": 132, "kind": "event-listener", "match": "handle('git:status'" }, @@ -14221,14 +14221,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 175, + "line": 172, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 175, + "line": 172, "kind": "event-listener", "match": "handle('git:switch-branch'" }, @@ -14252,14 +14252,14 @@ "consumers": [ { "file": "modules/ipc/gitHandlers.js", - "line": 146, + "line": 145, "kind": "event-listener" } ], "evidence": [ { "file": "modules/ipc/gitHandlers.js", - "line": 146, + "line": 145, "kind": "event-listener", "match": "handle('git:unstage'" }, @@ -15507,7 +15507,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 131, + "line": 132, "kind": "custom-event-listener" }, { @@ -15577,7 +15577,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 303, + "line": 305, "kind": "custom-event-listener" }, { @@ -15870,7 +15870,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 131, + "line": 132, "kind": "custom-event-listener", "match": "doc.addEventListener('keydown'" }, @@ -15954,7 +15954,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 303, + "line": 305, "kind": "custom-event-listener", "match": "tabListElement.addEventListener('keydown'" }, @@ -18817,7 +18817,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 199, + "line": 201, "kind": "custom-event-listener" }, { @@ -18941,7 +18941,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 199, + "line": 201, "kind": "custom-event-listener", "match": "tabItem.addEventListener('mousedown'" }, @@ -19032,7 +19032,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 210, + "line": 212, "kind": "custom-event-listener" }, { @@ -19074,7 +19074,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 210, + "line": 212, "kind": "custom-event-listener", "match": "tabItem.addEventListener('mouseenter'" }, @@ -19147,7 +19147,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 211, + "line": 213, "kind": "custom-event-listener" }, { @@ -19230,7 +19230,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 211, + "line": 213, "kind": "custom-event-listener", "match": "tabItem.addEventListener('mouseleave'" }, @@ -22998,7 +22998,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 130, + "line": 131, "kind": "custom-event-listener" }, { @@ -23023,7 +23023,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 212, + "line": 214, "kind": "custom-event-listener" }, { @@ -23052,7 +23052,7 @@ }, { "file": "modules/ui-system/side-pane/git/context-menu.js", - "line": 130, + "line": 131, "kind": "custom-event-listener", "match": "doc.addEventListener('pointerdown'" }, @@ -23082,7 +23082,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 212, + "line": 214, "kind": "custom-event-listener", "match": "tabItem.addEventListener('pointerdown'" }, @@ -26462,7 +26462,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 260, + "line": 262, "kind": "custom-event-listener" }, { @@ -26526,7 +26526,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 260, + "line": 262, "kind": "custom-event-listener", "match": "tabListElement.addEventListener('scroll'" }, @@ -32485,7 +32485,7 @@ }, { "file": "modules/ipc/gitHandlers.js", - "line": 97, + "line": 96, "reason": "dynamic event listener name: channel", "kind": "event-listener", "match": "ipcMain.handle(channel", From 0aa02957f217856ddc4ef4e1d56f5715aaa5e77b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:10:54 +0000 Subject: [PATCH 036/143] Tool card styles: pass the UI system gate the base branch fails Every selector now sits under :where(.vcp-ui-scope) (zero specificity, so cascade order is unchanged; chat messages and the side pane are already inside a scope). The reduced-motion rule repeats the animated selector instead of using !important, and the 11px labels derive from the sm token. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- styles/ui-system/tool-presentation.css | 196 ++++++++++++------------- 1 file changed, 98 insertions(+), 98 deletions(-) diff --git a/styles/ui-system/tool-presentation.css b/styles/ui-system/tool-presentation.css index 436a2e39c..38f0ec98a 100644 --- a/styles/ui-system/tool-presentation.css +++ b/styles/ui-system/tool-presentation.css @@ -1,12 +1,12 @@ /* Optional presentation; Legacy is untouched. Theme and typography stay owned * by the existing appearance profile, including role wallpapers. */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="compact"], -html[data-ui-mode="next"] [data-vcp-tool-presentation="grouped"] { +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="compact"], +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="grouped"] { --vcp-tool-row-height: 38px; } -html[data-ui-mode="next"][data-vcp-density="compact"] [data-vcp-tool-presentation] { --vcp-tool-row-height: 32px; } -html[data-ui-mode="next"][data-vcp-density="relaxed"] [data-vcp-tool-presentation] { --vcp-tool-row-height: 44px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented { +html[data-ui-mode="next"][data-vcp-density="compact"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] { --vcp-tool-row-height: 32px; } +html[data-ui-mode="next"][data-vcp-density="relaxed"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] { --vcp-tool-row-height: 44px; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented { --vcp-tool-presented-font-family: var(--vcp-tool-card-font-family, var(--vcp-chat-font-family)); --vcp-tool-presented-font-size: var(--vcp-ui-text-caption, 13px); --vcp-tool-presented-white-space: normal; @@ -32,11 +32,11 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented { color: var(--vcp-ui-text-primary); font-family: var(--vcp-tool-card-font-family, inherit); } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented::before, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented::after { display: none; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-summary, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-header, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-header { +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented::before, +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented::after { display: none; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-summary, +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-header, +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-header { display: flex; gap: 5px; padding: 0; @@ -47,7 +47,7 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vc min-width: 0; flex-wrap: nowrap; } -html[data-ui-mode="next"] .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-tool-process-toggle { +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-toggle, html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-toggle { display: flex; width: 100%; min-width: 0; @@ -65,13 +65,13 @@ html[data-ui-mode="next"] .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-t cursor: pointer; text-align: left; } -html[data-ui-mode="next"] .vcp-tool-result-header > .vcp-tool-row-toggle { +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-result-header > .vcp-tool-row-toggle { width: auto; flex: 1; } -html[data-ui-mode="next"] .vcp-tool-row-toggle:hover, html[data-ui-mode="next"] .vcp-tool-process-toggle:hover { background: var(--vcp-ui-bg-2); } -html[data-ui-mode="next"] .vcp-tool-row-toggle:focus-visible, html[data-ui-mode="next"] .vcp-tool-process-toggle:focus-visible, html[data-ui-mode="next"] .vcp-tool-process-artifact:focus-visible { outline: 2px solid var(--vcp-ui-accent); outline-offset: -2px; } -html[data-ui-mode="next"] .vcp-tool-row-chevron { +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-toggle:hover, html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-toggle:hover { background: var(--vcp-ui-bg-2); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-toggle:focus-visible, html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-toggle:focus-visible, html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-artifact:focus-visible { outline: 2px solid var(--vcp-ui-accent); outline-offset: -2px; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-chevron { width: 7px; height: 7px; flex: 0 0 7px; @@ -79,44 +79,44 @@ html[data-ui-mode="next"] .vcp-tool-row-chevron { border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); } -html[data-ui-mode="next"] [aria-expanded="true"] > .vcp-tool-row-chevron { transform: rotate(45deg); } -html[data-ui-mode="next"] .vcp-tool-row-title { overflow-wrap: anywhere; min-width: 0; color: var(--vcp-ui-text-primary); } -html[data-ui-mode="next"] .vcp-tool-row-resource { overflow-wrap: anywhere; min-width: 0; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] .vcp-tool-row-state { margin-left: auto; white-space: nowrap; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] [data-vcp-tool-state="success"] .vcp-tool-row-state { color: var(--vcp-ui-success); } -html[data-ui-mode="next"] [data-vcp-tool-state="failed"] .vcp-tool-row-state { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-state="waiting"] .vcp-tool-row-state, -html[data-ui-mode="next"] [data-vcp-tool-state="stopped"] .vcp-tool-row-state { color: var(--vcp-ui-warning, var(--vcp-ui-palette-amber)); } -html[data-ui-mode="next"] [data-vcp-tool-state="running"] .vcp-tool-row-state { color: var(--vcp-ui-accent); } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-delete-btn { flex: 0 0 24px; margin-right: 6px; opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:hover .vcp-tool-result-delete-btn, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:focus-within .vcp-tool-result-delete-btn { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="compact"] .vcp-role-divider, -html[data-ui-mode="next"] [data-vcp-tool-presentation="grouped"] .vcp-role-divider { margin: 4px 0; font-size: 11px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-details { max-height: 360px; overflow: auto; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-content { +html[data-ui-mode="next"] :where(.vcp-ui-scope) [aria-expanded="true"] > .vcp-tool-row-chevron { transform: rotate(45deg); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-title { overflow-wrap: anywhere; min-width: 0; color: var(--vcp-ui-text-primary); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-resource { overflow-wrap: anywhere; min-width: 0; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-state { margin-left: auto; white-space: nowrap; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-state="success"] .vcp-tool-row-state { color: var(--vcp-ui-success); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-state="failed"] .vcp-tool-row-state { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-state="waiting"] .vcp-tool-row-state, +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-state="stopped"] .vcp-tool-row-state { color: var(--vcp-ui-warning, var(--vcp-ui-palette-amber)); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-state="running"] .vcp-tool-row-state { color: var(--vcp-ui-accent); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-delete-btn { flex: 0 0 24px; margin-right: 6px; opacity: 0; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:hover .vcp-tool-result-delete-btn, +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:focus-within .vcp-tool-result-delete-btn { opacity: 1; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="compact"] .vcp-role-divider, +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="grouped"] .vcp-role-divider { margin: 4px 0; font-size: calc(var(--vcp-ui-text-sm, 12px) - 1px); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-details { max-height: 360px; overflow: auto; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-details, +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-content { margin: 0; padding: 6px 12px 12px 28px; background: transparent; border: 0; min-width: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-call-summary-content { display: none; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-details, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-call-summary-content { display: block; } -html[data-ui-mode="next"] [data-vcp-tool-presentation] .vcp-tool-presented pre { +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-details, +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-call-summary-content { display: none; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-details, +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-call-summary-content { display: block; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented pre { white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-height: 320px; overflow: auto; } -html[data-ui-mode="next"] .vcp-tool-process { +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process { border: 1px solid var(--vcp-ui-border); border-radius: var(--vcp-ui-radius-md, 8px); margin: 10px 0; @@ -124,12 +124,12 @@ html[data-ui-mode="next"] .vcp-tool-process { overflow: hidden; background: var(--vcp-ui-bg-0); } -html[data-ui-mode="next"] .vcp-tool-process-toggle { background: var(--vcp-ui-bg-1); border-radius: 0; } -html[data-ui-mode="next"] .vcp-tool-process-title { min-width: 0; overflow-wrap: anywhere; } -html[data-ui-mode="next"] .vcp-tool-process-stats { margin-left: auto; font-size: var(--vcp-ui-text-sm, 12px); overflow-wrap: anywhere; } -html[data-ui-mode="next"] .vcp-tool-process-body[hidden] { display: none; } -html[data-ui-mode="next"] .vcp-tool-process-body > .vcp-tool-presented { margin: 0; border-top: 1px solid var(--vcp-ui-border); } -html[data-ui-mode="next"] .vcp-tool-process-artifact { +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-toggle { background: var(--vcp-ui-bg-1); border-radius: 0; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-title { min-width: 0; overflow-wrap: anywhere; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-stats { margin-left: auto; font-size: var(--vcp-ui-text-sm, 12px); overflow-wrap: anywhere; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-body[hidden] { display: none; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-body > .vcp-tool-presented { margin: 0; border-top: 1px solid var(--vcp-ui-border); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-artifact { color: var(--vcp-ui-text-accent); background: transparent; border: 0; @@ -142,20 +142,20 @@ html[data-ui-mode="next"] .vcp-tool-process-artifact { cursor: pointer; } @media (max-width: 640px) { - html[data-ui-mode="next"] .vcp-tool-row-toggle { flex-wrap: wrap; gap: 6px; } - html[data-ui-mode="next"] .vcp-tool-row-state { margin-left: auto; } - html[data-ui-mode="next"] .vcp-tool-process-toggle { flex-wrap: wrap; } - html[data-ui-mode="next"] .vcp-tool-process-stats { margin-left: 16px; } + html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-toggle { flex-wrap: wrap; gap: 6px; } + html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-state { margin-left: auto; } + html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-toggle { flex-wrap: wrap; } + html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-stats { margin-left: 16px; } } /* 单行合并 / 整轮折叠:无框单行,一次调用一行;紧跟的同名结果收进请求行,展开后才显示。 */ -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) { +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) { --vcp-tool-call-color: var(--vcp-ui-text-3, var(--vcp-ui-text-secondary)); --vcp-tool-call-hover: var(--vcp-ui-text-2, var(--vcp-ui-text-secondary)); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-role-divider { margin: 4px 0; font-size: 11px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented { margin: 2px 0; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-role-divider { margin: 4px 0; font-size: calc(var(--vcp-ui-text-sm, 12px) - 1px); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented { margin: 2px 0; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { width: auto; max-width: 100%; min-height: 24px; @@ -168,10 +168,10 @@ html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-t color: var(--vcp-tool-call-color); transition: color .1s; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):hover { background: transparent; color: var(--vcp-tool-call-hover); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { flex: none; color: inherit; white-space: nowrap; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-process-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-resource { +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):hover { background: transparent; color: var(--vcp-tool-call-hover); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { flex: none; color: inherit; white-space: nowrap; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-process-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-resource { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -179,20 +179,20 @@ html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-t overflow-wrap: normal; font-size: inherit; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-state { flex: none; margin-left: 0; color: inherit; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon { display: inline-flex; flex: none; width: 16px; height: 16px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon svg { width: 100%; height: 100%; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-diff { flex: none; font-family: var(--vcp-ui-font-mono, ui-monospace, Consolas, monospace); font-size: 11px; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-add { color: var(--vcp-ui-success); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-del { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-state { flex: none; margin-left: 0; color: inherit; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon { display: inline-flex; flex: none; width: 16px; height: 16px; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon svg { width: 100%; height: 100%; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-diff { flex: none; font-family: var(--vcp-ui-font-mono, ui-monospace, Consolas, monospace); font-size: calc(var(--vcp-ui-text-sm, 12px) - 1px); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-add { color: var(--vcp-ui-success); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-del { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } /* 并入请求行的结果:收起时不占位,展开后只留一个小标题“输出”。 - * 旧层给结果气泡写了 display:block !important,这里只能藏标题、去掉外边距,正文由上面的收起规则隐藏。 */ -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) { margin: 0; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) > .vcp-tool-result-header { display: none; } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"] .vcp-tool-row-toggle { min-height: 20px; padding-left: 24px; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented > :is(.vcp-tool-details, .vcp-tool-result-collapsible-content, .vcp-tool-call-summary-content) { padding: 8px 0 4px 24px; } + * 旧层给结果气泡写了 display:block 并加了强制优先级,这里只能藏标题、去掉外边距,正文由上面的收起规则隐藏。 */ +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) { margin: 0; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) > .vcp-tool-result-header { display: none; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"] .vcp-tool-row-toggle { min-height: 20px; padding-left: 24px; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented > :is(.vcp-tool-details, .vcp-tool-result-collapsible-content, .vcp-tool-call-summary-content) { padding: 8px 0 4px 24px; } /* 等待结果时动作词的微光,只在回复生成中出现。 */ -html[data-ui-mode="next"] .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { +html[data-ui-mode="next"] :where(.vcp-ui-scope) .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { background: linear-gradient(90deg, var(--vcp-tool-call-color) 0%, var(--vcp-ui-text-primary) 50%, var(--vcp-tool-call-color) 100%); background-size: 200% 100%; -webkit-background-clip: text; @@ -202,33 +202,33 @@ html[data-ui-mode="next"] .message-item.streaming :is([data-vcp-tool-presentatio } @keyframes vcp-tool-call-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } } @media (prefers-reduced-motion: reduce) { - html[data-ui-mode="next"] [data-vcp-tool-pending="true"] .vcp-tool-row-title { animation: none !important; } + html[data-ui-mode="next"] :where(.vcp-ui-scope) .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { animation: none; } } /* 单行合并:动作词加粗,箭头在行尾、悬停或展开时出现;问题状态用虚线下划线,悬停看报错。 */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { font-size: var(--vcp-ui-text-body, 14px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { font-weight: 500; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) > .vcp-tool-row-chevron { order: 1; opacity: 0; transition: opacity .2s, transform .2s; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):is(:hover, [aria-expanded="true"]) > .vcp-tool-row-chevron { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] [data-vcp-tool-call-state] .vcp-tool-row-state { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] { margin: 2px 0; border: 0; border-radius: 0; background: transparent; overflow: visible; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] .vcp-tool-process-stats { margin-left: 0; font-size: inherit; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body { margin: 4px 0 4px 8px; padding-left: 14px; border-left: 1px solid var(--vcp-ui-border); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-artifact { border-top: 0; padding: 4px 0 4px 22px; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { font-size: var(--vcp-ui-text-body, 14px); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { font-weight: 500; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) > .vcp-tool-row-chevron { order: 1; opacity: 0; transition: opacity .2s, transform .2s; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):is(:hover, [aria-expanded="true"]) > .vcp-tool-row-chevron { opacity: 1; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] [data-vcp-tool-call-state] .vcp-tool-row-state { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] { margin: 2px 0; border: 0; border-radius: 0; background: transparent; overflow: visible; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] .vcp-tool-process-stats { margin-left: 0; font-size: inherit; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body { margin: 4px 0 4px 8px; padding-left: 14px; border-left: 1px solid var(--vcp-ui-border); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-artifact { border-top: 0; padding: 4px 0 4px 22px; } /* 整轮折叠:图标悬停换成箭头,标题与摘要之间用小圆点分隔,失败时摘要显示红色报错首行。 */ -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle { gap: 0; font-size: var(--vcp-ui-text-caption, 13px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading { position: relative; display: inline-flex; flex: none; width: 16px; height: 16px; margin-right: 6px; align-items: center; justify-content: center; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-icon { width: 14px; height: 14px; transition: opacity .1s; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-chevron { position: absolute; inset: 0; margin: auto; opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-icon { opacity: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-chevron { opacity: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-dot { flex: none; width: 2px; height: 2px; margin: 0 8px; border-radius: 1px; background: currentColor; opacity: .7; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle :is(.vcp-tool-row-state, .vcp-tool-row-diff) { margin-left: 10px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] [data-vcp-tool-call-state="failed"] .vcp-tool-row-toggle .vcp-tool-row-resource { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] { margin: 4px 0 10px; border: 0; border-radius: 0; background: transparent; overflow: visible; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle { +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle { gap: 0; font-size: var(--vcp-ui-text-caption, 13px); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-leading { position: relative; display: inline-flex; flex: none; width: 16px; height: 16px; margin-right: 6px; align-items: center; justify-content: center; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-icon { width: 14px; height: 14px; transition: opacity .1s; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-chevron { position: absolute; inset: 0; margin: auto; opacity: 0; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-icon { opacity: 0; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-chevron { opacity: 1; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-dot { flex: none; width: 2px; height: 2px; margin: 0 8px; border-radius: 1px; background: currentColor; opacity: .7; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle :is(.vcp-tool-row-state, .vcp-tool-row-diff) { margin-left: 10px; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] [data-vcp-tool-call-state="failed"] .vcp-tool-row-toggle .vcp-tool-row-resource { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] { margin: 4px 0 10px; border: 0; border-radius: 0; background: transparent; overflow: visible; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle { width: 100%; min-height: 33px; padding: 0 0 8px; @@ -236,8 +236,8 @@ html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-proce border-bottom: .5px solid var(--vcp-ui-border); font-size: var(--vcp-ui-text-body, 14px); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle > .vcp-tool-row-chevron { order: 1; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] .vcp-tool-process-stats { margin-left: 0; color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body { padding-top: 8px; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } -html[data-ui-mode="next"] [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-artifact { border-top: 0; padding: 6px 0; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle > .vcp-tool-row-chevron { order: 1; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] .vcp-tool-process-stats { margin-left: 0; color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body { padding-top: 8px; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } +html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-artifact { border-top: 0; padding: 6px 0; } From e20dbb456bffd93210156e6c7e68cd8276907d7a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:12:06 +0000 Subject: [PATCH 037/143] UI system gate: use the accent token for the pinned window button Finishes porting 2200d128 now that the base branch carries the pinned button rule with its hard-coded indigo fallback. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017bCbRB3E9yajQ3HbdMrjiJ --- styles/ui-system/components.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/styles/ui-system/components.css b/styles/ui-system/components.css index 418b7df8b..ddff52098 100644 --- a/styles/ui-system/components.css +++ b/styles/ui-system/components.css @@ -541,8 +541,8 @@ } :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned { - color: var(--vcp-ui-primary, #6366f1); - background: var(--vcp-ui-primary-bg, rgba(99, 102, 241, 0.16)); + color: var(--vcp-ui-accent); + background: color-mix(in srgb, var(--vcp-ui-accent) 16%, transparent); } :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned .vcp-ui-icon, From dcd3a737cee562e68efb2364b59d0c874677b211 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:12:06 +0000 Subject: [PATCH 038/143] Chat: open big assistants without waiting on side chat scans and height estimates Selecting an assistant with 1000 topics took about 0.4s longer than on main. Two causes: - side-chat:list-metadata ran requireChild (two ancestor lstats, an lstat, two realpaths and a marker read) on every topic directory before checking for side chat metadata. Checking the metadata file first, in parallel, takes the scan from ~650ms to ~25ms. - Every rendered message queued its own pretext idle callback. Forty of them ran ahead of the batched history insertion, each forcing a layout. One per-window idle queue now drains jobs within the idle deadline and re-queues behind waiting work. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017bCbRB3E9yajQ3HbdMrjiJ --- modules/ipc/sideChatHandlers.js | 13 ++++- modules/messageRenderer.js | 72 ++++++++++++++++--------- scripts/next-delta-shared-baseline.json | 4 +- 3 files changed, 59 insertions(+), 30 deletions(-) diff --git a/modules/ipc/sideChatHandlers.js b/modules/ipc/sideChatHandlers.js index 9725d01b2..5dd1ede3a 100644 --- a/modules/ipc/sideChatHandlers.js +++ b/modules/ipc/sideChatHandlers.js @@ -240,8 +240,17 @@ function initialize(paths) { const entries = await fs.readdir(topicsDir, { withFileTypes: true }); const items = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; + // 普通话题远多于侧聊;先用一次 stat 排除没有侧聊元数据的目录, + // 再做 requireChild 的多次 lstat/realpath/读标记校验。1000 个话题时 + // 这次扫描从约 650ms 降到约 25ms。 + const candidates = await Promise.all(entries.map(async entry => { + if (!entry.isDirectory()) return null; + const entryDir = path.join(topicsDir, entry.name); + return await fs.pathExists(path.join(entryDir, 'sidechat-metadata.json')) ? entry : null; + })); + + for (const entry of candidates) { + if (!entry) continue; const entryDir = path.join(topicsDir, entry.name); if (!await requireChild(entryDir, safeAgentId, entry.name, parentTopicId)) continue; const metadataPath = path.join(entryDir, 'sidechat-metadata.json'); diff --git a/modules/messageRenderer.js b/modules/messageRenderer.js index 15c40c4f9..9182737ac 100644 --- a/modules/messageRenderer.js +++ b/modules/messageRenderer.js @@ -2650,16 +2650,7 @@ function cleanupMessageDomResources(messageItem, messageId = null) { clearTimeout(contentDiv._vcpDeferredHighlightTimer); delete contentDiv._vcpDeferredHighlightTimer; } - if (contentDiv._vcpPretextIdleHandle) { - const { kind, id } = contentDiv._vcpPretextIdleHandle; - const ownerWindow = contentDiv.ownerDocument?.defaultView; - if (kind === 'idle' && typeof ownerWindow?.cancelIdleCallback === 'function') { - ownerWindow.cancelIdleCallback(id); - } else if (kind === 'timer') { - clearTimeout(id); - } - delete contentDiv._vcpPretextIdleHandle; - } + cancelPretextEstimate(contentDiv); cleanupMermaidViewers(contentDiv); contentProcessor.cleanupPreviewsInContent(contentDiv); cleanupAnimationsInContent(contentDiv); @@ -4273,24 +4264,53 @@ function scheduleMessagePretextEstimate(messageId, text, contentDiv) { } }; - if (contentDiv?._vcpPretextIdleHandle) { - const previous = contentDiv._vcpPretextIdleHandle; - const ownerWindow = contentDiv.ownerDocument?.defaultView; - if (previous.kind === 'idle' && typeof ownerWindow?.cancelIdleCallback === 'function') ownerWindow.cancelIdleCallback(previous.id); - else if (previous.kind === 'timer') clearTimeout(previous.id); - } - const wrappedRun = () => { - if (contentDiv) delete contentDiv._vcpPretextIdleHandle; - run(); - }; const ownerWindow = contentDiv?.ownerDocument?.defaultView; - if (typeof ownerWindow?.requestIdleCallback === 'function') { - const id = ownerWindow.requestIdleCallback(wrappedRun, { timeout: 300 }); - if (contentDiv) contentDiv._vcpPretextIdleHandle = { kind: 'idle', id }; - } else { - const id = ownerWindow?.setTimeout?.(wrappedRun, 0) || setTimeout(wrappedRun, 0); - if (contentDiv) contentDiv._vcpPretextIdleHandle = { kind: 'timer', id }; + enqueuePretextEstimate(ownerWindow, contentDiv || messageId, run); +} + +/* + * 每条消息各占一个 requestIdleCallback 时,40 条历史就是 40 个排在前面的回调, + * 历史分批插入的 idle 回调只能等它们逐个跑完(每个还要读一次 clientWidth 触发布局), + * 大话题打开因此慢约 0.4s。这里每个窗口只挂一个 idle 回调,按 deadline 分片消费队列, + * 没做完就重新排队,让已经排着的分批插入先执行。同一节点重复渲染时覆盖旧任务。 + */ +const pretextEstimateQueues = new WeakMap(); + +function getPretextQueueOwner(ownerWindow) { + return typeof ownerWindow?.requestIdleCallback === 'function' ? ownerWindow : globalThis; +} + +function enqueuePretextEstimate(ownerWindow, key, run) { + const queueOwner = getPretextQueueOwner(ownerWindow); + let state = pretextEstimateQueues.get(queueOwner); + if (!state) { + state = { jobs: new Map(), scheduled: false }; + pretextEstimateQueues.set(queueOwner, state); } + state.jobs.delete(key); + state.jobs.set(key, run); + if (state.scheduled) return; + + const schedule = () => { + state.scheduled = true; + if (queueOwner !== globalThis) queueOwner.requestIdleCallback(drain, { timeout: 300 }); + else setTimeout(drain, 0); + }; + const drain = (deadline) => { + state.scheduled = false; + for (const [jobKey, job] of state.jobs) { + state.jobs.delete(jobKey); + job(); + if (!deadline || deadline.didTimeout || deadline.timeRemaining() <= 1) break; + } + if (state.jobs.size > 0) schedule(); + }; + schedule(); +} + +function cancelPretextEstimate(contentDiv) { + const queueOwner = getPretextQueueOwner(contentDiv?.ownerDocument?.defaultView); + pretextEstimateQueues.get(queueOwner)?.jobs.delete(contentDiv); } async function renderFullMessageProjection(messageId, fullContent, agentName, agentId, root = mainRendererReferences.chatMessagesDiv) { diff --git a/scripts/next-delta-shared-baseline.json b/scripts/next-delta-shared-baseline.json index 251a462fd..6c64855eb 100644 --- a/scripts/next-delta-shared-baseline.json +++ b/scripts/next-delta-shared-baseline.json @@ -4,8 +4,8 @@ "reason": "Integrated chat behavior: selection subscriptions for topic side panes; clear old media before navigation; per-conversation side chat context/unread policy; explicit cancellation and non-streaming terminal settlement; one error notice per failed request. Reviewed auxiliary-side-chat reproduction: Stop during delayed history save or request preparation previously still sent generation; renderer-local AbortSignal checks now prevent that send. When upstream interrupt fails, await the existing local stream terminal before placeholder cleanup so accepted partial content is saved instead of deleted. Actual native side owner, internal renderer, manager, preload/IPC/disk and local HTTP before/after evidence; side preparation and partial-cancel regressions. Existing Surface routing and history authority remain in place. Main/group sending without the optional signal keeps its preparation path. Stop while awaiting the first HTTP headers: a locally aborted stream IPC return now awaits the already-owned cancellation terminal instead of showing a service-error notice; the regression keeps the submitted question and leaves the busy state." }, "modules/messageRenderer.js": { - "sha256": "7ebdd1b28c5af25ec0c1907e17037f873ed3d80eb9ae87d369dcc291b6c116ac", - "reason": "Integrated renderer behavior: nested tool result data isolation; lazy tool payload DOM; safe approval diagnostics; upstream tool-result hide/restore toggle, still persisted through historyMutationAuthority; upstream scanned-PDF page labels on attachments; owned media cleanup; cross-agent avatar projection; message-local pretext estimates. This records the integrated source boundary, not a guarantee of all data-write scenarios. Tool presentation: the renderer owns presenter teardown, reads appearance from its owner realm, captures disclosure before HTML replacement, and applies presentation after a valid render without changing protocol or persistence." + "sha256": "f24426207cf95eb4fa3f42e2ee357ed906d0a56ac6b235dd9d996fc39966b6e5", + "reason": "Integrated renderer behavior: nested tool result data isolation; lazy tool payload DOM; safe approval diagnostics; upstream tool-result hide/restore toggle, still persisted through historyMutationAuthority; upstream scanned-PDF page labels on attachments; owned media cleanup; cross-agent avatar projection; message-local pretext estimates drained from one per-window idle queue so they never starve batched history insertion. This records the integrated source boundary, not a guarantee of all data-write scenarios. Tool presentation: the renderer owns presenter teardown, reads appearance from its owner realm, captures disclosure before HTML replacement, and applies presentation after a valid render without changing protocol or persistence." }, "modules/renderer/streamManager.js": { "sha256": "f4cb0dfc44a7e870725fad479a718d010ec10238fadedd7808ce9bd958385b13", From 295d9570174b2abccfb3f6abf4daa3edf899cdbd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:16:17 +0000 Subject: [PATCH 039/143] Browser tab: a page playing video, even muted, is not put to sleep isBusy only knew audible pages, so a muted video or camera preview was evicted at the live view limit and reloaded from the start when shown. media-started-playing / media-paused now count too (ZCode protects media playback the same way); navigation or a crashed page clears it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- docs/contracts/chat-contracts.json | 2 +- .../side-pane/browserSideProvider.js | 12 ++++++-- tests/side-browser-address.test.mjs | 30 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/docs/contracts/chat-contracts.json b/docs/contracts/chat-contracts.json index d8ee3e1f8..5587488cb 100644 --- a/docs/contracts/chat-contracts.json +++ b/docs/contracts/chat-contracts.json @@ -1123,7 +1123,7 @@ "dynamicSites": [ { "file": "modules/ui-system/side-pane/browserSideProvider.js", - "line": 279, + "line": 281, "kind": "custom-event-listener", "match": "guest.addEventListener(name" } diff --git a/modules/ui-system/side-pane/browserSideProvider.js b/modules/ui-system/side-pane/browserSideProvider.js index 92102dc76..4e955c7ee 100644 --- a/modules/ui-system/side-pane/browserSideProvider.js +++ b/modules/ui-system/side-pane/browserSideProvider.js @@ -147,6 +147,8 @@ export function createBrowserSideProvider({ let domReady = false; let currentUrl = ''; let loading = false; + // 静音的视频、摄像头预览也算在播:isCurrentlyAudible 只认出声的 + let mediaPlaying = false; let lastFailure = null; let pendingUrl = ''; @@ -302,7 +304,11 @@ export function createBrowserSideProvider({ } syncControls(); }); + on('media-started-playing', () => { mediaPlaying = true; }); + on('media-paused', () => { mediaPlaying = false; }); on('did-navigate', (event) => { + // 换了文档,旧页面的播放不会再发暂停 + mediaPlaying = false; setAddress(event.url); syncControls(); }); @@ -329,6 +335,7 @@ export function createBrowserSideProvider({ }); on('render-process-gone', (event) => { loading = false; + mediaPlaying = false; lastFailure = { crashed: true }; showNotice({ title: '页面已停止响应', @@ -351,6 +358,7 @@ export function createBrowserSideProvider({ webview = null; domReady = false; loading = false; + mediaPlaying = false; old.remove(); } hideNotice(); @@ -492,9 +500,9 @@ export function createBrowserSideProvider({ if (result?.url) navigate(result.url); return result; }, - // 还在加载或者在放声音的页面不休眠,休眠了再显示会从当前地址重新打开 + // 还在加载、在放声音或在放视频的页面不休眠,休眠了再显示会从当前地址重新打开 isBusy() { - if (loading) return true; + if (loading || mediaPlaying) return true; try { return domReady && webview?.isCurrentlyAudible?.() === true; } catch (_error) { diff --git a/tests/side-browser-address.test.mjs b/tests/side-browser-address.test.mjs index e0ea320d9..e774b8014 100644 --- a/tests/side-browser-address.test.mjs +++ b/tests/side-browser-address.test.mjs @@ -184,3 +184,33 @@ for (const readyBeforeFailure of [false, true]) { } finally { await h.cleanup(); } }); } + +test('a page playing media stays busy even when muted, until it pauses or navigates away', async () => { + const { JSDOM } = await import('jsdom'); + const { createBrowserSideProvider } = await import('../modules/ui-system/side-pane/browserSideProvider.js'); + const dom = new JSDOM('
'); + const provider = createBrowserSideProvider({ + document: dom.window.document, + api: null, + sidePaneController: { updateTab() {} }, + notify: () => {} + }); + const view = dom.window.document.getElementById('view'); + const handle = await provider.mountTab({ id: 'browser:1', kind: 'browser', payload: { url: 'https://example.com/' } }, view); + const guest = view.querySelector('webview'); + assert.ok(guest); + guest.isCurrentlyAudible = () => false; + const fire = (name, extra = {}) => guest.dispatchEvent(Object.assign(new dom.window.Event(name), extra)); + + assert.equal(handle.isBusy(), false); + fire('media-started-playing'); + assert.equal(handle.isBusy(), true); + fire('media-paused'); + assert.equal(handle.isBusy(), false); + fire('media-started-playing'); + fire('did-navigate', { url: 'https://example.com/next' }); + assert.equal(handle.isBusy(), false); + + handle.dispose(); + dom.window.close(); +}); From 6014f754049c19314499344df0f0af049ecb9b9c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:16:17 +0000 Subject: [PATCH 040/143] Side chats: restoring them on a topic switch no longer forces the pane open restoreSessions used openTab for every child, which activated and expanded the pane, marked the topic as expanded in the saved preference, and mounted each chat. The controller now has restoreTabs: tabs go in without taking focus or mounting. If the topic had no tabs when the user switched to it (so the pane auto-collapsed), the switch is re-resolved as if they had been there: last shown tab, saved collapse state. ZCode mounts restored panels in the background without writing the preference. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- .../contracts/generated/chat-event-graph.json | 18 ++-- modules/renderer/sideChatWiring.js | 13 ++- .../side-pane/side-pane-controller.js | 51 +++++++++++ tests/side-pane-background-restore.test.mjs | 87 +++++++++++++++++++ tests/side-pane-wiring-modules.test.mjs | 18 ++-- 5 files changed, 161 insertions(+), 26 deletions(-) create mode 100644 tests/side-pane-background-restore.test.mjs diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 923abb305..6565107f0 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -4323,17 +4323,17 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1024, + "line": 1075, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1030, + "line": 1081, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1044, + "line": 1095, "kind": "custom-event-listener" }, { @@ -5904,19 +5904,19 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1024, + "line": 1075, "kind": "custom-event-listener", "match": "toggleNotificationsBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1030, + "line": 1081, "kind": "custom-event-listener", "match": "expandButton.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1044, + "line": 1095, "kind": "custom-event-listener", "match": "closeSidePaneBtn.addEventListener('click'" }, @@ -24996,7 +24996,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1064, + "line": 1115, "kind": "custom-event-listener" }, { @@ -25090,7 +25090,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1064, + "line": 1115, "kind": "custom-event-listener", "match": "win?.addEventListener?.('resize'" }, @@ -32827,7 +32827,7 @@ }, { "file": "modules/ui-system/side-pane/browserSideProvider.js", - "line": 279, + "line": 281, "reason": "dynamic listener name: name", "kind": "custom-event-listener", "match": "guest.addEventListener(name", diff --git a/modules/renderer/sideChatWiring.js b/modules/renderer/sideChatWiring.js index c22c8e189..b1224e2e9 100644 --- a/modules/renderer/sideChatWiring.js +++ b/modules/renderer/sideChatWiring.js @@ -229,14 +229,15 @@ export function createSideChatWiring({ if (!isSameParent(getController().getSnapshot().parent, agentId, parentTopicId)) return []; if (!listRes.ok || !Array.isArray(listRes.items)) return []; - // openTab 会激活恢复出来的侧聊,恢复完切回用户原来看的标签 - const activeBefore = getController().getSnapshot().activeTabId; + // 收齐了一次性在后台补回:不抢用户正在看的标签,不强行展开,也不改这个话题记下的收起状态 + const restored = []; for (const item of listRes.items) { try { if (!isSameParent(getController().getSnapshot().parent, agentId, parentTopicId)) break; if (item.open === false || item.status === 'closed') continue; const childTopicId = item.child?.topicId; if (getController().getSnapshot().tabs.some(t => t.descriptor?.child?.topicId === childTopicId)) continue; + if (restored.some(t => t.descriptor.child?.topicId === childTopicId)) continue; // 从未发过消息、也没有草稿和引用的空侧聊不再恢复,直接清理 const childAgentId = item.child.itemId || agentId; @@ -253,7 +254,7 @@ export function createSideChatWiring({ } } - await getController().openTab({ kind: 'chat', descriptor: { ...createSideChatDescriptor({ + restored.push({ kind: 'chat', descriptor: { ...createSideChatDescriptor({ parent: item.parent, childTopicId, title: item.title, @@ -270,10 +271,8 @@ export function createSideChatWiring({ console.warn('[SideChat] Failed to restore side chat tab:', e); } } - const snapshot = getController().getSnapshot(); - if (activeBefore && activeBefore !== snapshot.activeTabId && isSameParent(snapshot.parent, agentId, parentTopicId) - && snapshot.tabs.some(t => t.id === activeBefore)) { - getController().activateTab(activeBefore, { focus: false }); + if (restored.length > 0 && isSameParent(getController().getSnapshot().parent, agentId, parentTopicId)) { + await getController().restoreTabs(restored); } return listRes.items; } diff --git a/modules/ui-system/side-pane/side-pane-controller.js b/modules/ui-system/side-pane/side-pane-controller.js index 6aec0369e..035fb52b0 100644 --- a/modules/ui-system/side-pane/side-pane-controller.js +++ b/modules/ui-system/side-pane/side-pane-controller.js @@ -809,6 +809,57 @@ export function createSidePaneController({ return finishOpen(targetTabId, entry, origin); }, + /** + * 在后台补回标签(比如切到话题后才读回来的辅助对话):不抢激活、不挂载,也不改这个对话记下的收起状态。 + * 切到这个对话时它还一个标签都没有,面板按「没有标签」自动收起了;补回之后照切换时本该有的样子重新定一次: + * 回到上次停的标签、按记下的收起状态展开或收起。期间用户自己动过面板就不再改。 + * @param {Array} rawTabs + * @returns {Promise} 补进来的标签 id;其中有标签因此显示出来时,等它挂好 + */ + async restoreTabs(rawTabs = []) { + if (isDisposed) return []; + const ownTabsBefore = state.parent + ? state.tabs.filter(tab => SidePaneState.matchesConversation(SidePaneState.getTabParent(tab), state.parent)) + : []; + const untouchedFallback = state.parent && ownTabsBefore.length === 0 && !state.visible + && isNotificationsTab(state.activeTabId); + const added = []; + for (const rawTab of rawTabs) { + if (!rawTab) continue; + const definition = getTabType(rawTab.kind); + let resolved; + try { + resolved = definition?.toTab ? definition.toTab(rawTab, state.tabs) : rawTab; + } catch (error) { + console.error('[SidePaneController] Failed to restore tab:', error); + continue; + } + const next = SidePaneState.restoreTabs(state, [definition ? { + icon: definition.icon, typeLabel: definition.label, searchHint: definition.searchHint, + ...resolved + } : resolved]); + if (next === state) continue; + state = next; + added.push(String(resolved.id)); + } + if (added.length === 0) return added; + navigationRevision++; + if (untouchedFallback) { + const key = parentKeyOf(); + state = SidePaneState.setParent(state, state.parent, { + force: true, + preferredTabId: activeTabByParent.get(key), + collapsedPreference: collapsedByParent.get(key) + }); + } + renderTabList(); + syncViewPanels(); + syncDomVisibility(); + const mounting = pendingTabMounts.get(state.activeTabId); + if (mounting && added.includes(state.activeTabId)) await mounting.promise.catch(() => null); + return added; + }, + /** 改已打开标签的标题或 payload(关掉后重新打开时用新的 payload),不切换标签 */ updateTab(tabId, patch = {}) { if (isDisposed || !tabId) return; diff --git a/tests/side-pane-background-restore.test.mjs b/tests/side-pane-background-restore.test.mjs new file mode 100644 index 000000000..41d34fd23 --- /dev/null +++ b/tests/side-pane-background-restore.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { createSidePaneController } from '../modules/ui-system/side-pane/side-pane-controller.js'; +import { defineChatTabType } from '../modules/ui-system/side-pane/tab-types/chat.js'; + +// 切到话题后才读回来的辅助对话在后台补回:不抢激活、不强行展开、不改这个话题记下的收起状态 +const parentOf = topicId => ({ itemType: 'agent', itemId: 'agent', topicId }); +const sideChat = (id, topicId) => ({ kind: 'chat', descriptor: { + id, title: id, parent: parentOf(topicId), child: { itemType: 'agent', itemId: 'agent', topicId: `child-${id}` } +} }); + +function createController() { + const dom = new JSDOM('
'); + const doc = dom.window.document; + const mounted = []; + const ctrl = createSidePaneController({ + root: doc.getElementById('pane'), + tabListElement: doc.getElementById('tabs'), + contentContainer: doc.getElementById('content'), + tabTypes: [defineChatTabType({ + provider: { mountTab: async descriptor => { mounted.push(descriptor.id); return { dispose: async () => {}, focus() {} }; } } + })] + }); + return { ctrl, mounted, dispose: () => { ctrl.dispose?.(); dom.window.close(); } }; +} + +test('a topic the user collapsed stays collapsed when its side chats come back, and nothing mounts', async t => { + const { ctrl, mounted, dispose } = createController(); + t.after(dispose); + ctrl.setParent(parentOf('a')); + ctrl.setVisible(true); + ctrl.setVisible(false); + ctrl.setParent(parentOf('b')); + ctrl.setParent(parentOf('a')); + + const added = await ctrl.restoreTabs([sideChat('side-a', 'a')]); + + assert.deepEqual(added, ['side-a']); + const snapshot = ctrl.getSnapshot(); + assert.equal(snapshot.visible, false); + assert.ok(snapshot.tabs.some(tab => tab.id === 'side-a')); + assert.deepEqual(mounted, []); + + ctrl.setParent(parentOf('b')); + ctrl.setParent(parentOf('a')); + assert.equal(ctrl.getSnapshot().visible, false, 'the collapsed preference was not rewritten'); +}); + +test('a topic whose side chats arrive after the switch opens on the one it was last showing', async t => { + const { ctrl, mounted, dispose } = createController(); + t.after(dispose); + ctrl.setParent(parentOf('a')); + await ctrl.openTab(sideChat('first', 'a')); + await ctrl.openTab(sideChat('second', 'a')); + ctrl.activateTab('first'); + ctrl.setParent(parentOf('b')); + // 模拟重启后:话题 a 的辅助对话还没读回来 + await ctrl.closeTab('first', { force: true }); + await ctrl.closeTab('second', { force: true }); + mounted.length = 0; + ctrl.setParent(parentOf('a')); + assert.equal(ctrl.getSnapshot().visible, false); + + await ctrl.restoreTabs([sideChat('first', 'a'), sideChat('second', 'a')]); + + const snapshot = ctrl.getSnapshot(); + assert.equal(snapshot.visible, true); + assert.equal(snapshot.activeTabId, 'first'); + assert.deepEqual(mounted, ['first']); +}); + +test('side chats restored while the user is on another tab of the topic stay in the background', async t => { + const { ctrl, mounted, dispose } = createController(); + t.after(dispose); + ctrl.setParent(parentOf('a')); + await ctrl.openTab(sideChat('current', 'a')); + mounted.length = 0; + + await ctrl.restoreTabs([sideChat('older', 'a')]); + + const snapshot = ctrl.getSnapshot(); + assert.equal(snapshot.activeTabId, 'current'); + assert.equal(snapshot.visible, true); + assert.ok(snapshot.tabs.some(tab => tab.id === 'older')); + assert.deepEqual(mounted, []); +}); diff --git a/tests/side-pane-wiring-modules.test.mjs b/tests/side-pane-wiring-modules.test.mjs index a15b0db1a..f977d775b 100644 --- a/tests/side-pane-wiring-modules.test.mjs +++ b/tests/side-pane-wiring-modules.test.mjs @@ -18,17 +18,15 @@ test('restoring side chats in the background keeps the tab the user was looking const dom = new JSDOM(''); const parent = { itemType: 'agent', itemId: 'agent-1', topicId: 'topic-1' }; let state = { parent, tabs: [{ id: 'browser:1', kind: 'browser' }], activeTabId: 'browser:1' }; - const activations = []; + const restoredBatches = []; const controller = { getSnapshot: () => state, - async openTab(raw) { - const id = `chat:${raw.descriptor.child.topicId}`; - state = { ...state, tabs: [...state.tabs, { id, kind: 'chat', descriptor: raw.descriptor }], activeTabId: id }; - return null; - }, - activateTab(tabId, options) { - activations.push([tabId, options]); - state = { ...state, activeTabId: tabId }; + async openTab() { throw new Error('restoring must not open (activate and expand) tabs'); }, + async restoreTabs(raws) { + restoredBatches.push(raws.length); + const tabs = raws.map(raw => ({ id: `chat:${raw.descriptor.child.topicId}`, kind: 'chat', descriptor: raw.descriptor })); + state = { ...state, tabs: [...state.tabs, ...tabs] }; + return tabs.map(tab => tab.id); } }; const chatAPI = { @@ -45,7 +43,7 @@ test('restoring side chats in the background keeps the tab the user was looking await wiring.restoreSessions('agent-1', 'topic-1'); assert.ok(state.tabs.some(t => t.id === 'chat:child-1')); assert.equal(state.activeTabId, 'browser:1'); - assert.deepEqual(activations, [['browser:1', { focus: false }]]); + assert.deepEqual(restoredBatches, [1]); } finally { wiring.dispose?.(); dom.window.close(); From ea899c98ce8f4a92585b9d58ae8ba21b05bc438d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:18:46 +0000 Subject: [PATCH 041/143] Contracts: follow the message renderer listener site the pretext queue moved Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017bCbRB3E9yajQ3HbdMrjiJ --- docs/contracts/chat-contracts.json | 2 +- .../contracts/generated/chat-event-graph.json | 58 +++++++++---------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/docs/contracts/chat-contracts.json b/docs/contracts/chat-contracts.json index c6e520d4a..b6d3ea385 100644 --- a/docs/contracts/chat-contracts.json +++ b/docs/contracts/chat-contracts.json @@ -600,7 +600,7 @@ }, { "file": "modules/messageRenderer.js", - "line": 3328, + "line": 3319, "kind": "custom-event-listener", "match": "target.addEventListener(type" } diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 69770eda3..d5f7fbbfd 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -1244,7 +1244,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 200, + "line": 202, "kind": "custom-event-listener" } ], @@ -1263,7 +1263,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 200, + "line": 202, "kind": "custom-event-listener", "match": "tabItem.addEventListener('auxclick'" } @@ -3534,17 +3534,17 @@ }, { "file": "modules/messageRenderer.js", - "line": 3438, + "line": 3429, "kind": "custom-event-listener" }, { "file": "modules/messageRenderer.js", - "line": 3497, + "line": 3488, "kind": "custom-event-listener" }, { "file": "modules/messageRenderer.js", - "line": 3535, + "line": 3526, "kind": "custom-event-listener" }, { @@ -4374,12 +4374,12 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 177, + "line": 179, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 189, + "line": 191, "kind": "custom-event-listener" }, { @@ -4961,19 +4961,19 @@ }, { "file": "modules/messageRenderer.js", - "line": 3438, + "line": 3429, "kind": "custom-event-listener", "match": "attachmentElement.addEventListener('click'" }, { "file": "modules/messageRenderer.js", - "line": 3497, + "line": 3488, "kind": "custom-event-listener", "match": "attachmentElement.addEventListener('click'" }, { "file": "modules/messageRenderer.js", - "line": 3535, + "line": 3526, "kind": "custom-event-listener", "match": "removeBtn.addEventListener('click'" }, @@ -5969,13 +5969,13 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 177, + "line": 179, "kind": "custom-event-listener", "match": "btn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 189, + "line": 191, "kind": "custom-event-listener", "match": "closeBtn.addEventListener('click'" }, @@ -7215,7 +7215,7 @@ }, { "file": "modules/messageRenderer.js", - "line": 3439, + "line": 3430, "kind": "custom-event-listener" }, { @@ -7265,7 +7265,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 212, + "line": 214, "kind": "custom-event-listener" }, { @@ -7343,7 +7343,7 @@ }, { "file": "modules/messageRenderer.js", - "line": 3439, + "line": 3430, "kind": "custom-event-listener", "match": "attachmentElement.addEventListener('contextmenu'" }, @@ -7403,7 +7403,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 212, + "line": 214, "kind": "custom-event-listener", "match": "tabItem.addEventListener('contextmenu'" }, @@ -15522,7 +15522,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 285, + "line": 287, "kind": "custom-event-listener" }, { @@ -15899,7 +15899,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 285, + "line": 287, "kind": "custom-event-listener", "match": "tabListElement.addEventListener('keydown'" }, @@ -18762,7 +18762,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 197, + "line": 199, "kind": "custom-event-listener" }, { @@ -18886,7 +18886,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 197, + "line": 199, "kind": "custom-event-listener", "match": "tabItem.addEventListener('mousedown'" }, @@ -18977,7 +18977,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 208, + "line": 210, "kind": "custom-event-listener" }, { @@ -19019,7 +19019,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 208, + "line": 210, "kind": "custom-event-listener", "match": "tabItem.addEventListener('mouseenter'" }, @@ -19092,7 +19092,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 209, + "line": 211, "kind": "custom-event-listener" }, { @@ -19175,7 +19175,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 209, + "line": 211, "kind": "custom-event-listener", "match": "tabItem.addEventListener('mouseleave'" }, @@ -22968,7 +22968,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 210, + "line": 212, "kind": "custom-event-listener" }, { @@ -23027,7 +23027,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 210, + "line": 212, "kind": "custom-event-listener", "match": "tabItem.addEventListener('pointerdown'" }, @@ -26407,7 +26407,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 254, + "line": 256, "kind": "custom-event-listener" }, { @@ -26471,7 +26471,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-strip.js", - "line": 254, + "line": 256, "kind": "custom-event-listener", "match": "tabListElement.addEventListener('scroll'" }, @@ -32542,7 +32542,7 @@ }, { "file": "modules/messageRenderer.js", - "line": 3328, + "line": 3319, "reason": "dynamic listener name: type", "kind": "custom-event-listener", "match": "target.addEventListener(type", From b44c09263bd2b3040042249802ae8f2f3a7d61dc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:19:06 +0000 Subject: [PATCH 042/143] Side pane: expand before mounting, and batch closes stop mounting doomed tabs openTab switched the strip to the new tab but left the pane collapsed and the old view showing until the provider finished mounting. The panel and view now follow in the same frame and the new view fills in while the provider mounts, the same order DSH uses. Close all / close others retired tabs one by one, and each fallback activation mounted a not-yet-shown tab that was closed straight after. Mounting now waits until the batch ends and only the tab left showing mounts (ZCode applies the batch as one transition). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- .../contracts/generated/chat-event-graph.json | 16 ++--- .../side-pane/side-pane-controller.js | 24 ++++++-- ...st.mjs => side-pane-mount-timing.test.mjs} | 59 ++++++++++++++++++- 3 files changed, 86 insertions(+), 13 deletions(-) rename tests/{side-pane-background-restore.test.mjs => side-pane-mount-timing.test.mjs} (58%) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 6565107f0..cc613b27b 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -4323,17 +4323,17 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1075, + "line": 1091, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1081, + "line": 1097, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1095, + "line": 1111, "kind": "custom-event-listener" }, { @@ -5904,19 +5904,19 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1075, + "line": 1091, "kind": "custom-event-listener", "match": "toggleNotificationsBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1081, + "line": 1097, "kind": "custom-event-listener", "match": "expandButton.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1095, + "line": 1111, "kind": "custom-event-listener", "match": "closeSidePaneBtn.addEventListener('click'" }, @@ -24996,7 +24996,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1115, + "line": 1131, "kind": "custom-event-listener" }, { @@ -25090,7 +25090,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1115, + "line": 1131, "kind": "custom-event-listener", "match": "win?.addEventListener?.('resize'" }, diff --git a/modules/ui-system/side-pane/side-pane-controller.js b/modules/ui-system/side-pane/side-pane-controller.js index 035fb52b0..e20e99d91 100644 --- a/modules/ui-system/side-pane/side-pane-controller.js +++ b/modules/ui-system/side-pane/side-pane-controller.js @@ -540,8 +540,20 @@ export function createSidePaneController({ } // 恢复出来的标签不在启动时挂载,第一次显示时才挂;焦点留在原处 + // 批量关闭期间兜底激活的标签多半紧接着也要关,等整批关完再挂当时停着的那个 + let batchClosing = 0; + async function closeBatch(tabs, options, onFocusMoved) { + batchClosing++; + try { + await tabCloseOwner.closeTabs(tabs, options, onFocusMoved); + } finally { + batchClosing--; + mountActiveIfNeeded(); + } + } + function mountActiveIfNeeded() { - if (isDisposed || !state.visible) return; + if (isDisposed || !state.visible || batchClosing > 0) return; const tabId = state.activeTabId; if (!tabId || mountedTabMap.has(tabId) || pendingTabMounts.has(tabId)) return; const tab = state.tabs.find(t => t.id === tabId); @@ -762,7 +774,7 @@ export function createSidePaneController({ const revision = ++navigationRevision; const keptLifetime = tabCloseOwner.getLifetime(tabId); let expectedFocus = doc.activeElement; - await tabCloseOwner.closeTabs(closing, { collapseWhenEmpty: false }, (before, after) => { + await closeBatch(closing, { collapseWhenEmpty: false }, (before, after) => { // Follow our synchronous close handoffs, not an unrelated focus change. if (before === expectedFocus) expectedFocus = after; }); @@ -775,7 +787,7 @@ export function createSidePaneController({ async closeAllTabs() { if (isDisposed) return; navigationRevision++; - await tabCloseOwner.closeTabs(SidePaneState.getClosableVisibleTabs(state)); + await closeBatch(SidePaneState.getClosableVisibleTabs(state)); }, /** @@ -800,12 +812,16 @@ export function createSidePaneController({ rememberOpened(SidePaneState.getTabParent(openedTab), targetTabId); renderTabList(); - const entry = await ensureTabMounted(targetTabId, { + const mounting = ensureTabMounted(targetTabId, { provider: providers[rawTab.kind], payload: rawTab, onClosed: definition?.onClosed, ariaLabel: resolved.title || '副屏视图' }); + // 标签条已经切过去了,面板和内容区同一刻跟上,不等挂载完(慢的挂载期间不会是新标签配旧内容、或标签亮着面板却收着) + syncViewPanels(); + syncDomVisibility(); + const entry = await mounting; return finishOpen(targetTabId, entry, origin); }, diff --git a/tests/side-pane-background-restore.test.mjs b/tests/side-pane-mount-timing.test.mjs similarity index 58% rename from tests/side-pane-background-restore.test.mjs rename to tests/side-pane-mount-timing.test.mjs index 41d34fd23..a93379da2 100644 --- a/tests/side-pane-background-restore.test.mjs +++ b/tests/side-pane-mount-timing.test.mjs @@ -4,7 +4,7 @@ import { JSDOM } from 'jsdom'; import { createSidePaneController } from '../modules/ui-system/side-pane/side-pane-controller.js'; import { defineChatTabType } from '../modules/ui-system/side-pane/tab-types/chat.js'; -// 切到话题后才读回来的辅助对话在后台补回:不抢激活、不强行展开、不改这个话题记下的收起状态 +// 什么时候挂载:后台补回的标签不抢激活也不挂;打开时面板先展开;批量关闭不挂马上要关的标签 const parentOf = topicId => ({ itemType: 'agent', itemId: 'agent', topicId }); const sideChat = (id, topicId) => ({ kind: 'chat', descriptor: { id, title: id, parent: parentOf(topicId), child: { itemType: 'agent', itemId: 'agent', topicId: `child-${id}` } @@ -85,3 +85,60 @@ test('side chats restored while the user is on another tab of the topic stay in assert.ok(snapshot.tabs.some(tab => tab.id === 'older')); assert.deepEqual(mounted, []); }); + +test('opening a tab expands the pane and shows its view while the provider is still mounting', async t => { + const dom = new JSDOM('
'); + const doc = dom.window.document; + let finishMount; + const ctrl = createSidePaneController({ + root: doc.getElementById('pane'), + tabListElement: doc.getElementById('tabs'), + contentContainer: doc.getElementById('content'), + tabTypes: [defineChatTabType({ + provider: { mountTab: () => new Promise(resolve => { finishMount = () => resolve({ dispose: async () => {}, focus() {} }); }) } + })] + }); + t.after(() => { ctrl.dispose?.(); dom.window.close(); }); + ctrl.setParent(parentOf('a')); + assert.equal(ctrl.getSnapshot().visible, false); + + const opening = ctrl.openTab(sideChat('slow', 'a')); + const view = doc.querySelector('#content [data-tab-id="slow"]'); + assert.ok(view); + assert.equal(view.hidden, false); + assert.equal(doc.getElementById('sidePaneViewNotifications').hidden, true); + assert.equal(doc.getElementById('pane').getAttribute('aria-hidden'), 'false'); + + finishMount(); + await opening; + assert.equal(view.hidden, false); +}); + +test('close all does not mount the not-yet-shown tabs it is about to close', async t => { + const { ctrl, mounted, dispose } = createController(); + t.after(dispose); + ctrl.setParent(parentOf('a')); + await ctrl.openTab(sideChat('current', 'a')); + await ctrl.restoreTabs([sideChat('one', 'a'), sideChat('two', 'a'), sideChat('three', 'a')]); + mounted.length = 0; + + await ctrl.closeAllTabs(); + + assert.deepEqual(mounted, []); + assert.deepEqual(ctrl.getSnapshot().tabs.map(tab => tab.id), ['notifications']); +}); + +test('close others mounts only the tab that stays', async t => { + const { ctrl, mounted, dispose } = createController(); + t.after(dispose); + ctrl.setParent(parentOf('a')); + await ctrl.openTab(sideChat('current', 'a')); + await ctrl.restoreTabs([sideChat('one', 'a'), sideChat('two', 'a'), sideChat('keep', 'a')]); + mounted.length = 0; + + await ctrl.closeOtherTabs('keep'); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.deepEqual(mounted, ['keep']); + assert.equal(ctrl.getSnapshot().activeTabId, 'keep'); +}); From 0befa45c81d6424c39dfc46fd7391208e1cf6052 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:22:06 +0000 Subject: [PATCH 043/143] Side chat: View trajectory opens the child topic's calls and finds the reply The menu item passed only the message id, and the trajectory tab always read the main chat's current topic, so a side reply never matched and the user got 'no recorded call'. The side chat now passes its child conversation; the tab reads that until the main chat switches conversation or the trajectory is opened from the main chat. ZCode passes the task id explicitly the same way. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- .../contracts/generated/chat-event-graph.json | 36 +++++++++---------- modules/renderer/side-chat/message-actions.js | 6 +++- modules/renderer/sidePaneCommands.js | 2 +- .../side-pane/modelTrajectorySideProvider.js | 26 ++++++++++---- tests/side-chat-message-delete.test.mjs | 17 +++++++++ tests/side-pane-model-trajectory.test.mjs | 25 +++++++++++++ 6 files changed, 86 insertions(+), 26 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index cc613b27b..219b7ae84 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -3648,12 +3648,12 @@ }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 212, + "line": 216, "kind": "custom-event-listener" }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 261, + "line": 265, "kind": "custom-event-listener" }, { @@ -4208,17 +4208,17 @@ }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 339, + "line": 345, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 363, + "line": 369, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 751, + "line": 757, "kind": "custom-event-listener" }, { @@ -5094,13 +5094,13 @@ }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 212, + "line": 216, "kind": "custom-event-listener", "match": "el.addEventListener('click'" }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 261, + "line": 265, "kind": "custom-event-listener", "match": "doc.addEventListener('click'" }, @@ -5766,19 +5766,19 @@ }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 339, + "line": 345, "kind": "custom-event-listener", "match": "more.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 363, + "line": 369, "kind": "custom-event-listener", "match": "head.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 751, + "line": 757, "kind": "custom-event-listener", "match": "item.addEventListener('click'" }, @@ -7290,7 +7290,7 @@ }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 260, + "line": 264, "kind": "custom-event-listener" }, { @@ -7421,7 +7421,7 @@ }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 260, + "line": 264, "kind": "custom-event-listener", "match": "root?.addEventListener('contextmenu'" }, @@ -15394,7 +15394,7 @@ }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 262, + "line": 266, "kind": "custom-event-listener" }, { @@ -15539,7 +15539,7 @@ }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 364, + "line": 370, "kind": "custom-event-listener" }, { @@ -15729,7 +15729,7 @@ }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 262, + "line": 266, "kind": "custom-event-listener", "match": "doc.addEventListener('keydown'" }, @@ -15903,7 +15903,7 @@ }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 364, + "line": 370, "kind": "custom-event-listener", "match": "head.addEventListener('keydown'" }, @@ -21585,14 +21585,14 @@ "consumers": [ { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 872, + "line": 885, "kind": "preload-subscription" } ], "evidence": [ { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 872, + "line": 885, "kind": "preload-subscription", "match": "api?.onModelTrajectoryChanged(" } diff --git a/modules/renderer/side-chat/message-actions.js b/modules/renderer/side-chat/message-actions.js index 614184a64..b668a0b74 100644 --- a/modules/renderer/side-chat/message-actions.js +++ b/modules/renderer/side-chat/message-actions.js @@ -167,7 +167,11 @@ export function createSideChatMessageActions({ const commands = win?.VCPContributions?.commands; if (isAssistant && message.id && commands?.get('sidepane.open-trajectory')) { - add('trajectory', 'fa-route', '查看调用轨迹', () => commands.execute('sidepane.open-trajectory', { requestId: message.id })); + // 这条回复记在子话题下,轨迹要看子话题,不是主聊天当前的话题 + const conversation = descriptor?.child?.itemId && descriptor.child.topicId + ? { item: { id: descriptor.child.itemId, name: descriptor.title || '辅助对话' }, topicId: descriptor.child.topicId } + : null; + add('trajectory', 'fa-route', '查看调用轨迹', () => commands.execute('sidepane.open-trajectory', { requestId: message.id, conversation })); } if (message.id && typeof removeMessage === 'function' && typeof saveHistory === 'function' && !unfinished && !busy) { diff --git a/modules/renderer/sidePaneCommands.js b/modules/renderer/sidePaneCommands.js index e3cb53463..3378b76d0 100644 --- a/modules/renderer/sidePaneCommands.js +++ b/modules/renderer/sidePaneCommands.js @@ -1,7 +1,7 @@ /* * 侧栏对外提供的动作,登记成命令: * sidepane.open-tab 打开(或回到)一个标签,参数同 controller.openTab - * sidepane.open-trajectory 打开调用轨迹并定位到某次请求 { requestId } + * sidepane.open-trajectory 打开调用轨迹并定位到某次请求 { requestId, conversation }(conversation 指定会话,辅助对话用) * notifications.toggle 开 / 关通知页 * projectforge.open 打开 V工程,可带 { projectId } 直接定位到那个工程 * 别的模块(消息右键、代码块按钮、主进程转来的快捷键)只认命令 id,不再碰侧栏控制器或 window 上的全局函数。 diff --git a/modules/ui-system/side-pane/modelTrajectorySideProvider.js b/modules/ui-system/side-pane/modelTrajectorySideProvider.js index 7eb59cecb..8d8e53685 100644 --- a/modules/ui-system/side-pane/modelTrajectorySideProvider.js +++ b/modules/ui-system/side-pane/modelTrajectorySideProvider.js @@ -61,17 +61,23 @@ export function createModelTrajectorySideProvider({ const kind = 'model-trajectory'; const win = doc.defaultView || window; const toast = (message, type = 'info') => uiHelper?.showToastNotification?.(message, type); - /** @type {Set<{focusCall: (requestId: string) => void}>} */ + /** @type {Set<{show: (requestId: string | null) => void}>} */ const instances = new Set(); let requestedRequestId = null; + // 从辅助对话打开时看的是它的子话题(同 ZCode 打开时显式带上会话);主聊天切换会话或从主聊天再打开时回到跟随主聊天 + let pinnedConversation = null; return { kind, - /** 打开(或聚焦)调用轨迹标签;带 requestId(消息 id)时滚动到对应的那次调用。 */ - async openModelTrajectoryTab({ requestId = null } = {}) { + /** + * 打开(或聚焦)调用轨迹标签;带 requestId(消息 id)时滚动到对应的那次调用。 + * conversation({ item: { id, name }, topicId })指定要看的会话,不传就跟随主聊天当前会话。 + */ + async openModelTrajectoryTab({ requestId = null, conversation = null } = {}) { if (!sidePaneController) return null; requestedRequestId = requestId; + pinnedConversation = trajectoryKeyFor(conversation) ? conversation : null; const handle = await sidePaneController.openTab({ id: TAB_ID, kind, @@ -82,7 +88,7 @@ export function createModelTrajectorySideProvider({ searchHint: '模型调用 请求 响应 token 轨迹' }); sidePaneController.setVisible?.(true); - if (requestId) for (const instance of instances) instance.focusCall(requestId); + for (const instance of instances) instance.show(requestId); handle?.focus?.(); return handle; }, @@ -777,7 +783,7 @@ export function createModelTrajectorySideProvider({ // ---------------------------------------------------------------- 数据 const currentKey = () => { - const conversation = getConversation?.() || null; + const conversation = pinnedConversation || getConversation?.() || null; conversationLabel = conversation?.item?.name || ''; return trajectoryKeyFor(conversation); }; @@ -850,7 +856,14 @@ export function createModelTrajectorySideProvider({ if (change.sessionKey === sessionKey || change.sessionKey === currentKey()) reloadWhenShown(scheduleReload); }; - const instance = { focusCall: requestId => { focusRequestId = requestId; if (!loading) renderAll(); } }; + const instance = { + show: requestId => { + if (requestId) focusRequestId = requestId; + // 换了要看的会话(比如从辅助对话打开)先读那个会话,读完再定位 + if (currentKey() !== sessionKey) void load(); + else if (!loading) renderAll(); + } + }; instances.add(instance); own.own(() => instances.delete(instance), 'focus-request-target'); own.own(() => { @@ -876,6 +889,7 @@ export function createModelTrajectorySideProvider({ // 切换智能体 / 话题(包括删掉当前助手)都由主聊天通知,接上了就不用轮询 own.subscribe(() => { const off = onConversationChange?.(() => { + pinnedConversation = null; if (!disposed() && currentKey() !== sessionKey) reloadWhenShown(() => void load()); }); followsConversation = typeof off === 'function'; diff --git a/tests/side-chat-message-delete.test.mjs b/tests/side-chat-message-delete.test.mjs index e83e2ee4c..1865c8e58 100644 --- a/tests/side-chat-message-delete.test.mjs +++ b/tests/side-chat-message-delete.test.mjs @@ -183,3 +183,20 @@ test('closing a side chat that has messages asks first, and keeps it when declin assert.deepEqual(await f.handle.requestClose(), { closed: true }); assert.equal(f.confirms.length, 2); }); + +test('view trajectory from a side reply asks for the child topic, not the main chat', async t => { + const f = await fixture(t); + const executed = []; + f.doc.defaultView.VCPContributions = { commands: { + get: id => id === 'sidepane.open-trajectory', + execute: (id, options) => executed.push([id, options]) + } }; + f.menu('answer'); + f.doc.querySelector('[data-side-chat-action="trajectory"]').click(); + assert.equal(executed.length, 1); + const [id, options] = executed[0]; + assert.equal(id, 'sidepane.open-trajectory'); + assert.equal(options.requestId, 'answer'); + assert.equal(options.conversation.item.id, 'agent'); + assert.equal(options.conversation.topicId, 'child'); +}); diff --git a/tests/side-pane-model-trajectory.test.mjs b/tests/side-pane-model-trajectory.test.mjs index 37c5a6eb0..b16fde4e1 100644 --- a/tests/side-pane-model-trajectory.test.mjs +++ b/tests/side-pane-model-trajectory.test.mjs @@ -377,3 +377,28 @@ test('closing the trajectory tab while the push registration is pending leaves n assert.equal(env.state.lists.length, 0); assert.equal(env.view.innerHTML, ''); }); + +test('opening from a side chat reads the child topic and finds its reply; a main chat switch goes back to following', async () => { + const env = makeEnv({ recs: manyRecords(2) }); + const handle = await env.provider.mountTab({ id: 'x' }, env.view); + assert.equal(env.state.lists.at(-1)[0], 'agent1__t1'); + + const child = { item: { id: 'agent1', name: '辅助对话 1' }, topicId: 'sidechat_1' }; + await env.provider.openModelTrajectoryTab({ requestId: 'm1', conversation: child }); + await wait(50); + assert.equal(env.state.lists.at(-1)[0], 'agent1__sidechat_1'); + assert.ok(env.view.querySelectorAll('.side-traj-call')[1].classList.contains('flash')); + assert.equal(env.state.toasts.filter(text => /没有对应的调用记录/.test(text)).length, 0); + + env.state.conversation = { item: { id: 'agent2', name: '另一个' }, topicId: 't9' }; + env.state.conversationListeners[0](); + await wait(50); + assert.equal(env.state.lists.at(-1)[0], 'agent2__t9'); + + await env.provider.openModelTrajectoryTab({ requestId: 'm0', conversation: child }); + await wait(50); + await env.provider.openModelTrajectoryTab(); + await wait(50); + assert.equal(env.state.lists.at(-1)[0], 'agent2__t9', 'opening from the main chat follows the main chat again'); + await handle.dispose(); +}); From f67b7409b04ec20effee81fa69d84f92f77afcc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:24:27 +0000 Subject: [PATCH 044/143] UI system gate: take the integration branch's tool card and components.css fix The base now carries upstream #234, whose components.css falls back to a literal colour, so check:ui-system was red again on the merge. Both files now match 2200d128 on the integration branch (#2), which fixes that and scopes the tool card styles, so the two branches merge without a conflict. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- styles/ui-system/components.css | 4 +- styles/ui-system/tool-presentation.css | 196 ++++++++++++------------- 2 files changed, 100 insertions(+), 100 deletions(-) diff --git a/styles/ui-system/components.css b/styles/ui-system/components.css index 418b7df8b..ddff52098 100644 --- a/styles/ui-system/components.css +++ b/styles/ui-system/components.css @@ -541,8 +541,8 @@ } :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned { - color: var(--vcp-ui-primary, #6366f1); - background: var(--vcp-ui-primary-bg, rgba(99, 102, 241, 0.16)); + color: var(--vcp-ui-accent); + background: color-mix(in srgb, var(--vcp-ui-accent) 16%, transparent); } :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned .vcp-ui-icon, diff --git a/styles/ui-system/tool-presentation.css b/styles/ui-system/tool-presentation.css index 38f0ec98a..be8a90316 100644 --- a/styles/ui-system/tool-presentation.css +++ b/styles/ui-system/tool-presentation.css @@ -1,12 +1,12 @@ /* Optional presentation; Legacy is untouched. Theme and typography stay owned * by the existing appearance profile, including role wallpapers. */ -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="compact"], -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="grouped"] { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="compact"], +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="grouped"] { --vcp-tool-row-height: 38px; } -html[data-ui-mode="next"][data-vcp-density="compact"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] { --vcp-tool-row-height: 32px; } -html[data-ui-mode="next"][data-vcp-density="relaxed"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] { --vcp-tool-row-height: 44px; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented { +html[data-ui-mode="next"][data-vcp-density="compact"] .vcp-ui-scope [data-vcp-tool-presentation] { --vcp-tool-row-height: 32px; } +html[data-ui-mode="next"][data-vcp-density="relaxed"] .vcp-ui-scope [data-vcp-tool-presentation] { --vcp-tool-row-height: 44px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented { --vcp-tool-presented-font-family: var(--vcp-tool-card-font-family, var(--vcp-chat-font-family)); --vcp-tool-presented-font-size: var(--vcp-ui-text-caption, 13px); --vcp-tool-presented-white-space: normal; @@ -32,11 +32,11 @@ html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vc color: var(--vcp-ui-text-primary); font-family: var(--vcp-tool-card-font-family, inherit); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented::before, -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented::after { display: none; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-summary, -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-header, -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-header { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented::before, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented::after { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-summary, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-header, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-header { display: flex; gap: 5px; padding: 0; @@ -47,7 +47,7 @@ html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vc min-width: 0; flex-wrap: nowrap; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-toggle, html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-toggle { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { display: flex; width: 100%; min-width: 0; @@ -65,13 +65,13 @@ html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-toggle, html[data- cursor: pointer; text-align: left; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-result-header > .vcp-tool-row-toggle { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-result-header > .vcp-tool-row-toggle { width: auto; flex: 1; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-toggle:hover, html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-toggle:hover { background: var(--vcp-ui-bg-2); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-toggle:focus-visible, html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-toggle:focus-visible, html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-artifact:focus-visible { outline: 2px solid var(--vcp-ui-accent); outline-offset: -2px; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-chevron { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle:hover, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle:hover { background: var(--vcp-ui-bg-2); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle:focus-visible, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle:focus-visible, html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-artifact:focus-visible { outline: 2px solid var(--vcp-ui-accent); outline-offset: -2px; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-chevron { width: 7px; height: 7px; flex: 0 0 7px; @@ -79,44 +79,44 @@ html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-chevron { border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [aria-expanded="true"] > .vcp-tool-row-chevron { transform: rotate(45deg); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-title { overflow-wrap: anywhere; min-width: 0; color: var(--vcp-ui-text-primary); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-resource { overflow-wrap: anywhere; min-width: 0; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-state { margin-left: auto; white-space: nowrap; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-state="success"] .vcp-tool-row-state { color: var(--vcp-ui-success); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-state="failed"] .vcp-tool-row-state { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-state="waiting"] .vcp-tool-row-state, -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-state="stopped"] .vcp-tool-row-state { color: var(--vcp-ui-warning, var(--vcp-ui-palette-amber)); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-state="running"] .vcp-tool-row-state { color: var(--vcp-ui-accent); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-delete-btn { flex: 0 0 24px; margin-right: 6px; opacity: 0; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:hover .vcp-tool-result-delete-btn, -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:focus-within .vcp-tool-result-delete-btn { opacity: 1; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="compact"] .vcp-role-divider, -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="grouped"] .vcp-role-divider { margin: 4px 0; font-size: calc(var(--vcp-ui-text-sm, 12px) - 1px); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-details { max-height: 360px; overflow: auto; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-details, -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-content { +html[data-ui-mode="next"] .vcp-ui-scope [aria-expanded="true"] > .vcp-tool-row-chevron { transform: rotate(45deg); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-title { overflow-wrap: anywhere; min-width: 0; color: var(--vcp-ui-text-primary); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-resource { overflow-wrap: anywhere; min-width: 0; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-state { margin-left: auto; white-space: nowrap; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="success"] .vcp-tool-row-state { color: var(--vcp-ui-success); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="failed"] .vcp-tool-row-state { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="waiting"] .vcp-tool-row-state, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="stopped"] .vcp-tool-row-state { color: var(--vcp-ui-warning, var(--vcp-ui-palette-amber)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-state="running"] .vcp-tool-row-state { color: var(--vcp-ui-accent); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-delete-btn { flex: 0 0 24px; margin-right: 6px; opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:hover .vcp-tool-result-delete-btn, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-header:focus-within .vcp-tool-result-delete-btn { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="compact"] .vcp-role-divider, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="grouped"] .vcp-role-divider { margin: 4px 0; font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented .vcp-tool-result-details { max-height: 360px; overflow: auto; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented > .vcp-tool-call-summary-content { margin: 0; padding: 6px 12px 12px 28px; background: transparent; border: 0; min-width: 0; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-details, -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-call-summary-content { display: none; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-details, -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-result-collapsible-content, -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-call-summary-content { display: block; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation] .vcp-tool-presented pre { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented:not(.expanded) > .vcp-tool-call-summary-content { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-details, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-result-collapsible-content, +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented.expanded > .vcp-tool-call-summary-content { display: block; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation] .vcp-tool-presented pre { white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-height: 320px; overflow: auto; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process { border: 1px solid var(--vcp-ui-border); border-radius: var(--vcp-ui-radius-md, 8px); margin: 10px 0; @@ -124,12 +124,12 @@ html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process { overflow: hidden; background: var(--vcp-ui-bg-0); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-toggle { background: var(--vcp-ui-bg-1); border-radius: 0; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-title { min-width: 0; overflow-wrap: anywhere; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-stats { margin-left: auto; font-size: var(--vcp-ui-text-sm, 12px); overflow-wrap: anywhere; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-body[hidden] { display: none; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-body > .vcp-tool-presented { margin: 0; border-top: 1px solid var(--vcp-ui-border); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-artifact { +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { background: var(--vcp-ui-bg-1); border-radius: 0; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-title { min-width: 0; overflow-wrap: anywhere; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-stats { margin-left: auto; font-size: var(--vcp-ui-text-sm, 12px); overflow-wrap: anywhere; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-body[hidden] { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-body > .vcp-tool-presented { margin: 0; border-top: 1px solid var(--vcp-ui-border); } +html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-artifact { color: var(--vcp-ui-text-accent); background: transparent; border: 0; @@ -142,20 +142,20 @@ html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-artifact { cursor: pointer; } @media (max-width: 640px) { - html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-toggle { flex-wrap: wrap; gap: 6px; } - html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-row-state { margin-left: auto; } - html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-toggle { flex-wrap: wrap; } - html[data-ui-mode="next"] :where(.vcp-ui-scope) .vcp-tool-process-stats { margin-left: 16px; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-toggle { flex-wrap: wrap; gap: 6px; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-row-state { margin-left: auto; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-toggle { flex-wrap: wrap; } + html[data-ui-mode="next"] .vcp-ui-scope .vcp-tool-process-stats { margin-left: 16px; } } /* 单行合并 / 整轮折叠:无框单行,一次调用一行;紧跟的同名结果收进请求行,展开后才显示。 */ -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) { --vcp-tool-call-color: var(--vcp-ui-text-3, var(--vcp-ui-text-secondary)); --vcp-tool-call-hover: var(--vcp-ui-text-2, var(--vcp-ui-text-secondary)); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-role-divider { margin: 4px 0; font-size: calc(var(--vcp-ui-text-sm, 12px) - 1px); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented { margin: 2px 0; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-role-divider { margin: 4px 0; font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented { margin: 2px 0; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { width: auto; max-width: 100%; min-height: 24px; @@ -168,10 +168,10 @@ html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation= color: var(--vcp-tool-call-color); transition: color .1s; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):hover { background: transparent; color: var(--vcp-tool-call-hover); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { flex: none; color: inherit; white-space: nowrap; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-process-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-resource { +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):hover { background: transparent; color: var(--vcp-tool-call-hover); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { flex: none; color: inherit; white-space: nowrap; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-process-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-resource { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -179,20 +179,20 @@ html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation= overflow-wrap: normal; font-size: inherit; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-state { flex: none; margin-left: 0; color: inherit; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon { display: inline-flex; flex: none; width: 16px; height: 16px; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon svg { width: 100%; height: 100%; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-diff { flex: none; font-family: var(--vcp-ui-font-mono, ui-monospace, Consolas, monospace); font-size: calc(var(--vcp-ui-text-sm, 12px) - 1px); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-add { color: var(--vcp-ui-success); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-del { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-toggle .vcp-tool-row-state { flex: none; margin-left: 0; color: inherit; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon { display: inline-flex; flex: none; width: 16px; height: 16px; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-icon svg { width: 100%; height: 100%; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-row-diff { flex: none; font-family: var(--vcp-ui-font-mono, ui-monospace, Consolas, monospace); font-size: var(--vcp-ui-text-xs, 11px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-add { color: var(--vcp-ui-success); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) :is(.vcp-tool-row-toggle:hover, [aria-expanded="true"]) .vcp-tool-row-diff-del { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } /* 并入请求行的结果:收起时不占位,展开后只留一个小标题“输出”。 - * 旧层给结果气泡写了 display:block 并加了强制优先级,这里只能藏标题、去掉外边距,正文由上面的收起规则隐藏。 */ -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) { margin: 0; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) > .vcp-tool-result-header { display: none; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"] .vcp-tool-row-toggle { min-height: 20px; padding-left: 24px; font-size: var(--vcp-ui-text-sm, 12px); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented > :is(.vcp-tool-details, .vcp-tool-result-collapsible-content, .vcp-tool-call-summary-content) { padding: 8px 0 4px 24px; } + * 旧层给结果气泡写了强制的 display:block(important 级),这里只能藏标题、去掉外边距,正文由上面的收起规则隐藏。 */ +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) { margin: 0; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"]:not(.expanded) > .vcp-tool-result-header { display: none; } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-merged="true"] .vcp-tool-row-toggle { min-height: 20px; padding-left: 24px; font-size: var(--vcp-ui-text-sm, 12px); } +html[data-ui-mode="next"] .vcp-ui-scope :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) .vcp-tool-presented > :is(.vcp-tool-details, .vcp-tool-result-collapsible-content, .vcp-tool-call-summary-content) { padding: 8px 0 4px 24px; } /* 等待结果时动作词的微光,只在回复生成中出现。 */ -html[data-ui-mode="next"] :where(.vcp-ui-scope) .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { +html[data-ui-mode="next"] .vcp-ui-scope .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { background: linear-gradient(90deg, var(--vcp-tool-call-color) 0%, var(--vcp-ui-text-primary) 50%, var(--vcp-tool-call-color) 100%); background-size: 200% 100%; -webkit-background-clip: text; @@ -202,33 +202,33 @@ html[data-ui-mode="next"] :where(.vcp-ui-scope) .message-item.streaming :is([dat } @keyframes vcp-tool-call-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } } @media (prefers-reduced-motion: reduce) { - html[data-ui-mode="next"] :where(.vcp-ui-scope) .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { animation: none; } + html[data-ui-mode="next"] .vcp-ui-scope .message-item.streaming :is([data-vcp-tool-presentation="inline"], [data-vcp-tool-presentation="process"]) [data-vcp-tool-pending="true"] .vcp-tool-row-title { animation: none; } } /* 单行合并:动作词加粗,箭头在行尾、悬停或展开时出现;问题状态用虚线下划线,悬停看报错。 */ -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { font-size: var(--vcp-ui-text-body, 14px); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { font-weight: 500; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) > .vcp-tool-row-chevron { order: 1; opacity: 0; transition: opacity .2s, transform .2s; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):is(:hover, [aria-expanded="true"]) > .vcp-tool-row-chevron { opacity: 1; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] [data-vcp-tool-call-state] .vcp-tool-row-state { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] { margin: 2px 0; border: 0; border-radius: 0; background: transparent; overflow: visible; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] .vcp-tool-process-stats { margin-left: 0; font-size: inherit; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body { margin: 4px 0 4px 8px; padding-left: 14px; border-left: 1px solid var(--vcp-ui-border); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-artifact { border-top: 0; padding: 4px 0 4px 22px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) { font-size: var(--vcp-ui-text-body, 14px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle .vcp-tool-row-title, .vcp-tool-process-title) { font-weight: 500; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle) > .vcp-tool-row-chevron { order: 1; opacity: 0; transition: opacity .2s, transform .2s; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] :is(.vcp-tool-row-toggle, .vcp-tool-process-toggle):is(:hover, [aria-expanded="true"]) > .vcp-tool-row-chevron { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] [data-vcp-tool-call-state] .vcp-tool-row-state { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] { margin: 2px 0; border: 0; border-radius: 0; background: transparent; overflow: visible; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] .vcp-tool-process-stats { margin-left: 0; font-size: inherit; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body { margin: 4px 0 4px 8px; padding-left: 14px; border-left: 1px solid var(--vcp-ui-border); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="inline"] .vcp-tool-process[data-variant="inline"] > .vcp-tool-process-artifact { border-top: 0; padding: 4px 0 4px 22px; } /* 整轮折叠:图标悬停换成箭头,标题与摘要之间用小圆点分隔,失败时摘要显示红色报错首行。 */ -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle { gap: 0; font-size: var(--vcp-ui-text-caption, 13px); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-leading { position: relative; display: inline-flex; flex: none; width: 16px; height: 16px; margin-right: 6px; align-items: center; justify-content: center; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-icon { width: 14px; height: 14px; transition: opacity .1s; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-chevron { position: absolute; inset: 0; margin: auto; opacity: 0; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-icon { opacity: 0; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-chevron { opacity: 1; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-dot { flex: none; width: 2px; height: 2px; margin: 0 8px; border-radius: 1px; background: currentColor; opacity: .7; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle :is(.vcp-tool-row-state, .vcp-tool-row-diff) { margin-left: 10px; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] [data-vcp-tool-call-state="failed"] .vcp-tool-row-toggle .vcp-tool-row-resource { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] { margin: 4px 0 10px; border: 0; border-radius: 0; background: transparent; overflow: visible; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle { +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle { gap: 0; font-size: var(--vcp-ui-text-caption, 13px); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading { position: relative; display: inline-flex; flex: none; width: 16px; height: 16px; margin-right: 6px; align-items: center; justify-content: center; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-icon { width: 14px; height: 14px; transition: opacity .1s; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-leading .vcp-tool-row-chevron { position: absolute; inset: 0; margin: auto; opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-icon { opacity: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle:is(:hover, [aria-expanded="true"]) .vcp-tool-row-leading .vcp-tool-row-chevron { opacity: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-dot { flex: none; width: 2px; height: 2px; margin: 0 8px; border-radius: 1px; background: currentColor; opacity: .7; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-row-toggle :is(.vcp-tool-row-state, .vcp-tool-row-diff) { margin-left: 10px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] [data-vcp-tool-call-state="failed"] .vcp-tool-row-toggle .vcp-tool-row-resource { color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] { margin: 4px 0 10px; border: 0; border-radius: 0; background: transparent; overflow: visible; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle { width: 100%; min-height: 33px; padding: 0 0 8px; @@ -236,8 +236,8 @@ html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="pro border-bottom: .5px solid var(--vcp-ui-border); font-size: var(--vcp-ui-text-body, 14px); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle > .vcp-tool-row-chevron { order: 1; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] .vcp-tool-process-stats { margin-left: 0; color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body { padding-top: 8px; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } -html[data-ui-mode="next"] :where(.vcp-ui-scope) [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-artifact { border-top: 0; padding: 6px 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-toggle > .vcp-tool-row-chevron { order: 1; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] .vcp-tool-process-stats { margin-left: 0; color: var(--vcp-ui-danger, var(--vcp-ui-palette-red)); } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body { padding-top: 8px; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-body > .vcp-tool-presented { margin: 2px 0; border-top: 0; } +html[data-ui-mode="next"] .vcp-ui-scope [data-vcp-tool-presentation="process"] .vcp-tool-process[data-variant="turn"] > .vcp-tool-process-artifact { border-top: 0; padding: 6px 0; } From 2eb5587152a32fdcf8299fc3aeb4a0044ff43e77 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:25:55 +0000 Subject: [PATCH 045/143] Contracts: regenerate the chat event graph after merging the latest #4 and #5 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UCGGgmHsCToooXTgSXpmq9 --- .../contracts/generated/chat-event-graph.json | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 041c1689c..eedcfe423 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -3545,17 +3545,17 @@ }, { "file": "modules/messageRenderer.js", - "line": 3438, + "line": 3429, "kind": "custom-event-listener" }, { "file": "modules/messageRenderer.js", - "line": 3497, + "line": 3488, "kind": "custom-event-listener" }, { "file": "modules/messageRenderer.js", - "line": 3535, + "line": 3526, "kind": "custom-event-listener" }, { @@ -3640,12 +3640,12 @@ }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 212, + "line": 216, "kind": "custom-event-listener" }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 261, + "line": 265, "kind": "custom-event-listener" }, { @@ -4205,17 +4205,17 @@ }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 339, + "line": 345, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 363, + "line": 369, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 751, + "line": 757, "kind": "custom-event-listener" }, { @@ -4325,17 +4325,17 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1077, + "line": 1144, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1083, + "line": 1150, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1097, + "line": 1164, "kind": "custom-event-listener" }, { @@ -4982,19 +4982,19 @@ }, { "file": "modules/messageRenderer.js", - "line": 3438, + "line": 3429, "kind": "custom-event-listener", "match": "attachmentElement.addEventListener('click'" }, { "file": "modules/messageRenderer.js", - "line": 3497, + "line": 3488, "kind": "custom-event-listener", "match": "attachmentElement.addEventListener('click'" }, { "file": "modules/messageRenderer.js", - "line": 3535, + "line": 3526, "kind": "custom-event-listener", "match": "removeBtn.addEventListener('click'" }, @@ -5096,13 +5096,13 @@ }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 212, + "line": 216, "kind": "custom-event-listener", "match": "el.addEventListener('click'" }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 261, + "line": 265, "kind": "custom-event-listener", "match": "doc.addEventListener('click'" }, @@ -5774,19 +5774,19 @@ }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 339, + "line": 345, "kind": "custom-event-listener", "match": "more.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 363, + "line": 369, "kind": "custom-event-listener", "match": "head.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 751, + "line": 757, "kind": "custom-event-listener", "match": "item.addEventListener('click'" }, @@ -5918,19 +5918,19 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1077, + "line": 1144, "kind": "custom-event-listener", "match": "toggleNotificationsBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1083, + "line": 1150, "kind": "custom-event-listener", "match": "expandButton.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1097, + "line": 1164, "kind": "custom-event-listener", "match": "closeSidePaneBtn.addEventListener('click'" }, @@ -7270,7 +7270,7 @@ }, { "file": "modules/messageRenderer.js", - "line": 3439, + "line": 3430, "kind": "custom-event-listener" }, { @@ -7285,7 +7285,7 @@ }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 260, + "line": 264, "kind": "custom-event-listener" }, { @@ -7398,7 +7398,7 @@ }, { "file": "modules/messageRenderer.js", - "line": 3439, + "line": 3430, "kind": "custom-event-listener", "match": "attachmentElement.addEventListener('contextmenu'" }, @@ -7416,7 +7416,7 @@ }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 260, + "line": 264, "kind": "custom-event-listener", "match": "root?.addEventListener('contextmenu'" }, @@ -15367,7 +15367,7 @@ }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 262, + "line": 266, "kind": "custom-event-listener" }, { @@ -15512,7 +15512,7 @@ }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 364, + "line": 370, "kind": "custom-event-listener" }, { @@ -15702,7 +15702,7 @@ }, { "file": "modules/renderer/side-chat/message-actions.js", - "line": 262, + "line": 266, "kind": "custom-event-listener", "match": "doc.addEventListener('keydown'" }, @@ -15876,7 +15876,7 @@ }, { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 364, + "line": 370, "kind": "custom-event-listener", "match": "head.addEventListener('keydown'" }, @@ -21547,14 +21547,14 @@ "consumers": [ { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 872, + "line": 885, "kind": "preload-subscription" } ], "evidence": [ { "file": "modules/ui-system/side-pane/modelTrajectorySideProvider.js", - "line": 872, + "line": 885, "kind": "preload-subscription", "match": "api?.onModelTrajectoryChanged(" } @@ -24969,7 +24969,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1117, + "line": 1184, "kind": "custom-event-listener" }, { @@ -25063,7 +25063,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-controller.js", - "line": 1117, + "line": 1184, "kind": "custom-event-listener", "match": "win?.addEventListener?.('resize'" }, @@ -32597,7 +32597,7 @@ }, { "file": "modules/messageRenderer.js", - "line": 3328, + "line": 3319, "reason": "dynamic listener name: type", "kind": "custom-event-listener", "match": "target.addEventListener(type", @@ -32861,7 +32861,7 @@ }, { "file": "modules/ui-system/side-pane/browserSideProvider.js", - "line": 279, + "line": 281, "reason": "dynamic listener name: name", "kind": "custom-event-listener", "match": "guest.addEventListener(name", From 06961e4b868fedf22a4638f8bd8f9d58c5d467d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:27:50 +0000 Subject: [PATCH 046/143] Leak test: count listeners, intervals and observers that bypass own.* The cycle test only counted resources registered through the lifecycle owner, so a stray doc.addEventListener in a provider passed all 452 tests. The fixture now also counts live listeners on document and window, open setInterval handles and observing MutationObservers. Swapping the trajectory tab's own.listen(doc, 'click') for a bare addEventListener now fails both cycle tests (4 -> 24 listeners). Browser and code viewer tabs join the cycle, so every lazily mounted tab type except side chat is covered. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- tests/side-pane-leak.test.mjs | 59 +++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/tests/side-pane-leak.test.mjs b/tests/side-pane-leak.test.mjs index 05872b158..6f2d5d48b 100644 --- a/tests/side-pane-leak.test.mjs +++ b/tests/side-pane-leak.test.mjs @@ -8,6 +8,8 @@ import { createTerminalSideProvider } from '../modules/ui-system/side-pane/termi import { createPlanDetailSideProvider } from '../modules/ui-system/side-pane/planDetailSideProvider.js'; import { createModelTrajectorySideProvider } from '../modules/ui-system/side-pane/modelTrajectorySideProvider.js'; import { createToolOutputSideProvider } from '../modules/ui-system/side-pane/toolOutputSideProvider.js'; +import { createBrowserSideProvider } from '../modules/ui-system/side-pane/browserSideProvider.js'; +import { createCodeViewerSideProvider } from '../modules/ui-system/side-pane/codeViewerSideProvider.js'; import { getProjectForgeChangesSource } from '../modules/ui-system/sources/projectforge-changes.js'; import { getCommandRunsSource } from '../modules/ui-system/sources/terminal-command-runs.js'; @@ -17,6 +19,51 @@ const settle = async () => { await new Promise(resolve => setTimeout(resolve, 0)); }; +/** + * 不经过 own.* 的资源也要数到:document / window 上的活监听、没清掉的 setInterval、没 disconnect 的 MutationObserver。 + * 只数这些长寿目标:挂在已移除视图节点上的监听随节点一起丢弃,不算泄漏。 + */ +function instrument(win) { + const live = new Set(); + const keyOf = (target, type, listener, options) => { + const capture = typeof options === 'boolean' ? options : Boolean(options?.capture); + return `${target === win ? 'window' : 'document'}|${type}|${capture}|${listenerIds.get(listener) ?? listenerIds.set(listener, listenerIds.size).get(listener)}`; + }; + const listenerIds = new Map(); + const proto = win.EventTarget.prototype; + const add = proto.addEventListener; + const remove = proto.removeEventListener; + const watched = target => target === win || target === win.document; + proto.addEventListener = function (type, listener, options) { + if (listener && watched(this)) { + const key = keyOf(this, type, listener, options); + live.add(key); + options?.signal?.addEventListener?.('abort', () => live.delete(key), { once: true }); + } + return add.call(this, type, listener, options); + }; + proto.removeEventListener = function (type, listener, options) { + if (listener && watched(this)) live.delete(keyOf(this, type, listener, options)); + return remove.call(this, type, listener, options); + }; + + const intervals = new Set(); + for (const host of new Set([globalThis, win])) { + const setI = host.setInterval, clearI = host.clearInterval; + host.setInterval = (...args) => { const id = setI.apply(host, args); intervals.add(id); return id; }; + host.clearInterval = id => { intervals.delete(id); return clearI.call(host, id); }; + } + + const observers = new Set(); + const NativeObserver = win.MutationObserver; + win.MutationObserver = class extends NativeObserver { + observe(...args) { observers.add(this); return super.observe(...args); } + disconnect() { observers.delete(this); return super.disconnect(); } + }; + + return () => ({ globalListeners: live.size, intervals: intervals.size, mutationObservers: observers.size }); +} + const PROJECT = { project: { id: 'p1', name: '工程', status: 'active', updated_at: '2026-09-30T01:00:00.000Z', root: 'C:\\w' }, todos: [{ id: 1, title: '第一步', status: 'doing' }], @@ -29,6 +76,7 @@ function fixture() {
`, { pretendToBeVisual: true }); + const unmanaged = instrument(dom.window); const doc = dom.window.document; const root = doc.getElementById('vcpSidePane'); const listeners = new Map(); @@ -81,14 +129,19 @@ function fixture() { 'plan-detail': createPlanDetailSideProvider({ document: doc, api, sidePaneController: controller, uiHelper: {} }), 'model-trajectory': createModelTrajectorySideProvider({ document: doc, api, sidePaneController: controller, uiHelper: {}, getConversation: () => ({ item: { id: 'agent1', name: 'A' }, topicId: 't1' }) }), - 'tool-output': createToolOutputSideProvider({ document: doc, api, sidePaneController: controller, uiHelper: {} }) + 'tool-output': createToolOutputSideProvider({ document: doc, api, sidePaneController: controller, uiHelper: {} }), + browser: createBrowserSideProvider({ document: doc, api: null, sidePaneController: controller, notify: () => {} }), + 'code-viewer': createCodeViewerSideProvider({ document: doc, api: null, uiHelper: null, sidePaneController: controller }) }; Object.entries(providers).forEach(([kind, provider]) => controller.registerProvider(kind, provider)); const opens = { terminal: () => providers.terminal.openTerminalTab(), 'plan-detail': () => providers['plan-detail'].openPlanDetailTab({ projectId: 'p1', projectName: '工程' }), 'model-trajectory': () => providers['model-trajectory'].openModelTrajectoryTab(), - 'tool-output': () => providers['tool-output'].openToolOutputTab() + 'tool-output': () => providers['tool-output'].openToolOutputTab(), + browser: () => controller.openTab({ id: 'browser:leak', kind: 'browser', title: '浏览器', closable: true, scopeMode: 'global', + payload: { url: 'https://example.com/' } }), + 'code-viewer': () => providers['code-viewer'].openViewer({ filePath: 'C:\\w\\a.js', code: 'const a = 1;\n' }) }; const { diagnostics } = globalThis.VCPLifecycle; @@ -100,6 +153,7 @@ function fixture() { sourcesRunning: globalThis.VCPSharedSources.diagnostics().filter(s => s.running || s.polling).length, ipcListeners: [...listeners.values()].reduce((sum, n) => sum + n, 0), nodes: doc.body.querySelectorAll('*').length, + ...unmanaged(), views: controller.getViewResidency() }); return { dom, controller, opens, calls, measure, @@ -141,6 +195,7 @@ test(`views put to sleep and woken ${CYCLES} times leave nothing behind`, async for (const open of Object.values(h.opens)) await open(); await settle(); const ids = openTabIds(h.controller); + assert.equal(ids.length, Object.keys(h.opens).length, 'every tab type opened'); // 只能有一个视图挂着:切到哪个标签,别的都休眠 for (const id of ids) { h.controller.activateTab(id); await settle(); } const baseline = h.measure(); From b38aa8bbae5513c03d1ece1e1a5d23a6a9773635 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:28:12 +0000 Subject: [PATCH 047/143] Code viewer picker: keyboard navigation and focus after opening a file Opening a file collapsed the picker under the focused row, so focus fell to body. Focus now returns to the picker toggle (which reports aria-expanded), like a Radix Popover returning to its trigger. The list no longer claims role=listbox over plain buttons, the filter has a name, the open file is aria-current, and arrow keys move between the filter and rows the way ZCode's cmdk file picker does. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../ui-system/side-pane/code-viewer/picker.js | 48 +++++++++++++++++-- tests/side-pane-code-viewer.test.mjs | 43 +++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/modules/ui-system/side-pane/code-viewer/picker.js b/modules/ui-system/side-pane/code-viewer/picker.js index 39bfe5b5a..b7bf878b4 100644 --- a/modules/ui-system/side-pane/code-viewer/picker.js +++ b/modules/ui-system/side-pane/code-viewer/picker.js @@ -22,6 +22,11 @@ export function createCodeViewerPicker({ // 监听挂在查看器这次挂载的 scope 上,标签释放时一起拆 const on = (node, event, listener) => scope.listen(node, event, listener, undefined, `picker:${event}`); + function setCollapsed(collapsed) { + picker.classList.toggle('is-collapsed', collapsed); + pickerToggleBtn?.setAttribute('aria-expanded', String(!collapsed)); + } + async function setupPicker() { const wsSelect = doc.createElement('select'); wsSelect.className = 'side-code-picker-select'; @@ -30,9 +35,11 @@ export function createCodeViewerPicker({ filterInput.type = 'search'; filterInput.className = 'side-code-picker-filter'; filterInput.placeholder = '搜索文件名...'; + filterInput.setAttribute('aria-label', '搜索文件名'); + // 行是普通按钮,不是 option,所以不标 listbox;方向键导航见下面的 onListKeydown const list = doc.createElement('div'); list.className = 'side-code-picker-list'; - list.setAttribute('role', 'listbox'); + list.setAttribute('aria-label', '文件'); const note = doc.createElement('div'); note.className = 'side-code-picker-note'; picker.append(wsSelect, filterInput, list, note); @@ -52,7 +59,10 @@ export function createCodeViewerPicker({ item.type = 'button'; item.className = 'side-code-picker-item'; item.dataset.path = rel; - if (rel === activePath) item.classList.add('active'); + if (rel === activePath) { + item.classList.add('active'); + item.setAttribute('aria-current', 'true'); + } const slash = rel.lastIndexOf('/'); const name = doc.createElement('span'); name.className = 'side-code-picker-name'; @@ -103,6 +113,8 @@ export function createCodeViewerPicker({ store.currentCode = ''; list.querySelectorAll('.side-code-picker-item').forEach((el) => { el.classList.toggle('active', el.dataset.path === rel); + if (el.dataset.path === rel) el.setAttribute('aria-current', 'true'); + else el.removeAttribute('aria-current'); }); setBodyMessage('加载文件中...'); try { @@ -130,7 +142,10 @@ export function createCodeViewerPicker({ } else { store.currentCode = file.text || ''; renderCodeView(); - picker.classList.add('is-collapsed'); + // 收起会把焦点所在的行藏掉,焦点交给能再打开选择器的按钮(Radix Popover 关闭时也回到触发器) + const hadFocus = picker.contains(doc.activeElement); + setCollapsed(true); + if (hadFocus) pickerToggleBtn?.focus?.(); } } catch (err) { if (!isCurrent()) return; @@ -153,10 +168,32 @@ export function createCodeViewerPicker({ loadFiles(); }); on(filterInput, 'input', renderList); + // 搜索框 ↓ 进列表,列表里 ↑↓/Home/End 移动,第一行再按 ↑ 回搜索框(同 ZCode/cmdk 的文件选择) + const rows = () => Array.from(list.querySelectorAll('.side-code-picker-item')); + on(filterInput, 'keydown', (event) => { + if (event.key !== 'ArrowDown') return; + const first = rows()[0]; + if (!first) return; + event.preventDefault(); + first.focus(); + }); + on(list, 'keydown', (event) => { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + const items = rows(); + const index = items.indexOf(event.target); + if (index < 0) return; + event.preventDefault(); + if (event.key === 'ArrowUp' && index === 0) { filterInput.focus(); return; } + const next = event.key === 'Home' ? 0 + : event.key === 'End' ? items.length - 1 + : Math.min(items.length - 1, Math.max(0, index + (event.key === 'ArrowDown' ? 1 : -1))); + items[next].focus(); + }); on(pickerToggleBtn, 'click', () => { - picker.classList.toggle('is-collapsed'); + setCollapsed(!picker.classList.contains('is-collapsed')); if (!picker.classList.contains('is-collapsed')) filterInput.focus(); }); + setCollapsed(picker.classList.contains('is-collapsed')); setBodyMessage('请选择要查看的文件'); if (typeof api?.gitListWorkspaces !== 'function' || typeof api?.sourceListFiles !== 'function') { @@ -181,7 +218,8 @@ export function createCodeViewerPicker({ wsSelect.appendChild(opt); } const valid = (id) => workspaces.some((w) => w.id === id); - const saved = getStorage()?.getItem(SOURCE_WORKSPACE_KEY); + let saved = null; + try { saved = getStorage()?.getItem(SOURCE_WORKSPACE_KEY); } catch (_error) { /* Storage is optional. */ } const preferred = valid(saved) ? saved : res.data.activeWorkspaceId; wsSelect.value = valid(preferred) ? preferred : workspaces[0].id; activeWorkspaceId = wsSelect.value; diff --git a/tests/side-pane-code-viewer.test.mjs b/tests/side-pane-code-viewer.test.mjs index 34baf3515..43572bac8 100644 --- a/tests/side-pane-code-viewer.test.mjs +++ b/tests/side-pane-code-viewer.test.mjs @@ -166,6 +166,49 @@ test('replaced picker rows no longer trigger reads after filtering', async () => } }); +test('workspace picker is keyboard usable and returns focus to its toggle after opening a file', async () => { + const dom = new JSDOM('
', { url: 'https://vcpchat.local/' }); + const doc = dom.window.document; + const provider = createCodeViewerSideProvider({ document: doc, uiHelper: null, api: { + async gitListWorkspaces() { return { success: true, data: { workspaces: [{ id: 'w', path: '/repo' }], activeWorkspaceId: 'w' } }; }, + async sourceListFiles() { return { success: true, data: { files: ['a.js', 'b.js'] } }; }, + async sourceReadFile(_workspace, path) { return { success: true, data: { text: path } }; } + } }); + const view = doc.getElementById('view'); + doc.body.appendChild(view); + const handle = await provider.mountTab({ title: '代码', payload: {} }, view); + try { + const filter = view.querySelector('.side-code-picker-filter'); + const list = view.querySelector('.side-code-picker-list'); + const toggle = view.querySelector('[aria-label="选择文件"]'); + assert.equal(filter.getAttribute('aria-label'), '搜索文件名'); + assert.notEqual(list.getAttribute('role'), 'listbox', 'rows are buttons, not options'); + assert.equal(toggle.getAttribute('aria-expanded'), 'true'); + + const key = (target, k) => target.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); + filter.focus(); + key(filter, 'ArrowDown'); + assert.equal(doc.activeElement?.dataset.path, 'a.js'); + key(doc.activeElement, 'ArrowDown'); + assert.equal(doc.activeElement?.dataset.path, 'b.js'); + key(doc.activeElement, 'Home'); + key(doc.activeElement, 'ArrowUp'); + assert.equal(doc.activeElement, filter); + + key(filter, 'ArrowDown'); + doc.activeElement.click(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(handle.getCode(), 'a.js'); + assert.equal(view.querySelector('.side-code-picker').classList.contains('is-collapsed'), true); + assert.equal(toggle.getAttribute('aria-expanded'), 'false'); + assert.equal(doc.activeElement, toggle, 'focus does not fall to body when the picker collapses'); + assert.equal(view.querySelector('[data-path="a.js"]').getAttribute('aria-current'), 'true'); + } finally { + handle.dispose(); + dom.window.close(); + } +}); + function fileTab(filePath) { return { id: `code-viewer:${filePath}`, title: filePath.split('/').pop(), payload: { filePath } }; } From d322faf5b6fbc587f57d26609a846cc8a5a0a7de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:30:52 +0000 Subject: [PATCH 048/143] Tests: replace source-text assertions with behaviour, drop CSS text pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Side sends leave the main topic's unread mark and list badges alone: now driven through chatManager.handleSendMessage with a side conversation (the old regex only checked how the if statement was spelled; removing the guard now fails the test). - Side chat numbering: the selection menu entry is driven through sideChatWiring with chats 1 and 3 open and must create 辅助对话 2; this also replaces the regex checks that the entry point exists. - Removed the gpt-4o grep (the missing-model refusal test covers it) and the three tests that matched stylesheet text and the order in main.html, which broke on formatting and would block loading tab styles lazily. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- tests/chat-manager-selection-race.test.js | 38 ++++++++++++++++- tests/side-chat-entry-points.test.mjs | 14 ++----- tests/side-chat-model-and-context.test.mjs | 47 ++++++++++++++-------- tests/side-pane-advanced-parity.test.mjs | 41 ------------------- 4 files changed, 70 insertions(+), 70 deletions(-) diff --git a/tests/chat-manager-selection-race.test.js b/tests/chat-manager-selection-race.test.js index 2d14d0861..d5115b9ee 100644 --- a/tests/chat-manager-selection-race.test.js +++ b/tests/chat-manager-selection-race.test.js @@ -59,6 +59,8 @@ function createFixture(options = {}) { let nextVcpGate = null; let nextSettingsSaveGate = null; let historySaveCount = 0; + const unreadMarks = []; + let unreadRefreshes = 0; const savedSettings = []; const sentRequests = []; const topicRequests = new Map(); @@ -172,7 +174,7 @@ function createFixture(options = {}) { histories.set(`${itemId}:${requestedTopicId}`, JSON.parse(JSON.stringify(messages))); return { success: true }; }, - setTopicUnread: async () => ({ success: true }), + setTopicUnread: async (...args) => { unreadMarks.push(args); return { success: true }; }, sendToVCP: async (...args) => { sentRequests.push(args); const gate = nextVcpGate; @@ -241,7 +243,7 @@ function createFixture(options = {}) { streamManager: options.streamProjection || null, itemListManager: { highlightActiveItem() {}, - refreshUnreadCounts() {}, + refreshUnreadCounts() { unreadRefreshes += 1; }, findItemById(itemId, itemType) { const item = configs[itemId]; return item && itemType === 'agent' ? { ...item, type: 'agent' } : null; @@ -318,6 +320,8 @@ function createFixture(options = {}) { }, savedSettings, historySaveCount: () => historySaveCount, + unreadMarks, + unreadRefreshes: () => unreadRefreshes, canvas: { emitContent: value => canvasContentListener?.(value), emitClosed: () => canvasClosedListener?.(), @@ -1025,3 +1029,33 @@ test('deleting the current assistant tells selection followers that nothing is s unsubscribe(); fixture.dom.window.close(); }); + +test('a side conversation send leaves the main topic unread state and item list alone', async () => { + const fixture = createFixture(); + const selectedA = fixture.chatManager.selectItem('agent-a', 'agent', 'Agent A', null, fixture.configs['agent-a']); + await new Promise(resolve => setImmediate(resolve)); + fixture.topicRequests.get('agent-a').resolve(fixture.configs['agent-a'].topics); + await selectedA; + + let sideHistory = []; + const sideInput = fixture.window.document.createElement('textarea'); + await fixture.chatManager.handleSendMessage({ + content: 'side question', + attachments: [], + input: sideInput, + conversation: { + selectedItemRef: { get: () => ({ ...fixture.configs['agent-a'], type: 'agent', config: fixture.configs['agent-a'] }) }, + topicIdRef: { get: () => 'topic-side' }, + historyRef: { get: () => sideHistory, set: value => { sideHistory = value; } }, + }, + }); + assert.equal(fixture.sentRequests.length, 1, 'the side message was sent'); + assert.deepEqual(fixture.unreadMarks, []); + assert.equal(fixture.unreadRefreshes(), 0); + + fixture.window.document.getElementById('messageInput').value = 'main question'; + await fixture.chatManager.handleSendMessage(); + assert.equal(fixture.sentRequests.length, 2); + assert.deepEqual(fixture.unreadMarks, [['agent-a', 'topic-a', false]]); + assert.equal(fixture.unreadRefreshes(), 1); +}); diff --git a/tests/side-chat-entry-points.test.mjs b/tests/side-chat-entry-points.test.mjs index 221bd85ee..399083a1f 100644 --- a/tests/side-chat-entry-points.test.mjs +++ b/tests/side-chat-entry-points.test.mjs @@ -4,8 +4,6 @@ import fs from 'node:fs'; import { JSDOM } from 'jsdom'; const html = fs.readFileSync(new URL('../main.html', import.meta.url), 'utf8'); -const renderer = fs.readFileSync(new URL('../renderer.js', import.meta.url), 'utf8'); -const wiring = fs.readFileSync(new URL('../modules/renderer/sideChatWiring.js', import.meta.url), 'utf8'); test('the selection "ask in side pane" button survives message-list re-renders', () => { const { document } = new JSDOM(html).window; @@ -16,16 +14,10 @@ test('the selection "ask in side pane" button survives message-list re-renders', // lucide 适配器只替换 .vcp-ui-scope 里的图标 assert.ok(button.classList.contains('vcp-ui-scope') || button.closest('.vcp-ui-scope')); + // 初始隐藏,选中消息文字后才出现;点它打开侧聊的行为见 side-chat-model-and-context.test.mjs + assert.equal(button.hidden, true); + const list = document.getElementById('chatMessages'); list.innerHTML = ''; assert.ok(document.getElementById('floatingSelectionSideChatBtn')); }); - -test('side chat is enabled and wired to the selection button and message context menu', () => { - assert.match(renderer, /initWorkspaceSidePane\(\{/); - assert.match(wiring, /win\.openSideChatWithSelection = async/); - assert.ok(html.includes('side-pane-side-chat-extras.css')); - // 按钮初始隐藏,选中消息文字后才出现 - const { document } = new JSDOM(html).window; - assert.equal(document.getElementById('floatingSelectionSideChatBtn').hidden, true); -}); diff --git a/tests/side-chat-model-and-context.test.mjs b/tests/side-chat-model-and-context.test.mjs index 080e5efcd..a78d4f9f9 100644 --- a/tests/side-chat-model-and-context.test.mjs +++ b/tests/side-chat-model-and-context.test.mjs @@ -1,6 +1,5 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import { JSDOM } from 'jsdom'; import { mountSideChatSurface } from '../modules/renderer/sideChatSurfaceOwner.js'; @@ -134,24 +133,40 @@ test('references-only mode never sends parent history and never refreshes the sn await handle.dispose(); }); -test('a side conversation send does not touch the main topic unread state or item list', () => { - const source = fs.readFileSync(new URL('../modules/chatManager.js', import.meta.url), 'utf8'); - assert.match(source, /const isSideConversation = !!request\?\.conversation;\s+if \(!isSideConversation\) try \{\s+const readResult = await electronAPI\.setTopicUnread\(/); - assert.match(source, /if \(isSideConversation\) \{[^}]*\} else if \(itemListManager && typeof itemListManager\.refreshUnreadCounts/); -}); - -test('no hard-coded fallback model remains in the side chat stack', () => { - for (const file of ['../modules/renderer/sideChatSurfaceOwner.js', '../modules/chat/sideChatSessionService.js']) { - assert.ok(!/gpt-4o/.test(fs.readFileSync(new URL(file, import.meta.url), 'utf8')), `${file} must not hard-code gpt-4o`); +// 侧聊发送不碰主话题未读:行为测试在 chat-manager-selection-race.test.js;没配置模型时拒绝发送见上面的测试 + +test('new side chats are named by the lowest free ordinal under the same parent', async () => { + const { createSideChatWiring } = await import('../modules/renderer/sideChatWiring.js'); + const dom = new JSDOM(''); + const parent = { itemType: 'agent', itemId: 'agent', topicId: 'parent' }; + const tab = (title, topicId = 'parent') => ({ id: title, kind: 'chat', title, descriptor: { parent: { ...parent, topicId } } }); + const opened = []; + const controller = { + getSnapshot: () => ({ parent, activeTabId: null, + tabs: [tab('辅助对话 1'), tab('辅助对话 3'), tab('辅助对话 2', 'other-topic')] }), + async openTab(raw) { opened.push(raw.descriptor.title); return null; }, + setVisible() {} + }; + let children = 0; + const chatAPI = { + createSideChatChild: async () => ({ success: true, topicId: `child-${++children}` }), + saveSideChatMetadata: async metadata => ({ success: true, metadata }) + }; + const wiring = createSideChatWiring({ + doc: dom.window.document, win: dom.window, chatAPI, chatRepository: null, chatManager: null, uiHelper: null, + createRenderer: () => null, + selectedItemRef: { get: () => ({ id: 'agent', type: 'agent', name: 'Agent', config: { model: 'm' } }) }, + topicIdRef: { get: () => 'parent' }, historyRef: { get: () => [] }, getController: () => controller + }); + try { + await dom.window.openSideChatWithSelection({ selectedText: '一段引用' }); + assert.deepEqual(opened, ['辅助对话 2'], 'the menu entry opens a side chat with the first free number of this topic'); + } finally { + wiring.dispose?.(); + dom.window.close(); } }); -test('new side chats are named by the lowest free ordinal under the same parent', () => { - const source = fs.readFileSync(new URL('../modules/renderer/sideChatWiring.js', import.meta.url), 'utf8'); - assert.ok(source.includes(String.raw`/^辅助对话 (\d+)$/`)); - assert.ok(source.includes('`辅助对话 ${ordinal}`')); -}); - test('composer autosaves go to browser storage; metadata only migrates once', async () => { const saved = []; const caps = capabilities({ saveSideChatMetadata: async (meta) => { saved.push(meta); return { success: true }; } }); diff --git a/tests/side-pane-advanced-parity.test.mjs b/tests/side-pane-advanced-parity.test.mjs index e66a08a70..4b5877db2 100644 --- a/tests/side-pane-advanced-parity.test.mjs +++ b/tests/side-pane-advanced-parity.test.mjs @@ -1,6 +1,5 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import { JSDOM } from 'jsdom'; import * as SidePaneState from '../modules/ui-system/side-pane/side-pane-state.js'; @@ -611,9 +610,6 @@ test('Parity: tab context menu is scoped, keyboard friendly and closes on Escape // 外壳的 backdrop-filter 会把 fixed 菜单的定位和背后内容都带偏,所以菜单挂在 body 下 assert.equal(contextMenu.parentNode, doc.body); assert.ok(contextMenu.classList.contains('vcp-ui-scope')); - // 出了侧栏就没有 #vcpSidePane [hidden] 兜底,菜单自己的 display: flex 会让它一直显示 - const overlaysCss = fs.readFileSync(new URL('../styles/ui-system/side-pane-tab-overlays.css', import.meta.url), 'utf8'); - assert.match(overlaysCss, /html \.side-pane-context-menu\[hidden\]:where\(\.vcp-ui-scope, \.vcp-ui-scope \*\) \{\s*display: none;/); ctrl.setParent({ itemType: 'agent', itemId: 'agent-1', topicId: 'parent' }); await ctrl.openTab({ kind: 'chat', descriptor: createDesc('s1', 'c1') }); @@ -812,25 +808,6 @@ test('Parity: Side Chat Model Picker supports interactive switching', async () = dom.window.close(); }); -test('Side pane divider and header hairlines', () => { - const html = fs.readFileSync(new URL('../main.html', import.meta.url), 'utf8'); - const css = [...html.matchAll(/href="(styles\/ui-system\/side-pane-[^"]+\.css)"/g)] - .map(([, href]) => fs.readFileSync(new URL(`../${href}`, import.meta.url), 'utf8')).join(''); - - // The pane sits inside the workspace card, so its only edge is a hairline - // on the left, drawn with the same token as the card border. - assert.match(css, /html #vcpSidePane:where\(\.vcp-ui-scope, \.vcp-ui-scope \*\) \{[^}]*border-left:\s*1px solid var\(--next-panel-edge/); - // Main panel and main content are outside .vcp-ui-scope: scoped rules for - // them would never match, so the side pane stylesheet must not carry any. - assert.doesNotMatch(css, /#nextUiMainPanel:where\(\.vcp-ui-scope/); - assert.doesNotMatch(css, /\.main-content[^{]*:where\(\.vcp-ui-scope/); - - assert.match(css, /html \.side-pane-tab-bar[\s\S]*?border-bottom:\s*1px solid var\(--zcode-header-divider/); - // VCPLog status lives on the 通知 tab, so the panel has no second header row. - assert.doesNotMatch(css, /\.notifications-header/); -}); - - test('tab type registration connects presentation, launcher availability and provider mounting', async () => { const dom = createParityTestDOM(); const ctrl = createController(dom); @@ -939,21 +916,3 @@ test('entry identity changes replace the whole declaration, while invalid replac } }); -test('Side pane overlays animate in and out and respect reduced motion', () => { - const html = fs.readFileSync(new URL('../main.html', import.meta.url), 'utf8'); - assert.match(html, /side-pane-side-chat-extras\.css">\s*/); - const motion = fs.readFileSync(new URL('../styles/ui-system/side-pane-motion.css', import.meta.url), 'utf8'); - for (const cls of ['side-pane-tab-overview-popover', 'side-pane-context-menu', 'side-chat-model-popover']) { - assert.ok(motion.includes(`.${cls}[hidden]`), `${cls} has a hidden state to fade out to`); - } - assert.match(motion, /display var\(--vcp-motion-duration-fast\) allow-discrete/); - assert.match(motion, /@starting-style/); - const reduced = motion.slice(motion.indexOf('@media (prefers-reduced-motion: reduce)')); - for (const sel of ['.side-pane-launcher-tabs', '.side-pane-launcher-tab-status[data-status]', '.side-pane-launcher-notice-dot', '.side-traj-call.flash', '.side-git-refresh-btn.spinning']) { - assert.ok(reduced.includes(sel), `reduced motion covers ${sel}`); - } - const launcher = fs.readFileSync(new URL('../styles/ui-system/side-pane-launcher.css', import.meta.url), 'utf8'); - assert.match(launcher, /\.side-pane-launcher-tabs:where\(\.vcp-ui-scope, \.vcp-ui-scope \*\)::before \{[^}]*transform: translateX\(calc\(var\(--side-pane-segment-index\) \* 100%\)\)/); - const bubbles = fs.readFileSync(new URL('../styles/side-chat-bubbles.css', import.meta.url), 'utf8'); - assert.match(bubbles, /#chatContextMenu\[data-side-chat-menu="true"\] \{[^}]*animation: side-chat-menu-in/); -}); From 15af839499d829a71976274c43a3da9b1a1d80cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:32:01 +0000 Subject: [PATCH 049/143] CI: run the 28 workbench tests no gate ran, and trigger on the files the PR adds outside modules The side pane PR adds 100 test files; 30 were not reached by any CI script. test:workbench runs the 28 that run on Linux (186 tests: terminal command runs, model trajectory, conversation status, notification center, tool presentation, message file changes, source and state IPC, and so on). terminal-handlers needs PowerShell and side-browser-retry-electron needs a display, so they stay with the Windows check. The Chat Kernel and UI gates also did not trigger on changes to main.js, style.css, Groupmodules, ProjectForgemodules, the PowerShellExecutor and ProjectForge plugins or vendor/xterm, all of which this PR changes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UCGGgmHsCToooXTgSXpmq9 --- .github/workflows/chat_kernel_ui.yml | 10 ++++++++++ package.json | 1 + 2 files changed, 11 insertions(+) diff --git a/.github/workflows/chat_kernel_ui.yml b/.github/workflows/chat_kernel_ui.yml index ebf406d00..fb204971b 100644 --- a/.github/workflows/chat_kernel_ui.yml +++ b/.github/workflows/chat_kernel_ui.yml @@ -5,6 +5,13 @@ on: pull_request: paths: - 'renderer.js' + - 'main.js' + - 'style.css' + - 'Groupmodules/**' + - 'ProjectForgemodules/**' + - 'VCPDistributedServer/Plugin/PowerShellExecutor/**' + - 'VCPDistributedServer/Plugin/ProjectForge/**' + - 'vendor/xterm/**' - 'main.html' - 'modules/**' - 'preloads/**' @@ -57,6 +64,9 @@ jobs: - name: Run side pane tests run: npm run test:side-pane + - name: Run side pane workbench surface tests + run: npm run test:workbench + - name: Run Chat contract evidence gate env: VCPCHAT_PACKAGED_ARTIFACT_SMOKE_OUTPUT: ${{ runner.temp }}/packaged-artifact-smoke.json diff --git a/package.json b/package.json index 70498447d..aa8ef47ec 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "test:built-artifact-smoke": "node scripts/test-built-artifact-smoke.mjs", "test:chat-transcript-snapshot": "node scripts/test-chat-transcript-snapshot.mjs", "test:side-pane": "node --test tests/side-pane-*.test.mjs tests/side-chat-*.test.mjs tests/side-browser-address.test.mjs tests/side-plan-revert-confirmation.test.mjs tests/sidebar-resizer-lifecycle.test.mjs tests/conversation-status-panel.test.mjs tests/domain-activator.test.js tests/browser-handlers.test.js tests/git-service.test.js tests/git-watcher.test.js tests/git-file-diff.test.mjs tests/git-changes-source.test.mjs tests/git-workspace-source.test.mjs", + "test:workbench": "node --test tests/conversation-current-source.test.mjs tests/conversation-scope.test.mjs tests/conversation-status-races.test.mjs tests/design-system-boundary.test.mjs tests/group-chat-url.test.js tests/group-stream-watchdog.test.js tests/line-diff-budget.test.mjs tests/main-composer-commands.test.mjs tests/message-file-changes-projectforge.test.mjs tests/message-file-changes.test.mjs tests/messenger-presentation.test.mjs tests/model-trajectory-handlers.test.js tests/model-trajectory-model.test.mjs tests/model-trajectory-recorder.test.js tests/notification-center.test.js tests/popover-window-blur.test.mjs tests/projectforge-changes-source.test.mjs tests/selection-reference.test.mjs tests/shared-source.test.mjs tests/source-handlers-sender.test.js tests/state-subscriptions.test.js tests/terminal-command-run-watch.test.js tests/terminal-native-helper.test.js tests/terminal-package-closure.test.js tests/tool-approval-card-enhancement.test.mjs tests/tool-presentation-settings.test.cjs tests/tool-presentation.test.mjs tests/tool-request-malformed-marker.test.mjs", "check:chat-evidence": "node scripts/check-chat-evidence.mjs", "check:chat-release-evidence": "node scripts/check-chat-release-evidence.mjs", "write:chat-evidence-manifest": "node scripts/write-chat-evidence-manifest.mjs", From 263b6c97bef6a35b7967a972fe8026f82259966b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:32:25 +0000 Subject: [PATCH 050/143] Tab overview: expose the arrow-key selection to screen readers Arrow keys only toggled a CSS class while focus stayed in the search box, so assistive tech never heard which tab was selected. The search box is now a combobox controlling a listbox of options with aria-activedescendant, the pattern ZCode's cmdk command palette uses. Closing a tab from the list no longer drops focus to body. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- .../contracts/generated/chat-event-graph.json | 36 +++++++++--------- .../side-pane/side-pane-tab-overview.js | 37 ++++++++++++++++++- tests/side-pane-tab-strip.test.mjs | 23 ++++++++++++ 3 files changed, 77 insertions(+), 19 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 26850224b..e8bc986a2 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -1720,7 +1720,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 219, + "line": 254, "kind": "custom-event-listener" }, { @@ -1831,7 +1831,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 219, + "line": 254, "kind": "custom-event-listener", "match": "win?.addEventListener('blur'" }, @@ -4375,22 +4375,22 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 120, + "line": 152, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 128, + "line": 162, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 143, + "line": 178, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 200, + "line": 235, "kind": "custom-event-listener" }, { @@ -5978,25 +5978,25 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 120, + "line": 152, "kind": "custom-event-listener", "match": "closeBtn.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 128, + "line": 162, "kind": "custom-event-listener", "match": "item.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 143, + "line": 178, "kind": "custom-event-listener", "match": "item.addEventListener('click'" }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 200, + "line": 235, "kind": "custom-event-listener", "match": "button.addEventListener('click'" }, @@ -14839,7 +14839,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 154, + "line": 189, "kind": "custom-event-listener" } ], @@ -15032,7 +15032,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 154, + "line": 189, "kind": "custom-event-listener", "match": "searchInput.addEventListener('input'" } @@ -15545,12 +15545,12 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 180, + "line": 215, "kind": "custom-event-listener" }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 218, + "line": 253, "kind": "custom-event-listener" }, { @@ -15920,13 +15920,13 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 180, + "line": 215, "kind": "custom-event-listener", "match": "searchInput.addEventListener('keydown'" }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 218, + "line": 253, "kind": "custom-event-listener", "match": "doc.addEventListener('keydown'" }, @@ -22996,7 +22996,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 217, + "line": 252, "kind": "custom-event-listener" }, { @@ -23054,7 +23054,7 @@ }, { "file": "modules/ui-system/side-pane/side-pane-tab-overview.js", - "line": 217, + "line": 252, "kind": "custom-event-listener", "match": "doc.addEventListener('pointerdown'" }, diff --git a/modules/ui-system/side-pane/side-pane-tab-overview.js b/modules/ui-system/side-pane/side-pane-tab-overview.js index fe54207bd..9f64723fc 100644 --- a/modules/ui-system/side-pane/side-pane-tab-overview.js +++ b/modules/ui-system/side-pane/side-pane-tab-overview.js @@ -38,6 +38,35 @@ export function createSidePaneTabOverview({ const listEl = popover.querySelector('#sidePaneOpenTabsList'); const searchInput = popover.querySelector('.side-pane-overview-input'); + // 搜索框 + 列表按 combobox/listbox 标注(ZCode 的 cmdk 命令面板同一套):焦点留在搜索框, + // 方向键选中的项通过 aria-activedescendant 读出来 + let optionSeq = 0; + if (listEl) { + listEl.setAttribute('role', 'listbox'); + listEl.setAttribute('aria-label', '标签页'); + } + if (searchInput && listEl?.id) { + searchInput.setAttribute('role', 'combobox'); + searchInput.setAttribute('aria-autocomplete', 'list'); + searchInput.setAttribute('aria-expanded', 'true'); + searchInput.setAttribute('aria-controls', listEl.id); + } + + function markOption(item, selected = false) { + item.id ||= `side-pane-overview-option-${++optionSeq}`; + item.setAttribute('role', 'option'); + item.setAttribute('aria-selected', String(selected)); + } + + function setKeyboardActive(items, index) { + items.forEach((item, i) => { + item.classList.toggle('kbd-active', i === index); + item.setAttribute('aria-selected', String(i === index)); + }); + if (items[index]) searchInput?.setAttribute('aria-activedescendant', items[index].id); + else searchInput?.removeAttribute('aria-activedescendant'); + } + const isOpen = () => !popover.hidden; const currentQuery = () => searchInput?.value || ''; @@ -66,6 +95,7 @@ export function createSidePaneTabOverview({ function createSectionTitle(text) { const title = doc.createElement('div'); title.className = 'side-pane-overview-section-title'; + title.setAttribute('role', 'presentation'); title.textContent = text; return title; } @@ -80,6 +110,7 @@ export function createSidePaneTabOverview({ function render(filterQuery = '') { if (!listEl) return; listEl.innerHTML = ''; + searchInput?.removeAttribute('aria-activedescendant'); const queryParts = normalizeSearchQuery(filterQuery); const activeTabId = getActiveTabId(); const now = Date.now(); @@ -107,6 +138,7 @@ export function createSidePaneTabOverview({ const item = doc.createElement('div'); item.className = `side-pane-overview-item${tab.id === activeTabId ? ' active' : ''}`; item.setAttribute('data-tab-id', tab.id); + markOption(item); item.appendChild(createItemTitle(getTabIconName(tab, getTabType), tab.title)); if (tab.openedAt) item.appendChild(createTime(formatRelativeTime(tab.openedAt, now))); @@ -121,6 +153,8 @@ export function createSidePaneTabOverview({ e.stopPropagation(); await onClose(tab.id); render(currentQuery()); + // 被点的关闭按钮跟着整行重建没了,焦点回搜索框而不是掉到 body + if (isOpen()) searchInput?.focus?.(); }); item.appendChild(closeBtn); } @@ -139,6 +173,7 @@ export function createSidePaneTabOverview({ const item = doc.createElement('div'); item.className = 'side-pane-overview-item recently-closed'; item.setAttribute('data-closed-tab-id', closed.id); + markOption(item); item.append(createItemTitle(getTabIconName(closed.tab, getTabType), closed.title), createTime(formatRelativeTime(closed.closedAt, now))); item.addEventListener('click', async () => { hide(); @@ -163,7 +198,7 @@ export function createSidePaneTabOverview({ event.preventDefault(); const step = event.key === 'ArrowDown' ? 1 : -1; const next = current < 0 ? (step > 0 ? 0 : items.length - 1) : (current + step + items.length) % items.length; - items.forEach((item, index) => item.classList.toggle('kbd-active', index === next)); + setKeyboardActive(items, next); items[next].scrollIntoView?.({ block: 'nearest' }); } else if (event.key === 'Enter') { const target = items[current] || items[0]; diff --git a/tests/side-pane-tab-strip.test.mjs b/tests/side-pane-tab-strip.test.mjs index 0993a161a..778a0ee0a 100644 --- a/tests/side-pane-tab-strip.test.mjs +++ b/tests/side-pane-tab-strip.test.mjs @@ -211,6 +211,29 @@ test('controller: the tab overview is keyboard driven (arrows, Enter, Escape)', button.click(); press('Escape'); assert.equal(popover.hidden, true); + + // 屏幕阅读器要能读到方向键选中的是哪一项:combobox + listbox + aria-activedescendant + button.click(); + const list = doc.getElementById('sidePaneOpenTabsList'); + assert.equal(input.getAttribute('role'), 'combobox'); + assert.equal(input.getAttribute('aria-controls'), list.id); + assert.equal(list.getAttribute('role'), 'listbox'); + press('ArrowDown'); + const selected = doc.querySelector('.side-pane-overview-item.kbd-active'); + assert.equal(selected.getAttribute('role'), 'option'); + assert.equal(selected.getAttribute('aria-selected'), 'true'); + assert.equal(input.getAttribute('aria-activedescendant'), selected.id); + assert.equal(doc.getElementById(selected.id), selected); + + // 关闭按钮跟着整行重建掉了,焦点回到搜索框 + const closeBtn = doc.querySelector('.side-pane-overview-item[data-tab-id="n2"] .side-pane-tab-close'); + closeBtn.focus(); + closeBtn.click(); + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(doc.querySelector('.side-pane-overview-item[data-tab-id="n2"]'), null); + assert.equal(doc.activeElement, input); + assert.equal(input.hasAttribute('aria-activedescendant'), false, 'no stale id after a redraw'); controller.dispose(); }); From c15e7076bbe75f27adfc7e2127b5422cdf62a7c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:33:20 +0000 Subject: [PATCH 051/143] Git graph: selecting a commit or loading more keeps the scroll position Every repaint rebuilt the scroll container, so clicking commit 120 for its details or pressing Load more threw the list back to the top. The scroll offset and the focused row now survive a repaint; only a refresh starts at the top. A refresh also discards a Load more page still in flight, and pages that overlap after new commits are de-duplicated by hash. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019DFRovoZHMzeheXKkLNWKG --- .../contracts/generated/chat-event-graph.json | 12 +++--- .../conversation-status-panel/git-graph.js | 35 +++++++++++++--- tests/conversation-status-panel.test.mjs | 41 +++++++++++++++++++ 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 1794089df..d0744c4d3 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -4049,17 +4049,17 @@ }, { "file": "modules/ui-system/conversation-status-panel/git-graph.js", - "line": 71, + "line": 77, "kind": "custom-event-listener" }, { "file": "modules/ui-system/conversation-status-panel/git-graph.js", - "line": 126, + "line": 140, "kind": "custom-event-listener" }, { "file": "modules/ui-system/conversation-status-panel/git-graph.js", - "line": 135, + "line": 149, "kind": "custom-event-listener" }, { @@ -5579,19 +5579,19 @@ }, { "file": "modules/ui-system/conversation-status-panel/git-graph.js", - "line": 71, + "line": 77, "kind": "custom-event-listener", "match": "refreshBtn.addEventListener('click'" }, { "file": "modules/ui-system/conversation-status-panel/git-graph.js", - "line": 126, + "line": 140, "kind": "custom-event-listener", "match": "line.addEventListener('click'" }, { "file": "modules/ui-system/conversation-status-panel/git-graph.js", - "line": 135, + "line": 149, "kind": "custom-event-listener", "match": "more.addEventListener('click'" }, diff --git a/modules/ui-system/conversation-status-panel/git-graph.js b/modules/ui-system/conversation-status-panel/git-graph.js index 11f7d0991..a7cc31cef 100644 --- a/modules/ui-system/conversation-status-panel/git-graph.js +++ b/modules/ui-system/conversation-status-panel/git-graph.js @@ -52,6 +52,8 @@ export function createStatusPanelGitGraph({ let expandedHash = null; let loadingMore = false; let refreshing = false; + // 刷新换掉整份列表;在那之前发出的「加载更多」回来时作废 + let generation = 0; const closeBtn = button('zc-btn zc-btn-ghost zc-btn-icon-sm zc-graph-close', { label: '关闭', onClick: modal.close }, icon('x')); const fetchPage = async skip => { @@ -60,7 +62,11 @@ export function createStatusPanelGitGraph({ return res.data; }; - const paint = () => { + // 重画时保住滚动位置和焦点所在的行:点一行看详情、加载更多都不该把人送回顶部 + const paint = ({ keepScroll = true } = {}) => { + const previousScroll = keepScroll ? (modal.dialog.querySelector('.zc-graph-scroll')?.scrollTop || 0) : 0; + const focusedHash = modal.dialog.contains(doc.activeElement) ? doc.activeElement?.dataset?.hash || null : null; + const focusLoadMore = modal.dialog.contains(doc.activeElement) && doc.activeElement?.classList?.contains('zc-graph-load-more'); modal.dialog.textContent = ''; const layout = layoutGitGraph(commits); const graphWidth = Math.max(56, layout.width + 12); @@ -71,17 +77,24 @@ export function createStatusPanelGitGraph({ refreshBtn.addEventListener('click', async () => { if (refreshing) return; refreshing = true; + const current = ++generation; + loadingMore = false; paint(); + let refreshed = false; try { const page = await fetchPage(0); + if (current !== generation || modal.closed) return; commits = page.commits; hasMore = page.hasMore; selectedHash = commits[0]?.hash ?? null; + expandedHash = null; + refreshed = true; } catch (e) { + if (current !== generation || modal.closed) return; toast(`刷新 Git 图谱失败:${e?.message || e}`, 'error'); } refreshing = false; - paint(); + paint({ keepScroll: !refreshed }); }); head.appendChild(refreshBtn); modal.dialog.append(closeBtn, head); @@ -121,6 +134,7 @@ export function createStatusPanelGitGraph({ h('span', 'zc-graph-cell zc-graph-date', formatCommitTime(commit.time)), h('span', 'zc-graph-cell zc-graph-author', commit.author), h('span', 'zc-graph-cell zc-graph-hash', commit.hash.slice(0, 7))); + line.dataset.hash = commit.hash; line.style.gridTemplateColumns = columns; line.style.height = `${layout.rowHeight}px`; line.addEventListener('click', () => { @@ -131,16 +145,21 @@ export function createStatusPanelGitGraph({ rows.appendChild(line); } if (hasMore) { - const more = button('zc-graph-load-more', { disabled: loadingMore }, loadingMore ? '正在加载...' : '加载更多提交'); + const more = button('zc-graph-load-more', { disabled: loadingMore || refreshing }, loadingMore ? '正在加载...' : '加载更多提交'); more.addEventListener('click', async () => { - if (loadingMore) return; + if (loadingMore || refreshing) return; loadingMore = true; + const current = generation; paint(); try { const page = await fetchPage(commits.length); - commits = [...commits, ...page.commits]; + if (current !== generation || modal.closed) return; + // 两页之间有新提交时分页会错开,已有的提交不再追加一遍 + const known = new Set(commits.map(commit => commit.hash)); + commits = [...commits, ...page.commits.filter(commit => !known.has(commit.hash))]; hasMore = page.hasMore; } catch (e) { + if (current !== generation || modal.closed) return; toast(`Git 图谱加载失败:${e?.message || e}`, 'error'); } loadingMore = false; @@ -157,7 +176,11 @@ export function createStatusPanelGitGraph({ const graphCol = h('div', 'zc-graph-lane-col', graphSvg); graphCol.style.height = `${svgHeight}px`; table.append(h('div', 'zc-graph-col zc-graph-col-first', '图'), headCells, graphCol, rows); - modal.dialog.appendChild(h('div', 'zc-graph-scroll', table)); + const scroll = h('div', 'zc-graph-scroll', table); + modal.dialog.appendChild(scroll); + scroll.scrollTop = previousScroll; + const refocus = focusedHash ? rows.querySelector(`[data-hash="${focusedHash}"]`) : focusLoadMore ? rows.querySelector('.zc-graph-load-more') : null; + refocus?.focus({ preventScroll: true }); const expanded = commits.find(commit => commit.hash === expandedHash); if (expanded) { diff --git a/tests/conversation-status-panel.test.mjs b/tests/conversation-status-panel.test.mjs index 58354fd26..38d498c85 100644 --- a/tests/conversation-status-panel.test.mjs +++ b/tests/conversation-status-panel.test.mjs @@ -380,6 +380,47 @@ test('git graph dialog draws commits and lanes', async () => { panel.dispose(); }); +test('git graph keeps its scroll position on select and load more; a refresh drops a load more already in flight', async () => { + const page = (from, count) => Array.from({ length: count }, (_, i) => { + const n = from + i; + return { hash: `h${String(n).padStart(6, '0')}`, parents: [`h${String(n + 1).padStart(6, '0')}`], author: 'Roxy', time: 1790000000 - n, subject: `提交 ${n}`, refs: [] }; + }); + let gate = null; + const { doc, panel, dom } = setup({ api: { + gitCommitGraph: async (_id, { skip }) => { + if (gate) await gate.promise; + return { success: true, data: { commits: page(skip, 50), hasMore: true } }; + } + } }); + panel.mount(); + await flush(); + await panel.openGitGraphDialog(); + const dialog = q(doc, '.zc-graph-dialog'); + q(dialog, '.zc-graph-scroll').scrollTop = 900; + + click(dom, dialog.querySelectorAll('.zc-graph-row')[30]); + assert.equal(q(dialog, '.zc-graph-scroll').scrollTop, 900, 'opening a commit detail keeps the list where it was'); + assert.match(q(doc, '.zc-graph-detail').textContent, /h000030/); + + click(dom, q(dialog, '.zc-graph-load-more')); + await flush(); + assert.equal(dialog.querySelectorAll('.zc-graph-row').length, 100); + assert.equal(q(dialog, '.zc-graph-scroll').scrollTop, 900, 'loading more keeps the list where it was'); + + // 加载更多还没回来时刷新:旧的那页回来后不能追加到刷新后的列表上 + let release; + gate = { promise: new Promise(resolve => { release = resolve; }) }; + click(dom, q(dialog, '.zc-graph-load-more')); + click(dom, q(dialog, '.zc-graph-refresh')); + gate = null; + release(); + await flush(); + await flush(); + assert.equal(dialog.querySelectorAll('.zc-graph-row').length, 50); + assert.equal(new Set([...dialog.querySelectorAll('.zc-graph-row')].map(row => row.dataset.hash)).size, 50); + panel.dispose(); +}); + test('panel lists recent AI commands from the built-in terminal and opens their output', async () => { let push = null; let watching = false; From decc1b3f183fbdc5ea9c9e21866fa5f53bd0d088 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:34:17 +0000 Subject: [PATCH 052/143] Git view: keep focus on the same file across a pushed redraw Every file change pushes a quiet refresh that rebuilds the whole list, so a keyboard user's focused row vanished and focus fell to body each time the agent edited a file. The redraw now refocuses the same file's row (or the row at the same position if that file is gone), which is what keyed React lists in ZCode and DSH do for free. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016hW799u5Ji5k9khDAwu688 --- modules/ui-system/side-pane/git/git-view.js | 10 +++++ tests/side-pane-git-refresh-focus.test.mjs | 46 +++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/side-pane-git-refresh-focus.test.mjs diff --git a/modules/ui-system/side-pane/git/git-view.js b/modules/ui-system/side-pane/git/git-view.js index 4fe3d20dd..9f15f06b9 100644 --- a/modules/ui-system/side-pane/git/git-view.js +++ b/modules/ui-system/side-pane/git/git-view.js @@ -266,9 +266,19 @@ export function mountGitView(host, { return; } empty.hidden = true; + // 推送触发的重绘会换掉整张列表:记下焦点所在的行,重建后还给同一个文件(React 按 key 复用节点时焦点本来就不丢, + // ZCode/DSH 的列表都是这样);那个文件没了就给同一位置的行 + const focusedCard = list.contains(doc.activeElement) ? doc.activeElement.closest('.side-git-card') : null; + const focusKey = focusedCard?.dataset.key ?? null; + const focusIndex = focusedCard ? Array.prototype.indexOf.call(list.children, focusedCard) : -1; list.innerHTML = ''; items.forEach(item => list.appendChild(buildCard(item))); cardsOwner.prefetch(items); + if (focusKey !== null) { + const cards = Array.from(list.querySelectorAll('.side-git-card')); + const target = cards.find(card => card.dataset.key === focusKey) || cards[Math.min(focusIndex, cards.length - 1)]; + target?.querySelector('.side-git-row')?.focus?.({ preventScroll: true }); + } } // ── 数据 ──────────────────────────────────────────────── diff --git a/tests/side-pane-git-refresh-focus.test.mjs b/tests/side-pane-git-refresh-focus.test.mjs new file mode 100644 index 000000000..8b6827473 --- /dev/null +++ b/tests/side-pane-git-refresh-focus.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +import { mountGitView } from '../modules/ui-system/side-pane/git/git-view.js'; + +// 文件一改主进程就推送,Git 列表静默重绘;键盘用户正停在某一行时焦点不能掉到 body +test('a quiet refresh that redraws the list keeps focus on the same file row', async () => { + const dom = new JSDOM('
', { pretendToBeVisual: true }); + const doc = dom.window.document; + const host = doc.getElementById('host'); + // JSDOM 不做布局,offsetParent 总是 null;这里当作挂在文档里就看得见 + Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetParent', { configurable: true, get() { return this.isConnected ? this.parentNode : null; } }); + let changes = [{ path: 'a.js', status: 'M' }, { path: 'b.js', status: 'M' }]; + const api = { + async gitListWorkspaces() { return { success: true, data: { workspaces: [{ id: 'w', alias: 'w', path: '/w' }], activeWorkspaceId: 'w' } }; }, + async gitStatus() { return { success: true, data: { isRepo: true, branch: { head: 'main' }, remotes: [], staged: [], changes: [...changes], conflicts: [] } }; }, + async gitDiff() { return { success: false, error: 'n/a' }; } + }; + const handle = mountGitView(host, { api, uiHelper: null }); + await handle.ready; + const row = path => host.querySelector(`.side-git-card[data-path="${path}"] .side-git-row`); + const settle = () => new Promise(resolve => setTimeout(resolve, 20)); + try { + row('b.js').focus(); + assert.equal(doc.activeElement, row('b.js')); + + // 新增一个文件:整张列表重建,焦点仍在 b.js + changes = [{ path: 'new.js', status: 'U' }, ...changes]; + dom.window.dispatchEvent(new dom.window.Event('focus')); + await settle(); + assert.ok(host.querySelector('.side-git-card[data-path="new.js"]'), 'list was redrawn'); + assert.equal(doc.activeElement, row('b.js')); + + // 聚焦的文件被提交掉了:焦点给同一位置的行,不掉到 body + changes = changes.filter(item => item.path !== 'b.js'); + dom.window.dispatchEvent(new dom.window.Event('focus')); + await settle(); + assert.equal(row('b.js'), null); + assert.ok(host.contains(doc.activeElement) && doc.activeElement.isConnected, 'focus stays in the list'); + assert.equal(doc.activeElement.classList.contains('side-git-row'), true); + } finally { + handle.dispose?.(); + dom.window.close(); + } +}); From 281471c21ec5ff8642f87f7b1d40d59748047b08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:35:47 +0000 Subject: [PATCH 053/143] Sidebar: show every unsaved settings edit; debounce and harden topic search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent and group settings save only on the Save button, so the 未保存更改 dot is the only warning before a switch drops an edit. - Regex rule add/edit/delete (modal is outside the form) and MiMo director prompt add/remove now mark the Agent draft unsaved. - The group form gets the same indicator: input/change, speaker reorder, member tags and JEV styles mark it; a successful save clears it; loading a group resets it; a failed save keeps it. Topic list: - Typing in topic search waits 200ms before reloading (one config read and one content search instead of one per keystroke); Enter stays immediate. ZCode's IO-backed refreshes debounce in the same range (fileWatcherService 150ms, useTabPersistence 300ms). - A rejected config IPC shows an error row instead of a permanent spinner. - Item names and IPC errors render as text, not innerHTML. Same for the item list load errors and embedded-app error messages. Contract line pins and the next-delta digests are updated for the moved lines. New tests run in check:ui-system. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012AP71nAxrCFEpH7st6asf9 --- Groupmodules/grouprenderer.js | 66 ++++- docs/contracts/chat-contracts.json | 10 +- .../contracts/generated/chat-event-graph.json | 174 ++++++------- modules/itemListManager.js | 2 +- modules/settings/schema/sidebar-surfaces.js | 6 +- modules/settingsManager.js | 28 ++- modules/topicListManager.js | 59 ++++- .../next-shell/next-shell-controller.js | 18 +- package.json | 2 +- scripts/next-delta-shared-baseline.json | 16 +- tests/settings-unsaved-indicator.test.mjs | 238 ++++++++++++++++++ tests/topic-list-load-robustness.test.js | 111 ++++++++ 12 files changed, 604 insertions(+), 126 deletions(-) create mode 100644 tests/settings-unsaved-indicator.test.mjs create mode 100644 tests/topic-list-load-robustness.test.js diff --git a/Groupmodules/grouprenderer.js b/Groupmodules/grouprenderer.js index fa5532806..0b16b4118 100644 --- a/Groupmodules/grouprenderer.js +++ b/Groupmodules/grouprenderer.js @@ -40,6 +40,11 @@ window.GroupRenderer = (() => { const pendingGroupUserMessageIds = new Set(); let groupSettingsGeneration = 0; let groupSettingsReady = false; + // Group settings save only on the Save button, like Agent settings. Track + // unsaved edits the same way so the form shows 未保存更改 before a switch + // throws them away. + let groupSettingsDirty = false; + let groupSettingsRevision = 0; let groupSlotsLoadPromise = null; function ensureGroupSlotsBridge() { @@ -629,6 +634,11 @@ window.GroupRenderer = (() => { } groupSettingsForm.addEventListener('submit', handleSaveGroupSettings); groupSettingsForm._eventListenerAttached = true; + if (!groupSettingsForm._dirtyListenerAttached) { + groupSettingsForm.addEventListener('input', markGroupSettingsDirty); + groupSettingsForm.addEventListener('change', markGroupSettingsDirty); + groupSettingsForm._dirtyListenerAttached = true; + } if (deleteGroupBtn?._eventListenerAttached) { @@ -646,6 +656,7 @@ window.GroupRenderer = (() => { groupAvatarInput._eventListenerAttached = true; groupSettingsReady = true; + resetGroupSettingsDirty(); updateAllGroupSectionSummaries(); } @@ -722,7 +733,10 @@ window.GroupRenderer = (() => { list: sequentialSpeakerOrderList, agents: availableAgentsForGroup, groupConfig, - onChanged: () => updateGroupSectionSummary('mode') + onChanged: () => { + updateGroupSectionSummary('mode'); + markGroupSettingsDirty(); + } }); } @@ -732,7 +746,10 @@ window.GroupRenderer = (() => { membersContainer: groupMembersListDiv, agents: availableAgentsForGroup, groupConfig, - onChanged: () => updateGroupSectionSummary('mode') + onChanged: () => { + updateGroupSectionSummary('mode'); + markGroupSettingsDirty(); + } }); } @@ -743,7 +760,10 @@ window.GroupRenderer = (() => { membersContainer: groupMembersListDiv, agents: availableAgentsForGroup, groupConfig, - onChanged: () => updateGroupSectionSummary('mode') + onChanged: () => { + updateGroupSectionSummary('mode'); + markGroupSettingsDirty(); + } }); } @@ -757,7 +777,41 @@ window.GroupRenderer = (() => { updateGroupSectionSummary('mode'); } - function reportSettingsSaveResult(success, error = '') { + function setGroupSaveState(state) { + const indicator = groupSettingsForm?.querySelector('.form-save-state-indicator'); + if (!indicator) return; + indicator.dataset.state = state; + const label = indicator.querySelector('.form-state-dot-label'); + if (label) label.textContent = state === 'warning' ? '未保存更改' : (state === 'ongoing' ? '保存中...' : '已保存'); + const dotHost = indicator.querySelector('.form-state-dot-host'); + if (!dotHost) return; + dotHost.replaceChildren(); + try { + window.VCPUIUX?.mountStateDot?.(dotHost, { state, size: 7 }, { + own: () => {}, + child: () => ({ own: () => {}, listen: () => {}, dispose: () => {} }), + listen: () => {}, + dispose: () => {} + }); + } catch (_) {} + } + + function markGroupSettingsDirty() { + if (!groupSettingsReady) return; + groupSettingsDirty = true; + ++groupSettingsRevision; + setGroupSaveState('warning'); + } + + function resetGroupSettingsDirty() { + groupSettingsDirty = false; + groupSettingsRevision = 0; + setGroupSaveState('done'); + } + + function reportSettingsSaveResult(success, error = '', saveRevision = null) { + if (success && saveRevision === groupSettingsRevision) groupSettingsDirty = false; + setGroupSaveState(success && !groupSettingsDirty ? 'done' : 'warning'); groupSettingsForm?.dispatchEvent(new CustomEvent('vcp-settings-save-result', { detail: { success: Boolean(success), error } })); @@ -790,6 +844,8 @@ window.GroupRenderer = (() => { const selectedMemberIds = [...getSelectedMemberIds()]; const targetGroupId = groupId; const saveGeneration = groupSettingsGeneration; + const saveRevision = groupSettingsRevision; + setGroupSaveState('ongoing'); const saveSurface = window.VCPSettingsSidebar; const saveSnapshot = saveSurface?.getSnapshot?.(); const isCurrentSave = () => { @@ -959,7 +1015,7 @@ window.GroupRenderer = (() => { const saveButton = groupSettingsForm.querySelector('button[type="submit"]'); if (result.success && result.agentGroup) { - reportSettingsSaveResult(true); + reportSettingsSaveResult(true, '', saveRevision); if (saveButton) uiHelper.showSaveFeedback(saveButton, true, "已保存!", "保存群组设置"); await mainRendererFunctions.loadItems(); // Reload list to reflect name/avatar changes if (!isCurrentSave()) return; diff --git a/docs/contracts/chat-contracts.json b/docs/contracts/chat-contracts.json index c6e520d4a..813dbd145 100644 --- a/docs/contracts/chat-contracts.json +++ b/docs/contracts/chat-contracts.json @@ -720,19 +720,19 @@ "dynamicSites": [ { "file": "modules/settingsManager.js", - "line": 711, + "line": 722, "kind": "custom-event-listener", "match": "target.addEventListener(type" }, { "file": "modules/settingsManager.js", - "line": 2221, + "line": 2233, "kind": "custom-event-listener", "match": "element.addEventListener(eventName" }, { "file": "modules/settingsManager.js", - "line": 2250, + "line": 2262, "kind": "custom-event-listener", "match": "systemPromptContainer.addEventListener(eventName" } @@ -751,13 +751,13 @@ "dynamicSites": [ { "file": "modules/topicListManager.js", - "line": 34, + "line": 40, "kind": "custom-event-listener", "match": "target?.addEventListener?.(type" }, { "file": "modules/topicListManager.js", - "line": 39, + "line": 45, "kind": "custom-event-listener", "match": "target?.addEventListener?.(type" } diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json index 69770eda3..12a5ff813 100644 --- a/docs/contracts/generated/chat-event-graph.json +++ b/docs/contracts/generated/chat-event-graph.json @@ -1239,7 +1239,7 @@ }, { "file": "modules/settingsManager.js", - "line": 1000, + "line": 1011, "kind": "custom-event-listener" }, { @@ -1257,7 +1257,7 @@ }, { "file": "modules/settingsManager.js", - "line": 1000, + "line": 1011, "kind": "custom-event-listener", "match": "settingsContainer.addEventListener('auxclick'" }, @@ -1675,7 +1675,7 @@ }, { "file": "modules/settingsManager.js", - "line": 1351, + "line": 1357, "kind": "custom-event-listener" }, { @@ -1772,7 +1772,7 @@ }, { "file": "modules/settingsManager.js", - "line": 1351, + "line": 1357, "kind": "custom-event-listener", "match": "vcpServerUrlInput.addEventListener('blur'" }, @@ -2318,7 +2318,7 @@ }, { "file": "modules/settings/schema/sidebar-surfaces.js", - "line": 515, + "line": 519, "kind": "custom-event-listener" }, { @@ -2348,7 +2348,7 @@ }, { "file": "modules/settingsManager.js", - "line": 1160, + "line": 1166, "kind": "custom-event-listener" }, { @@ -2483,7 +2483,7 @@ }, { "file": "modules/settings/schema/sidebar-surfaces.js", - "line": 515, + "line": 519, "kind": "custom-event-listener", "match": "form.addEventListener('change'" }, @@ -2519,7 +2519,7 @@ }, { "file": "modules/settingsManager.js", - "line": 1160, + "line": 1166, "kind": "custom-event-listener", "match": "agentSettingsForm.addEventListener('change'" }, @@ -3774,77 +3774,77 @@ }, { "file": "modules/settingsManager.js", - "line": 1064, + "line": 1075, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1079, + "line": 1090, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1080, + "line": 1091, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1082, + "line": 1093, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1092, + "line": 1103, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1099, + "line": 1110, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1163, + "line": 1169, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1238, + "line": 1244, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1244, + "line": 1250, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1271, + "line": 1277, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1300, + "line": 1306, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1627, + "line": 1637, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1640, + "line": 1650, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1814, + "line": 1824, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1822, + "line": 1832, "kind": "custom-event-listener" }, { @@ -3959,7 +3959,7 @@ }, { "file": "modules/topicListManager.js", - "line": 1276, + "line": 1318, "kind": "custom-event-listener" }, { @@ -5249,91 +5249,91 @@ }, { "file": "modules/settingsManager.js", - "line": 1064, + "line": 1075, "kind": "custom-event-listener", "match": "refreshModelsBtn.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1079, + "line": 1090, "kind": "custom-event-listener", "match": "cancelRegexRuleBtn.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1080, + "line": 1091, "kind": "custom-event-listener", "match": "closeRegexRuleModalBtn.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1082, + "line": 1093, "kind": "custom-event-listener", "match": "regexRuleModal.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1092, + "line": 1103, "kind": "custom-event-listener", "match": "openTopicSummaryModelSelectBtn.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1099, + "line": 1110, "kind": "custom-event-listener", "match": "document.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1163, + "line": 1169, "kind": "custom-event-listener", "match": "deleteItemBtn.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1238, + "line": 1244, "kind": "custom-event-listener", "match": "openModelSelectBtn.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1244, + "line": 1250, "kind": "custom-event-listener", "match": "refreshModelsBtn.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1271, + "line": 1277, "kind": "custom-event-listener", "match": "refreshTtsModelsBtn.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1300, + "line": 1306, "kind": "custom-event-listener", "match": "resetAvatarColorsBtn.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1627, + "line": 1637, "kind": "custom-event-listener", "match": "starSpan.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1640, + "line": 1650, "kind": "custom-event-listener", "match": "li.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1814, + "line": 1824, "kind": "custom-event-listener", "match": "editBtn.addEventListener('click'" }, { "file": "modules/settingsManager.js", - "line": 1822, + "line": 1832, "kind": "custom-event-listener", "match": "deleteBtn.addEventListener('click'" }, @@ -5471,7 +5471,7 @@ }, { "file": "modules/topicListManager.js", - "line": 1276, + "line": 1318, "kind": "custom-event-listener", "match": "document.addEventListener('click'" }, @@ -7245,7 +7245,7 @@ }, { "file": "modules/settingsManager.js", - "line": 975, + "line": 986, "kind": "custom-event-listener" }, { @@ -7379,7 +7379,7 @@ }, { "file": "modules/settingsManager.js", - "line": 975, + "line": 986, "kind": "custom-event-listener", "match": "settingsContainer.addEventListener('contextmenu'" }, @@ -14649,7 +14649,7 @@ "producers": [ { "file": "modules/settingsManager.js", - "line": 1961, + "line": 1973, "kind": "custom-event-create" } ], @@ -14736,32 +14736,32 @@ }, { "file": "modules/settingsManager.js", - "line": 1063, + "line": 1074, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1159, + "line": 1165, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1241, + "line": 1247, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1306, + "line": 1312, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1310, + "line": 1316, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1338, + "line": 1344, "kind": "custom-event-listener" }, { @@ -14909,43 +14909,43 @@ }, { "file": "modules/settingsManager.js", - "line": 1063, + "line": 1074, "kind": "custom-event-listener", "match": "modelSearchInput.addEventListener('input'" }, { "file": "modules/settingsManager.js", - "line": 1159, + "line": 1165, "kind": "custom-event-listener", "match": "agentSettingsForm.addEventListener('input'" }, { "file": "modules/settingsManager.js", - "line": 1241, + "line": 1247, "kind": "custom-event-listener", "match": "modelSearchInput.addEventListener('input'" }, { "file": "modules/settingsManager.js", - "line": 1306, + "line": 1312, "kind": "custom-event-listener", "match": "picker.addEventListener('input'" }, { "file": "modules/settingsManager.js", - "line": 1310, + "line": 1316, "kind": "custom-event-listener", "match": "textInput.addEventListener('input'" }, { "file": "modules/settingsManager.js", - "line": 1338, + "line": 1344, "kind": "custom-event-listener", "match": "agentCardCssInput.addEventListener('input'" }, { "file": "modules/settingsManager.js", - "line": 1961, + "line": 1973, "kind": "custom-event-create", "match": "new CustomEvent('input'" }, @@ -15377,7 +15377,7 @@ }, { "file": "modules/topicListManager.js", - "line": 1077, + "line": 1119, "kind": "custom-event-listener" }, { @@ -15725,7 +15725,7 @@ }, { "file": "modules/topicListManager.js", - "line": 1077, + "line": 1119, "kind": "custom-event-listener", "match": "inputField.addEventListener('keydown'" }, @@ -18326,7 +18326,7 @@ }, { "file": "modules/settingsManager.js", - "line": 1055, + "line": 1066, "kind": "custom-event-listener" }, { @@ -18356,7 +18356,7 @@ }, { "file": "modules/settingsManager.js", - "line": 1055, + "line": 1066, "kind": "custom-event-listener", "match": "document.addEventListener('modal-ready'" }, @@ -18742,7 +18742,7 @@ }, { "file": "modules/settingsManager.js", - "line": 1016, + "line": 1027, "kind": "custom-event-listener" }, { @@ -18862,7 +18862,7 @@ }, { "file": "modules/settingsManager.js", - "line": 1016, + "line": 1027, "kind": "custom-event-listener", "match": "settingsContainer.addEventListener('mousedown'" }, @@ -21462,7 +21462,7 @@ "consumers": [ { "file": "modules/settingsManager.js", - "line": 1247, + "line": 1253, "kind": "preload-subscription" }, { @@ -21474,7 +21474,7 @@ "evidence": [ { "file": "modules/settingsManager.js", - "line": 1247, + "line": 1253, "kind": "preload-subscription", "match": "electronAPI.onModelsUpdated(" }, @@ -22517,7 +22517,7 @@ }, { "file": "modules/settingsManager.js", - "line": 1263, + "line": 1269, "kind": "custom-event-listener" }, { @@ -22535,7 +22535,7 @@ }, { "file": "modules/settingsManager.js", - "line": 1263, + "line": 1269, "kind": "custom-event-listener", "match": "window.addEventListener('pagehide'" }, @@ -26387,12 +26387,12 @@ }, { "file": "modules/settingsManager.js", - "line": 2014, + "line": 2026, "kind": "custom-event-listener" }, { "file": "modules/topicListManager.js", - "line": 535, + "line": 562, "kind": "custom-event-listener" }, { @@ -26447,13 +26447,13 @@ }, { "file": "modules/settingsManager.js", - "line": 2014, + "line": 2026, "kind": "custom-event-listener", "match": "settingsTab.addEventListener('scroll'" }, { "file": "modules/topicListManager.js", - "line": 535, + "line": 562, "kind": "custom-event-listener", "match": "scrollContainer.addEventListener('scroll'" }, @@ -28009,12 +28009,12 @@ }, { "file": "modules/settingsManager.js", - "line": 1078, + "line": 1089, "kind": "custom-event-listener" }, { "file": "modules/settingsManager.js", - "line": 1132, + "line": 1143, "kind": "custom-event-listener" }, { @@ -28070,13 +28070,13 @@ }, { "file": "modules/settingsManager.js", - "line": 1078, + "line": 1089, "kind": "custom-event-listener", "match": "regexRuleForm.addEventListener('submit'" }, { "file": "modules/settingsManager.js", - "line": 1132, + "line": 1143, "kind": "custom-event-listener", "match": "agentSettingsForm.addEventListener('submit'" }, @@ -30176,7 +30176,7 @@ "producers": [ { "file": "modules/settingsManager.js", - "line": 172, + "line": 183, "kind": "custom-event-create" } ], @@ -30184,7 +30184,7 @@ "evidence": [ { "file": "modules/settingsManager.js", - "line": 172, + "line": 183, "kind": "custom-event-create", "match": "new CustomEvent('vcp-settings-delete-result'" } @@ -30229,7 +30229,7 @@ }, { "file": "modules/settingsManager.js", - "line": 166, + "line": 177, "kind": "custom-event-create" }, { @@ -30291,7 +30291,7 @@ }, { "file": "modules/settingsManager.js", - "line": 166, + "line": 177, "kind": "custom-event-create", "match": "new CustomEvent('vcp-settings-save-result'" }, @@ -30344,12 +30344,12 @@ "producers": [ { "file": "modules/settingsManager.js", - "line": 461, + "line": 472, "kind": "custom-event-create" }, { "file": "modules/settingsManager.js", - "line": 1344, + "line": 1350, "kind": "custom-event-create" } ], @@ -30363,13 +30363,13 @@ "evidence": [ { "file": "modules/settingsManager.js", - "line": 461, + "line": 472, "kind": "custom-event-create", "match": "new CustomEvent('vcp-settings-surface-updated'" }, { "file": "modules/settingsManager.js", - "line": 1344, + "line": 1350, "kind": "custom-event-create", "match": "new CustomEvent('vcp-settings-surface-updated'" }, @@ -32598,7 +32598,7 @@ }, { "file": "modules/settingsManager.js", - "line": 711, + "line": 722, "reason": "dynamic listener name: type", "kind": "custom-event-listener", "match": "target.addEventListener(type", @@ -32606,7 +32606,7 @@ }, { "file": "modules/settingsManager.js", - "line": 2221, + "line": 2233, "reason": "dynamic listener name: eventName", "kind": "custom-event-listener", "match": "element.addEventListener(eventName", @@ -32614,7 +32614,7 @@ }, { "file": "modules/settingsManager.js", - "line": 2250, + "line": 2262, "reason": "dynamic listener name: eventName", "kind": "custom-event-listener", "match": "systemPromptContainer.addEventListener(eventName", @@ -32622,7 +32622,7 @@ }, { "file": "modules/topicListManager.js", - "line": 34, + "line": 40, "reason": "dynamic listener name: type", "kind": "custom-event-listener", "match": "target?.addEventListener?.(type", @@ -32630,7 +32630,7 @@ }, { "file": "modules/topicListManager.js", - "line": 39, + "line": 45, "reason": "dynamic listener name: type", "kind": "custom-event-listener", "match": "target?.addEventListener?.(type", diff --git a/modules/itemListManager.js b/modules/itemListManager.js index 4c78dbfe3..9e95cef85 100644 --- a/modules/itemListManager.js +++ b/modules/itemListManager.js @@ -1089,7 +1089,7 @@ window.itemListManager = (() => { } else if (errors.length > 0) { console.warn('[ItemListManager] Failed to fully reload items, preserving previous list where possible:', errors.join(' | ')); if (!hadPreviousItems) { - itemListUl.innerHTML = errors.map(error => `
  • ${error}
  • `).join(''); + itemListUl.innerHTML = errors.map(error => `
  • ${escapeHtml(String(error))}
  • `).join(''); } } else { loadedItemsCache = []; diff --git a/modules/settings/schema/sidebar-surfaces.js b/modules/settings/schema/sidebar-surfaces.js index 3ef4af49d..c525b8e88 100644 --- a/modules/settings/schema/sidebar-surfaces.js +++ b/modules/settings/schema/sidebar-surfaces.js @@ -511,7 +511,11 @@ export function renderGroupSettingsSurface(host, doc = host?.ownerDocument || do const form = el(doc, 'form', { id: 'groupSettingsForm' }); form.append(el(doc, 'input', { type: 'hidden', id: 'editingGroupId' })); [['identity', '基础信息', 'groupIdentitySummary'], ['mode', '群聊模式', 'groupModeSummary'], ['model', '模型设置', 'groupModelSummary'], ['prompt', '系统提示词', 'groupPromptSummary']].forEach(([key, title, summaryId]) => form.append(renderSection(doc, { kind: 'group', key, title, summaryId, content: d => renderGroupSectionContent(d, key) }))); - form.append(el(doc, 'div', { class: 'form-actions' }, el(doc, 'button', { type: 'submit' }, '保存群组设置'), el(doc, 'div', { class: 'delete-button-container' }, el(doc, 'button', { type: 'button', id: 'deleteGroupBtn', class: 'danger-button' }, '删除此群组')))); + form.append(el(doc, 'div', { class: 'form-actions' }, + el(doc, 'div', { id: 'groupFormSaveStateIndicator', class: 'form-save-state-indicator', 'data-state': 'done' }, + el(doc, 'span', { class: 'form-state-dot-host' }), + el(doc, 'span', { class: 'form-state-dot-label' }, '已保存')), + el(doc, 'button', { type: 'submit' }, '保存群组设置'), el(doc, 'div', { class: 'delete-button-container' }, el(doc, 'button', { type: 'button', id: 'deleteGroupBtn', class: 'danger-button' }, '删除此群组')))); form.addEventListener('change', () => { syncSchemaDependencies(form); }); diff --git a/modules/settingsManager.js b/modules/settingsManager.js index c5de468b9..aa370901f 100644 --- a/modules/settingsManager.js +++ b/modules/settingsManager.js @@ -95,6 +95,17 @@ const settingsManager = (() => { let isAgentSettingsDirty = false; let agentSettingsRevision = 0; let agentSettingsAutosaveTimer = null; + + // Edits that live outside the form's own input/change events (the regex + // modal, MiMo add/remove buttons) report here so the unsaved-changes dot + // shows them like any other field. + function markAgentSettingsDirty() { + isAgentSettingsDirty = true; + ++agentSettingsRevision; + if (typeof globalThis.vcpUpdateFormStateDot === 'function') { + globalThis.vcpUpdateFormStateDot('warning'); + } + } const initializedCollapseStateAgents = new Set(); const promptModeFallbackLabels = { original: '文本', @@ -1151,13 +1162,8 @@ const settingsManager = (() => { } } }); - const markDirty = () => { - isAgentSettingsDirty = true; - ++agentSettingsRevision; - updateStateDotIndicator('warning'); - }; - agentSettingsForm.addEventListener('input', markDirty); - agentSettingsForm.addEventListener('change', markDirty); + agentSettingsForm.addEventListener('input', markAgentSettingsDirty); + agentSettingsForm.addEventListener('change', markAgentSettingsDirty); } if (deleteItemBtn) { deleteItemBtn.addEventListener('click', handleDeleteCurrentItem); @@ -1377,9 +1383,13 @@ const settingsManager = (() => { // slot owns rows, shortcuts and editor geometry. getTtsDirectorPrompts: () => [...currentAgentTtsDirectorPrompts], setTtsDirectorPrompts: (prompts) => { - currentAgentTtsDirectorPrompts = Array.isArray(prompts) + const nextPrompts = Array.isArray(prompts) ? prompts.map(prompt => String(prompt ?? '').trim()).filter(Boolean) : []; + const changed = nextPrompts.length !== currentAgentTtsDirectorPrompts.length + || nextPrompts.some((prompt, index) => prompt !== currentAgentTtsDirectorPrompts[index]); + currentAgentTtsDirectorPrompts = nextPrompts; + if (changed) markAgentSettingsDirty(); updateSectionSummary('tts'); }, getTtsDirectorTemplate: () => TTS_DIRECTOR_TEMPLATE, @@ -1823,6 +1833,7 @@ function resolveRegexSlots() { if (await uiHelper.showConfirmDialog(`确定要删除规则 "${rule.title}" 吗?`, '删除确认', '删除', '取消', true)) { currentAgentRegexes = currentAgentRegexes.filter(r => r.id !== rule.id); renderRegexList(); + markAgentSettingsDirty(); } }); @@ -1930,6 +1941,7 @@ function resolveRegexSlots() { } renderRegexList(); + markAgentSettingsDirty(); closeRegexModal(); } diff --git a/modules/topicListManager.js b/modules/topicListManager.js index 41b4a9339..075f8c746 100644 --- a/modules/topicListManager.js +++ b/modules/topicListManager.js @@ -28,6 +28,12 @@ window.topicListManager = (() => { const TOPIC_INITIAL_RENDER_COUNT = 40; const TOPIC_PROGRESSIVE_BATCH_SIZE = 30; const TOPIC_LOAD_MORE_THRESHOLD_PX = 320; + // Each search reload re-reads the item config and runs a content search + // over IPC. Typing waits for a short pause first, in the range ZCode uses + // for its IO-backed refreshes (fileWatcherService 150ms, useTabPersistence + // 300ms); Enter still searches at once. + const TOPIC_SEARCH_DEBOUNCE_MS = 200; + let topicSearchTimer = null; const addListener = (target, type, handler, options) => { if (listenerOwner) return listenerOwner.add(target, type, handler, options); @@ -40,6 +46,27 @@ window.topicListManager = (() => { renderListenerDisposers.push(() => target?.removeEventListener?.(type, handler, options)); } + function cancelPendingTopicSearch() { + if (topicSearchTimer) clearTimeout(topicSearchTimer); + topicSearchTimer = null; + } + + // Names and IPC error strings are user or disk data, so status rows are + // built with textContent, never by interpolating into innerHTML. + function renderTopicListStatus(topicListUl, message, { loading = false } = {}) { + const li = document.createElement('li'); + if (loading) { + const spinner = document.createElement('div'); + spinner.className = 'loading-spinner-small'; + li.append(spinner, document.createTextNode(message)); + } else { + const p = document.createElement('p'); + p.textContent = message; + li.appendChild(p); + } + topicListUl.replaceChildren(li); + } + function disposeRenderListeners() { renderListenerDisposers.splice(0).reverse().forEach(dispose => dispose()); } @@ -545,6 +572,7 @@ window.topicListManager = (() => { return; } + cancelPendingTopicSearch(); cleanupProgressiveTopicRendering(); const loadGeneration = topicListRenderGeneration; @@ -594,15 +622,21 @@ window.topicListManager = (() => { let itemConfigFull; if (!searchTerm) { - topicListUl.innerHTML = `
  • 正在加载 ${itemNameForLoading} 的话题...
  • `; + renderTopicListStatus(topicListUl, `正在加载 ${itemNameForLoading} 的话题...`, { loading: true }); } else { topicListUl.innerHTML = ''; } - if (currentSelectedItem.type === 'agent') { - itemConfigFull = await electronAPI.getAgentConfig(currentSelectedItem.id); - } else if (currentSelectedItem.type === 'group') { - itemConfigFull = await electronAPI.getAgentGroupConfig(currentSelectedItem.id); + try { + if (currentSelectedItem.type === 'agent') { + itemConfigFull = await electronAPI.getAgentConfig(currentSelectedItem.id); + } else if (currentSelectedItem.type === 'group') { + itemConfigFull = await electronAPI.getAgentGroupConfig(currentSelectedItem.id); + } + } catch (error) { + // A rejected IPC call used to leave the loading spinner up for good. + console.error('[TopicListManager] Failed to load item config:', error); + itemConfigFull = { error: error?.message || String(error) }; } if (loadGeneration !== topicListRenderGeneration || @@ -621,7 +655,7 @@ window.topicListManager = (() => { currentItemConfig = null; selectedTopicIds.clear(); syncManageUi(); - topicListUl.innerHTML = `
  • 无法加载 ${itemNameForLoading} 的配置信息: ${itemConfigFull?.error || '未知错误'}

  • `; + renderTopicListStatus(topicListUl, `无法加载 ${itemNameForLoading} 的配置信息: ${itemConfigFull?.error || '未知错误'}`); } else { let topicsToProcess = itemConfigFull.topics || []; if (currentSelectedItem.type === 'agent' && topicsToProcess.length === 0) { @@ -682,7 +716,7 @@ window.topicListManager = (() => { syncManageUi(); if (topicsToProcess.length === 0) { - topicListUl.innerHTML = `
  • ${itemNameForLoading} 还没有任何话题${searchTerm ? '匹配当前搜索' : ''}。您可以点击上方的“新建${currentSelectedItem.type === 'group' ? '群聊话题' : '聊天话题'}”按钮创建一个。

  • `; + renderTopicListStatus(topicListUl, `${itemNameForLoading} 还没有任何话题${searchTerm ? '匹配当前搜索' : ''}。您可以点击上方的“新建${currentSelectedItem.type === 'group' ? '群聊话题' : '聊天话题'}”按钮创建一个。`); } else { const currentTopicId = currentTopicIdRef.get(); renderTopicListProgressively(topicListUl, topicsToProcess, currentSelectedItem, currentTopicId, itemConfigFull, searchTerm); @@ -701,7 +735,7 @@ window.topicListManager = (() => { if (inputElement.dataset.topicSearchBound === 'true') return; inputElement.dataset.topicSearchBound = 'true'; - addListener(inputElement, 'input', filterTopicList); + addListener(inputElement, 'input', scheduleTopicSearch); addListener(inputElement, 'keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); @@ -710,6 +744,14 @@ window.topicListManager = (() => { }); } + function scheduleTopicSearch() { + cancelPendingTopicSearch(); + topicSearchTimer = setTimeout(() => { + topicSearchTimer = null; + loadTopicList(); + }, TOPIC_SEARCH_DEBOUNCE_MS); + } + function filterTopicList() { loadTopicList(); } @@ -1539,6 +1581,7 @@ window.topicListManager = (() => { } function dispose() { + cancelPendingTopicSearch(); cleanupProgressiveTopicRendering(); closeTopicContextMenu(); selectedTopicIds.clear(); diff --git a/modules/ui-system/next-shell/next-shell-controller.js b/modules/ui-system/next-shell/next-shell-controller.js index 05b90a637..c0bc81260 100644 --- a/modules/ui-system/next-shell/next-shell-controller.js +++ b/modules/ui-system/next-shell/next-shell-controller.js @@ -401,10 +401,24 @@ } catch (error) { console.error(`[NextUI] Failed to open embedded app ${app.id}:`, error); container.dataset.state = 'error'; - container.innerHTML = `
    ${error.message || '应用加载失败'}
    `; + container.replaceChildren(createEmbeddedAppError(error.message || '应用加载失败')); } } + // The error text comes from the app or its process, so it goes in as text. + function createEmbeddedAppError(message) { + const status = document.createElement('div'); + status.className = 'next-ui-embedded-app-status is-error'; + const icon = document.createElement('span'); + icon.className = 'vcp-ui-icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = 'error'; + const text = document.createElement('span'); + text.textContent = String(message); + status.append(icon, text); + return status; + } + function openEmbeddedApp(app) { return trackTabPromise(`open:${app?.action || app?.id || 'unknown'}`, openEmbeddedAppInternal(app)); } @@ -592,7 +606,7 @@ } if (payload?.state !== 'error') return; view.container.dataset.state = 'error'; - view.container.innerHTML = `
    ${payload.error || '应用运行异常'}
    `; + view.container.replaceChildren(createEmbeddedAppError(payload.error || '应用运行异常')); }); } diff --git a/package.json b/package.json index 50c91de3a..f64155862 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "test:launchpad-icons": "node --test tests/launchpad-icons.test.js tests/launchpad-controller.test.js", "test:next-ui-empty-state": "node scripts/test-next-ui-empty-state.mjs", "test:ui-motion-contract": "node scripts/test-ui-motion-contract.mjs", - "check:ui-system": "npm run guard:design-subtraction && npm run guard:classic-parity && npm run guard:classic-retirement && npm run guard:next-delta && npm run guard:ui-interaction && npm run guard:ui-async-state && npm run guard:ui-task-journeys && npm run guard:theme-provenance && npm run test:ui-motion-contract && node --check modules/ui-system/lifecycle-scope.js && node --check VCPDistributedServer/frontend-plugin-loader.js && node --check modules/ui-system/component-manifest.js && node --check modules/ui-system/vcp-ui.js && node --check modules/ui-system/webawesome-runtime-manifest.js && node --check modules/ui-system/webawesome-adapter.js && node --check modules/ui-system/appearance-engine.js && node --check modules/ui-system/appearance-studio.js && node --check modules/ui-system/next-ui-apps.js && node --check modules/ui-system/component-showcase.js && node --check modules/ui-system/webawesome-comparison.js && node --check modules/topTabManager.js && npm run lint:ui-system && npm run guard:ui-system && npm run check:ui-applications && npm run test:ui-system && npm run test:appearance-engine && npm run test:appearance-studio && node scripts/test-next-ui-tab-lifecycle.mjs && node scripts/test-webawesome-adapter.mjs && node --test tests/lifecycle-scope.test.js tests/next-ui-registries.test.mjs tests/topic-list-mode-lifecycle.test.js && npm run test:bootstrap", + "check:ui-system": "npm run guard:design-subtraction && npm run guard:classic-parity && npm run guard:classic-retirement && npm run guard:next-delta && npm run guard:ui-interaction && npm run guard:ui-async-state && npm run guard:ui-task-journeys && npm run guard:theme-provenance && npm run test:ui-motion-contract && node --check modules/ui-system/lifecycle-scope.js && node --check VCPDistributedServer/frontend-plugin-loader.js && node --check modules/ui-system/component-manifest.js && node --check modules/ui-system/vcp-ui.js && node --check modules/ui-system/webawesome-runtime-manifest.js && node --check modules/ui-system/webawesome-adapter.js && node --check modules/ui-system/appearance-engine.js && node --check modules/ui-system/appearance-studio.js && node --check modules/ui-system/next-ui-apps.js && node --check modules/ui-system/component-showcase.js && node --check modules/ui-system/webawesome-comparison.js && node --check modules/topTabManager.js && npm run lint:ui-system && npm run guard:ui-system && npm run check:ui-applications && npm run test:ui-system && npm run test:appearance-engine && npm run test:appearance-studio && node scripts/test-next-ui-tab-lifecycle.mjs && node scripts/test-webawesome-adapter.mjs && node --test tests/lifecycle-scope.test.js tests/next-ui-registries.test.mjs tests/topic-list-mode-lifecycle.test.js tests/topic-list-load-robustness.test.js tests/settings-unsaved-indicator.test.mjs && npm run test:bootstrap", "test:appearance-engine": "node scripts/test-appearance-engine.mjs", "test:appearance-studio": "node scripts/test-appearance-studio.mjs", "test:vcp-ui-select-proxy": "node scripts/test-vcp-ui-select-proxy.mjs", diff --git a/scripts/next-delta-shared-baseline.json b/scripts/next-delta-shared-baseline.json index 251a462fd..e246e27d2 100644 --- a/scripts/next-delta-shared-baseline.json +++ b/scripts/next-delta-shared-baseline.json @@ -12,20 +12,20 @@ "reason": "Integrated projection behavior: media cleanup before subtree replacement; nested tool-result ranges shield protocol markers; incremental smooth-stream burst handling preserves text order; durable persistence remains outside this projection." }, "modules/topicListManager.js": { - "sha256": "a06e63547574acb218ae131b1539ccc5fc6ac798d0cd0e2e0a0adb34677c8c16", - "reason": "Integrated topic behavior: configuration/unread loads validate the selected item; sort/title/menu operations capture their original topic target; completed deletion changes the active view only for the same item and topic. Repository, readiness and render listener ownership remain unchanged." + "sha256": "50b2475f546cde9b601fb37dcc77562513f57b27823287a64061e85858653b74", + "reason": "Integrated topic behavior: configuration/unread loads validate the selected item; sort/title/menu operations capture their original topic target; completed deletion changes the active view only for the same item and topic. Repository, readiness and render listener ownership remain unchanged. Topic search waits 200ms after typing before reloading (Enter is immediate); a rejected config IPC shows an error row instead of a permanent spinner; names and IPC errors render as text." }, "modules/itemListManager.js": { - "sha256": "6fb3f38f57edda8c4a9632d04fba6e1239c85e11064fc5214b58700e035850ae", - "reason": "Integrated catalog behavior: operation-counted speaking indicators survive list refreshes; catalog publication validates the final settings read; unread publication is ordered across navigation and catalog refreshes. Persona WIP is excluded." + "sha256": "ae5eca22dd19312f56d5c9d60b6a221c3a5be8cf2d34c1049407b45100cc5393", + "reason": "Integrated catalog behavior: operation-counted speaking indicators survive list refreshes; catalog publication validates the final settings read; unread publication is ordered across navigation and catalog refreshes. Persona WIP is excluded. Load error rows are HTML-escaped." }, "Groupmodules/grouprenderer.js": { - "sha256": "31f0d3defe8ba31ef1f9b6c41f9655b23fa02991671483b6ab658a3e3f8450eb", - "reason": "Integrated group behavior: JEV mode/context-window fields and invitations; explicit group queue interrupt; optimistic user message IDs survive file sync; settings read/save snapshots preserve the original group and mode data; chat header delegates to its presentation owner." + "sha256": "a88a5e4972d9b3739a8da8f300876f066dd2841bcfa8f41f6ffac664cc36cac8", + "reason": "Integrated group behavior: JEV mode/context-window fields and invitations; explicit group queue interrupt; optimistic user message IDs survive file sync; settings read/save snapshots preserve the original group and mode data; chat header delegates to its presentation owner. Group form tracks unsaved edits (including speaker reorder) and shows the same 未保存更改 indicator as Agent settings; a successful save clears it, loading a group resets it." }, "modules/settingsManager.js": { - "sha256": "ab7f07e0a7ef558b3c5ac8a04ff7d715912414d6cd8f0799f38160ac7e46c550", - "reason": "Integrated manual-save policy: Agent/prompt edits remain drafts until Save; context changes and collapse no longer implicitly persist; submit captures draft and display identity; regex import merges into the draft; Lucide renders action icons. Replaces the obsolete autosave rationale. Settings display uses a local selection snapshot; a delayed config read cannot overwrite a newer settings view (frozen-selection and A-to-B display regression tests)." + "sha256": "1d1a99372dba5059837c4a00d48ed8eb3e983f01323cb664750828e1254585f4", + "reason": "Integrated manual-save policy: Agent/prompt edits remain drafts until Save; context changes and collapse no longer implicitly persist; submit captures draft and display identity; regex import merges into the draft; Lucide renders action icons. Replaces the obsolete autosave rationale. Settings display uses a local selection snapshot; a delayed config read cannot overwrite a newer settings view (frozen-selection and A-to-B display regression tests). Regex rule add/edit/delete and MiMo director prompt add/remove mark the draft unsaved like ordinary field edits." }, "modules/filterManager.js": { "sha256": "2390116ff961f18cdf2d3defd02d5559de9381f891be7e5eb33ef99f98337225", diff --git a/tests/settings-unsaved-indicator.test.mjs b/tests/settings-unsaved-indicator.test.mjs new file mode 100644 index 000000000..fd42952e2 --- /dev/null +++ b/tests/settings-unsaved-indicator.test.mjs @@ -0,0 +1,238 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +// Agent and group settings save only on the Save button. Every edit has to +// show 未保存更改 before the user switches away, or the switch drops it +// without a trace. These cover the edits that bypass the form's own +// input/change events: the regex modal, MiMo add/remove, and group speaker +// reordering, plus the group form, which had no indicator at all. + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const mainHtml = fs.readFileSync(path.join(repoRoot, 'main.html'), 'utf8'); +const schema = await import(pathToFileURL(path.join(repoRoot, 'modules/settings/schema/sidebar-surfaces.js')).href); +const surfaceModule = await import(pathToFileURL(path.join(repoRoot, 'modules/ui-system/settings/settings-sidebar-surface.js')).href); +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); + +function withDom(run) { + return async () => { + const dom = new JSDOM('

    请选择

    ', { url: 'http://localhost' }); + const saved = { + window: globalThis.window, + document: globalThis.document, + requestAnimationFrame: globalThis.requestAnimationFrame, + MutationObserver: globalThis.MutationObserver, + CustomEvent: globalThis.CustomEvent, + setInterval: globalThis.setInterval + }; + const intervals = []; + globalThis.window = dom.window; + globalThis.document = dom.window.document; + globalThis.requestAnimationFrame = cb => setTimeout(cb, 0); + dom.window.requestAnimationFrame = cb => setTimeout(cb, 0); + globalThis.MutationObserver = dom.window.MutationObserver; + globalThis.CustomEvent = dom.window.CustomEvent; + globalThis.setInterval = (...args) => { + const id = saved.setInterval(...args); + id.unref?.(); + intervals.push(id); + return id; + }; + try { + await run(dom, dom.window.document); + } finally { + intervals.forEach(clearInterval); + Object.assign(globalThis, saved); + dom.window.close(); + } + }; +} + +function mountRegexModal(document) { + const match = mainHtml.match(/