mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(web-shell): customize context details and collapse snapshots (#11996)
Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
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
|
||||
渲染不变。扩展已有浏览器上下文压缩场景,并检查展开/收起截图。
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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"]',
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user