From e4755ef6abb53a83316076f0e9e53f5f08c1f844 Mon Sep 17 00:00:00 2001 From: ytahdn <1294726970@qq.com> Date: Wed, 16 Sep 2026 08:01:01 +0000 Subject: [PATCH] feat(web-shell): customize context details and collapse snapshots (#11996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 钉萁 --- .../web-shell-context-usage-callback.md | 51 ++++++++++++++ .../web-shell-context-usage-callback.zh-CN.md | 44 ++++++++++++ packages/web-shell/README.md | 12 ++++ packages/web-shell/client/App.test.tsx | 68 +++++++++++++++++++ packages/web-shell/client/App.tsx | 9 ++- .../messages/ContextUsageMessage.module.css | 5 ++ .../messages/ContextUsageMessage.test.tsx | 38 ++++++++++- .../messages/ContextUsageMessage.tsx | 19 +++++- .../e2e/web-shell.context-compression.spec.ts | 33 +++++++++ 9 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 docs/design/web-shell-context-usage-callback.md create mode 100644 docs/design/web-shell-context-usage-callback.zh-CN.md diff --git a/docs/design/web-shell-context-usage-callback.md b/docs/design/web-shell-context-usage-callback.md new file mode 100644 index 00000000000..1ba3265c2b0 --- /dev/null +++ b/docs/design/web-shell-context-usage-callback.md @@ -0,0 +1,51 @@ +# Web Shell context usage open callback + +[English](web-shell-context-usage-callback.md) | [简体中文](web-shell-context-usage-callback.zh-CN.md) + +## Problem and scope + +The composer context usage hover action opens Web Shell’s right panel. Embedded +hosts need to open their own detail surface. Main and split sessions already +share one context detail opener, also used by built-in and custom headers. + +## Design + +Add `onContextUsageOpen?: (sessionId: string) => void` to `WebShellProps`. +When supplied, the shared opener invokes it with the originating session ID and +returns before creating or selecting a built-in panel tab. Without the callback, +existing behavior is preserved. Both `WebShell` and `WebShellWithProviders` +expose the prop through their existing public types. + +The callback applies to composer details and header detail entries, including +split panes. It does not intercept the ring’s `/context` snapshot action, +compression, or restoration of already persisted panel tabs. It is an override, +not a notification; no return-value protocol or extra UI is needed. + +## Files and validation + +Update `client/App.tsx`, its existing tests, and the package README. Test callback +invocation and session ownership for main and split composers, absence of panel +creation or automatic detail reads, callback replacement/removal, and unchanged +default opening. Existing popover tests cover the hover action wiring. Run build, +typecheck, focused tests and a browser smoke check with the mock daemon. + +## Acceptance and risks + +Hosts receive the selected session ID once per detail action and can choose any +presentation. Omitted callbacks retain the current right panel behavior. The +shared opener also redirects header actions; this is intentional and documented. +No daemon routes or data contracts change. No open questions remain. + +## Snapshot collapse + +Transcript cards start expanded. An icon button immediately after Snapshot +collapses all content below the title row; expanding restores the current card. +Use the shared Button, existing translated expand/collapse labels and Lucide +chevrons. Keep the body mounted but hidden so nested detail expansion is retained. +The control exposes `aria-expanded` and works with Enter and Space. State is local +to each mounted card; no persistence or host option is added. Compact right-panel +content has no snapshot header or collapse control and remains visible. + +Verify default expansion, collapse to only the header, keyboard reopening, no +context request on toggle, and unchanged compact rendering. Extend the existing +browser context-compression scenario and inspect expanded/collapsed screenshots. diff --git a/docs/design/web-shell-context-usage-callback.zh-CN.md b/docs/design/web-shell-context-usage-callback.zh-CN.md new file mode 100644 index 00000000000..0c54df674ed --- /dev/null +++ b/docs/design/web-shell-context-usage-callback.zh-CN.md @@ -0,0 +1,44 @@ +# Web Shell 上下文详情打开回调 + +[English](web-shell-context-usage-callback.md) | [简体中文](web-shell-context-usage-callback.zh-CN.md) + +## 问题与范围 + +Composer 上下文用量的 hover 操作会打开 Web Shell 右侧面板。嵌入宿主需要 +改为打开自己的详情界面。主会话和分屏已共用一个详情打开入口,内置页头和 +自定义页头也使用它。 + +## 设计 + +为 `WebShellProps` 添加 `onContextUsageOpen?: (sessionId: string) => void`。 +提供回调时,共用入口将来源会话 ID 传给宿主,并在创建或选中内置面板标签前 +返回。不提供时保留现有行为。`WebShell` 和 `WebShellWithProviders` 通过 +现有公开类型暴露此参数。 + +回调适用于 composer 详情和页头详情入口,包括分屏。它不拦截圆环的 +`/context` 快照操作、压缩操作或已持久化面板标签的恢复。它是打开行为的 +替代,而非通知;无需返回值协议或额外 UI。 + +## 文件与验证 + +修改 `client/App.tsx`、现有测试和包 README。测试主会话与分屏 composer +的回调及会话归属、不创建面板或自动读取详情、回调替换和移除,以及默认打开 +行为不变。已有 popover 测试覆盖 hover 操作连线。执行构建、类型检查、 +定向测试,以及使用模拟 daemon 的浏览器冒烟验证。 + +## 验收与风险 + +宿主每次详情操作收到一次对应会话 ID,并自行决定展示方式。未提供回调时 +仍打开现有右侧面板。共用入口也会重定向页头操作,这是明确记录的预期行为。 +不改变 daemon 路由或数据协议。无待决问题。 + +## 历史快照折叠 + +消息卡片默认展开。在「历史快照」后添加图标按钮,收起标题行以下的全部内容; +展开后恢复现有卡片。使用共享 Button、已有中英文展开/收起文案及 Lucide 箭头。 +正文保持挂载但隐藏,保留内部明细的展开状态。按钮提供 `aria-expanded`, +支持 Enter 和空格操作。状态仅属于当前挂载的卡片,不增加持久化或宿主选项。 +右侧面板的 compact 内容没有快照标题和折叠按钮,保持显示。 + +验证默认展开、收起后只保留标题行、键盘重新展开、切换不请求上下文以及 compact +渲染不变。扩展已有浏览器上下文压缩场景,并检查展开/收起截图。 diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index ec3a4ab6226..fceafcfa4ae 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -366,6 +366,18 @@ const projection = projectChatRecordsToDaemonTranscript(records); | `onSlashCommand` | `(command: WebShellSlashCommand) => boolean \| void` | 斜杠命令进入默认处理前触发;返回 `true` 时由宿主接管并跳过默认行为 | | `onSessionArtifactsChange` | `(change: WebShellSessionArtifactsChange) => void` | Session Artifact 初始恢复或变化后返回当前完整快照与 turn 投影 | +宿主可以通过 `onContextUsageOpen?: (sessionId: string) => void` 接管上下文 +详情的打开操作: + +```tsx + openContextDetails(sessionId)} /> +``` + +此参数也适用于 `WebShellWithProviders`。提供回调后,composer hover 弹层的 +「查看明细」及页头上下文详情入口会将来源会话 ID 交给宿主,不再打开内置右侧 +面板或自动读取详情;分屏传入对应分屏的会话 ID。不提供回调则保留默认行为。 +直接点击上下文圆环生成 `/context` 快照、压缩操作和已保存面板的恢复不受影响。 + 宿主可以监听命令,也可以返回 `true` 接管对应操作: ```tsx diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 7c7dd6d79da..28e297ca22c 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -15136,6 +15136,74 @@ describe('App session callbacks', () => { }); }); + it.each(['main', 'split'] as const)( + 'delegates %s composer details to the host context usage callback', + async (entry) => { + const onContextUsageOpen = vi.fn(); + renderApp({ + header: { items: [] }, + ...(entry === 'split' ? { splitSessionIds: ['s1'] } : {}), + onContextUsageOpen, + }); + await flush(); + await act(async () => { + if (entry === 'split') { + testState.latestSplitViewProps!.onOpenContextUsage!( + 's1', + mockPaneSessionActions, + ); + } else { + testState.latestChatEditorProps!.onOpenContextUsage!(); + } + }); + await flush(); + expect(onContextUsageOpen).toHaveBeenCalledExactlyOnceWith( + entry === 'split' ? 's1' : 'session-1', + ); + expect(mockSessionActions.getContextUsage).not.toHaveBeenCalled(); + expect(mockPaneSessionActions.getContextUsage).not.toHaveBeenCalled(); + expect( + document.body.querySelector('button[title="Context Usage"]'), + ).toBeNull(); + }, + ); + + it('updates and removes the host context usage callback without changing snapshots', async () => { + const onContextUsageOpen = vi.fn(); + const replacement = vi.fn(); + const { rerender } = renderApp({ onContextUsageOpen }); + await flush(); + rerender({ onContextUsageOpen: replacement }); + await flush(); + await act(async () => + testState.latestChatEditorProps!.onOpenContextUsage!(), + ); + expect(replacement).toHaveBeenCalledExactlyOnceWith('session-1'); + expect(onContextUsageOpen).not.toHaveBeenCalled(); + await act(async () => + testState.latestChatEditorProps!.onShowContextUsage!(), + ); + expect(mockSessionActions.getContextUsage).toHaveBeenCalledWith({ + detail: false, + }); + expect(replacement).toHaveBeenCalledTimes(1); + mockSessionActions.getContextUsage.mockClear(); + rerender({}); + await flush(); + await act(async () => + testState.latestChatEditorProps!.onOpenContextUsage!(), + ); + await flush(); + expect(replacement).toHaveBeenCalledTimes(1); + expect(mockSessionActions.getContextUsage).toHaveBeenCalledWith({ + detail: true, + silent: true, + }); + expect( + document.body.querySelector('button[title="Context Usage"]'), + ).not.toBeNull(); + }); + it('opens details and compresses through the composer with the header entry hidden', async () => { mockConnection.commands = [ { name: 'compress', description: '', source: 'builtin-command' }, diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index b3acdce7239..845f16382f4 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -1199,6 +1199,8 @@ export interface WebShellProps { onWorkspaceFileOpen?: (path: string) => void; /** Open a completed Insight report in a host-native surface. */ onInsightReportOpen?: (path: string) => void; + /** Open context usage details in the host instead of the built-in panel. */ + onContextUsageOpen?: (sessionId: string) => void; /** * Controls which turn output cards appear below messages. Defaults to all. */ @@ -3052,6 +3054,7 @@ export function App({ onFileReviewOpen, onWorkspaceFileOpen, onInsightReportOpen, + onContextUsageOpen, messageTurnOutputs, shellRef, composerToolbarActions, @@ -5400,6 +5403,10 @@ export function App({ sourceSessionActions?: DaemonSessionActions, closeWithPane = false, ) => { + if (onContextUsageOpen) { + onContextUsageOpen(sourceSessionId); + return; + } const tab: ArtifactPanelTab = { id: `context-usage:${sourceSessionId}`, kind: 'context_usage', @@ -5421,7 +5428,7 @@ export function App({ ); setArtifactPanelOpen(true); }, - [getDefaultReviewPanelWidth, t], + [getDefaultReviewPanelWidth, onContextUsageOpen, t], ); const openCurrentContextUsagePanel = useCallback(() => { if (connection.sessionId) diff --git a/packages/web-shell/client/components/messages/ContextUsageMessage.module.css b/packages/web-shell/client/components/messages/ContextUsageMessage.module.css index e5f54977a63..a287f610abf 100644 --- a/packages/web-shell/client/components/messages/ContextUsageMessage.module.css +++ b/packages/web-shell/client/components/messages/ContextUsageMessage.module.css @@ -16,6 +16,10 @@ overflow-wrap: anywhere; } +.panel[data-collapsed='true'] > :not(.header) { + display: none; +} + .header { display: flex; align-items: center; @@ -25,6 +29,7 @@ } .title { + flex: 1; font-weight: 700; } diff --git a/packages/web-shell/client/components/messages/ContextUsageMessage.test.tsx b/packages/web-shell/client/components/messages/ContextUsageMessage.test.tsx index 0666c233408..6e9fec742c1 100644 --- a/packages/web-shell/client/components/messages/ContextUsageMessage.test.tsx +++ b/packages/web-shell/client/components/messages/ContextUsageMessage.test.tsx @@ -74,6 +74,37 @@ function render( } describe('ContextUsageMessage', () => { + it.each(['en', 'zh-CN'] as const)( + 'toggles only the snapshot body without requesting context (%s)', + (language) => { + const read = vi.fn(); + const container = render(makeStatus(60, false), false, read, language); + const card = container.querySelector('section')!; + const toggle = container.querySelector( + 'button[aria-expanded]', + )!; + const meter = container.querySelector('[data-web-shell-context-meter]'); + expect(toggle).not.toBeNull(); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(toggle.getAttribute('aria-label')).toBe( + language === 'en' ? 'Collapse' : '收起', + ); + act(() => toggle.click()); + expect(card.getAttribute('data-collapsed')).toBe('true'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.getAttribute('aria-label')).toBe( + language === 'en' ? 'Expand' : '展开', + ); + act(() => toggle.click()); + expect(card.getAttribute('data-collapsed')).toBe('false'); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(container.querySelector('[data-web-shell-context-meter]')).toBe( + meter, + ); + expect(read).not.toHaveBeenCalled(); + }, + ); + it('separates remaining capacity from free space and clamps exhausted capacity', () => { const container = render(makeStatus(60, false)); expect(container.querySelector('[class*="total"]')?.textContent).toBe( @@ -110,7 +141,7 @@ describe('ContextUsageMessage', () => { const read = vi.fn(); const container = render(status, false, read); expect(container.textContent).toContain('Snapshot'); - const button = container.querySelector('button')!; + const button = container.querySelector('button:not([aria-expanded])')!; expect(button.textContent).toBe('View current context'); act(() => button.click()); expect(read).toHaveBeenCalledOnce(); @@ -218,6 +249,7 @@ describe('ContextUsageMessage', () => { it('suppresses its own title in compact mode so the panel toolbar is the only heading', () => { const compactContainer = render(makeStatus(60, false), true); expect(compactContainer.querySelector('[class*="title"]')).toBeNull(); + expect(compactContainer.querySelector('button[aria-expanded]')).toBeNull(); expect(compactContainer.querySelector('section[aria-label]')).toBeNull(); expect(compactContainer.querySelector('section[role]')).toBeNull(); expect(compactContainer.querySelector('[class*="compact"]')).not.toBeNull(); @@ -265,12 +297,12 @@ describe('ContextUsageMessage', () => { const onShowDetail = vi.fn(); const status = makeStatus(60, false); const container = render(status, false, onShowDetail); - const button = container.querySelector('button')!; + const button = container.querySelector('button:not([aria-expanded])')!; expect(button.textContent).toBe('View details'); act(() => button.click()); expect(onShowDetail).toHaveBeenCalledTimes(1); const readOnly = render(status); - expect(readOnly.querySelector('button')).toBeNull(); + expect(readOnly.querySelector('button:not([aria-expanded])')).toBeNull(); expect(readOnly.textContent).toContain( 'Run /context detail for per-item breakdown.', ); diff --git a/packages/web-shell/client/components/messages/ContextUsageMessage.tsx b/packages/web-shell/client/components/messages/ContextUsageMessage.tsx index 52a63eb9898..bd4a6d03372 100644 --- a/packages/web-shell/client/components/messages/ContextUsageMessage.tsx +++ b/packages/web-shell/client/components/messages/ContextUsageMessage.tsx @@ -1,4 +1,5 @@ -import type { ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; +import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; import type { DaemonContextMemoryDetail, DaemonContextSkillDetail, @@ -266,6 +267,7 @@ export function ContextUsageMessage({ compact?: boolean; }) { const { t } = useI18n(); + const [collapsed, setCollapsed] = useState(false); const { usage } = status; const { breakdown, contextWindowSize } = usage; const hasTokenCount = usage.totalTokens > 0; @@ -289,11 +291,26 @@ export function ContextUsageMessage({ className={`${styles.panel}${compact ? ` ${styles.compact}` : ''}`} role={compact ? undefined : 'group'} aria-label={compact ? undefined : t('contextUsage.title')} + data-collapsed={!compact && collapsed} > {!compact && (
{t('contextUsage.title')}
{t('contextUsage.snapshot')} +
)}
diff --git a/packages/web-shell/client/e2e/web-shell.context-compression.spec.ts b/packages/web-shell/client/e2e/web-shell.context-compression.spec.ts index 04cf2afba6e..b792c61189b 100644 --- a/packages/web-shell/client/e2e/web-shell.context-compression.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.context-compression.spec.ts @@ -110,6 +110,39 @@ for (const theme of ['light', 'dark']) { .getByRole('group', { name: 'Context Usage', exact: true }) .first(); await expect(historical).toContainText('Snapshot'); + const snapshotMeter = historical.locator('[data-web-shell-context-meter]'); + await expect(snapshotMeter).toBeVisible(); + const readsBeforeCollapse = reads; + await historical + .getByRole('button', { name: 'Collapse', exact: true }) + .click(); + await expect(snapshotMeter).toBeHidden(); + await expect(historical).toHaveText('Context Usage Snapshot', { + useInnerText: true, + }); + const expandSnapshot = historical.getByRole('button', { + name: 'Expand', + exact: true, + }); + await expect(expandSnapshot).toHaveAttribute('aria-expanded', 'false'); + await historical.screenshot({ + path: testInfo.outputPath(`context-snapshot-collapsed-${theme}.png`), + }); + await expandSnapshot.press('Enter'); + await expect(snapshotMeter).toBeVisible(); + const collapseSnapshot = historical.getByRole('button', { + name: 'Collapse', + exact: true, + }); + await expect(collapseSnapshot).toHaveAttribute('aria-expanded', 'true'); + await historical.screenshot({ + path: testInfo.outputPath(`context-snapshot-expanded-${theme}.png`), + }); + await collapseSnapshot.press('Space'); + await expect(snapshotMeter).toBeHidden(); + await expandSnapshot.click(); + await expect(snapshotMeter).toBeVisible(); + expect(reads).toBe(readsBeforeCollapse); const editor = page.locator( '[data-web-shell-composer-surface] .cm-content[contenteditable="true"]', );