feat(web-shell): customize context details and collapse snapshots (#11996)

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-09-16 08:01:01 +00:00
1 parent d0eebc9790
commit e4755ef6ab
9 files changed
+274 -5

No files matched your search

@@ -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.
@@ -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
渲染不变。扩展已有浏览器上下文压缩场景,并检查展开/收起截图。
+12
View File
@@ -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
<WebShell onContextUsageOpen={(sessionId) => openContextDetails(sessionId)} />
```
此参数也适用于 `WebShellWithProviders`。提供回调后,composer hover 弹层的
「查看明细」及页头上下文详情入口会将来源会话 ID 交给宿主,不再打开内置右侧
面板或自动读取详情;分屏传入对应分屏的会话 ID。不提供回调则保留默认行为。
直接点击上下文圆环生成 `/context` 快照、压缩操作和已保存面板的恢复不受影响。
宿主可以监听命令,也可以返回 `true` 接管对应操作:
```tsx
+68
View File
@@ -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' },
+8 -1
View File
@@ -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)
@@ -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;
}
@@ -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<HTMLButtonElement>(
'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.',
);
@@ -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 && (
<div className={styles.header}>
<div className={styles.title}>{t('contextUsage.title')}</div>
<span className={styles.secondary}>{t('contextUsage.snapshot')}</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={collapsed ? t('common.expand') : t('common.collapse')}
aria-expanded={!collapsed}
onClick={() => setCollapsed((value) => !value)}
>
{collapsed ? (
<ChevronDownIcon aria-hidden="true" />
) : (
<ChevronUpIcon aria-hidden="true" />
)}
</Button>
</div>
)}
<div className={styles.metaLine}>
@@ -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"]',
);