feat(web-shell): expose session artifact snapshots (#10747)

* feat(web-shell): expose session artifact snapshots

* fix(web-shell): re-arm artifact projection after reconnect

* fix(web-shell): deliver unprojected artifacts

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: 易良 <1204183885@qq.com>
This commit is contained in:
dreamWB 2026-09-03 09:33:15 +00:00 • committed by GitHub
parent a2ed9928bb
commit f4b49b0489
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 994 additions and 160 deletions

View file

@ -0,0 +1,102 @@
# Web Shell Session Artifact Snapshots
## Status
This design supersedes the assistant-turn settlement proposal in PR #10398.
Artifact delivery is session state, not assistant-turn settlement. It must not
depend on prompt status, terminal events, transcript repair, or a settlement
ledger.
## Requirements
Embedding hosts need an optional `onSessionArtifactsChange` callback with the
complete Artifact state for the current session. Delivery is eventually
consistent, but an SSE gap or Artifact/transcript arrival order must not hide
an Artifact.
```ts
interface WebShellSessionArtifactsChange {
reason: 'restore' | 'change';
sessionId: string;
sequence: number;
artifacts: readonly DaemonSessionArtifact[];
artifactsByTurn: ReadonlyMap<string, readonly DaemonSessionArtifact[]>;
}
```
`restore` is the first hydrated snapshot for a newly entered session after its
transcript is ready, and may be empty. `change` represents an
`artifact_changed` refresh, different same-session reconnect reconciliation,
or later turn-projection change. A real change takes precedence over an
undelivered restore.
`sequence` starts at 1 for each newly entered session. Payloads are complete
replacements, not incremental patches.
## Design
### Artifact hydration and reconciliation
The existing Artifact hook remains the sole snapshot owner. It will:
1. hydrate once for a connected, supported, non-catch-up session;
2. refresh whenever `artifactsVersion` changes;
3. reconcile after reconnect or catch-up;
4. preserve last-good state across a short disconnect;
5. discard stale request generations and session owners; and
6. allow later events or reconnects to recover failed hydration.
Prompt status is not an Artifact trigger. The provider's existing version
counter becomes session-scoped only for Artifact events; its workspace
counters retain their current ownership. No route, event payload, transcript
repair, timer, or settlement state is added.
### Host delivery
A small hook observes the hydrated snapshot and current turn projection. It
keeps a pending reason, delivered signature, per-session sequence, and
observed `artifactsVersion`.
Delivery waits until connected and transcript load/catch-up has ended. The
signature canonically includes every enumerable Artifact field and turn
assignment, including future fields and projection changes.
Live Artifacts are delivered as soon as their hydrated snapshot is ready. If
their turn projection arrives later, the updated full snapshot is delivered
again. This avoids hiding subagent Artifacts whose nested tool event is not
retained in the summary transcript.
Identical snapshots are suppressed. Listener exceptions are reported without
changing built-in Artifact UI state.
### Session ownership
A session-id change resets signature and sequence. Same-id owner replacement
preserves both while stale loads remain guarded by owner/request generation.
Reconnect reconciliation emits `change` only for a different full snapshot.
## Scope
Non-goals are incremental payloads, IDE concepts, Split View aggregation,
live-journal or prompt-settlement changes, new routes or event schemas, and
compatibility wrappers for the unpublished settlement callback.
## Test plan
- **TC-01:** Initial empty and historical sessions deliver `restore`.
- **TC-02:** An initially empty session's first Artifact delivers `change`.
- **TC-03:** An Artifact is delivered before a missing tool-block projection,
then delivered again when that projection appears.
- **TC-04:** Metadata, projection, and consecutive changes deliver complete
snapshots; canonical duplicates do not.
- **TC-05:** Reconnect/catch-up finds missed changes without a second restore.
- **TC-06:** Workspace, standalone, and live Artifact events increment the same
version while other counters remain workspace-scoped.
- **TC-07:** Late loads, failed refreshes, and throwing listeners stay isolated.
- **TC-08:** No prompt/terminal state is used; Artifact rendering remains green.
## Budget and stop conditions
Expected change: about 280 production, 450 test, and 170 documentation lines.
The hard stop is 300 production lines, 900 total additions, or any need to
modify live-journal repair, prompt settlement, daemon routes, or event schemas.

View file

@ -226,15 +226,16 @@ const projection = projectChatRecordsToDaemonTranscript(records);
### WebShell
| 属性 | 类型 | 说明 |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `onSessionIdChange` | `(sessionId: string \| undefined, workspaceId?: string, workspaceCwd?: string, sessionContext?: DaemonProductSessionContext) => void` | 当前 session、工作区或显式产品上下文变化时触发;standalone 和 Live 通过第四个参数上报 |
| `onSessionCreated` | `(sessionId: string) => Promise<void> \| void` | 新 session 创建后触发;完成前会阻塞 session 初始化和 prompt 提交,最长等待 30 秒 |
| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` |
| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 |
| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 |
| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 |
| `onSlashCommand` | `(command: WebShellSlashCommand) => boolean \| void` | 斜杠命令进入默认处理前触发;返回 `true` 时由宿主接管并跳过默认行为 |
| 属性 | 类型 | 说明 |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `onSessionIdChange` | `(sessionId: string \| undefined, workspaceId?: string, workspaceCwd?: string, sessionContext?: DaemonProductSessionContext) => void` | 当前 session、工作区或显式产品上下文变化时触发;standalone 和 Live 通过第四个参数上报 |
| `onSessionCreated` | `(sessionId: string) => Promise<void> \| void` | 新 session 创建后触发;完成前会阻塞 session 初始化和 prompt 提交,最长等待 30 秒 |
| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` |
| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 |
| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 |
| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 |
| `onSlashCommand` | `(command: WebShellSlashCommand) => boolean \| void` | 斜杠命令进入默认处理前触发;返回 `true` 时由宿主接管并跳过默认行为 |
| `onSessionArtifactsChange` | `(change: WebShellSessionArtifactsChange) => void` | Session Artifact 初始恢复或变化后返回当前完整快照与 turn 投影 |
宿主可以监听命令,也可以返回 `true` 接管对应操作:
@ -252,6 +253,36 @@ const projection = projectChatRecordsToDaemonTranscript(records);
命令名后必须是空白或输入结束,因此 `/usr/local/bin/tool` 等绝对路径不会触发
回调。如果回调抛出异常,Web Shell 会报告错误并继续执行默认命令流程。
宿主需要同步 Session 产物时,可以监听完整快照:
```tsx
<WebShell
onSessionArtifactsChange={({
reason,
sessionId,
sequence,
artifacts,
artifactsByTurn,
}) => {
replaceSessionArtifacts({
reason,
sessionId,
sequence,
artifacts,
artifactsByTurn,
});
}}
/>
```
`restore` 表示进入新 Session 后的首次恢复,可携带空快照;`change` 表示实时
Artifact 变化、同 Session 重连对账发现的差异,或 transcript 补齐后的 turn
归属变化。每次都返回完整 `artifacts` 和 `artifactsByTurn`,不提供增量。新 Artifact
可能先于 turn 归属交付,归属稍后补齐时会再交付一份完整快照。回调会等待 transcript
load/catch-up 结束;同 Session 短暂断线保留去重基线并主动对账,因此宿主应接受重复
交付,但不需要从 SSE 时序推断遗漏。`sequence` 在每个新 Session 从 1 开始。宿主回调
抛错不会影响 WebShell 内置 Artifact 面板与轮末渲染。
嵌入宿主只展示普通任务会话时,可以隐藏 Sidebar 的“任务 / 频道”来源切换:
```tsx

View file

@ -23798,8 +23798,8 @@ describe('App session callbacks', () => {
expect(document.body.textContent).toContain('Main artifact');
expect(document.body.textContent).toContain('10 B');
// A transient disconnect empties the live artifact list; the cached
// open-time row keeps the tab renderable through the gap.
// A transient disconnect keeps the last-good live Artifact snapshot and
// the open-time row remains renderable through the gap.
mockConnection.status = 'disconnected';
await act(async () => {
rerender();
@ -23822,6 +23822,40 @@ describe('App session callbacks', () => {
mockSessionActions.loadArtifacts.mockResolvedValue({ artifacts: [] });
});
it('delivers the main-session Artifact snapshot to the embedding host', async () => {
mockConnection.capabilities = {
...mockConnection.capabilities,
features: ['session_artifacts'],
};
const artifact = {
id: 'host-artifact',
kind: 'html',
storage: 'workspace',
source: 'tool',
status: 'available',
title: 'Host artifact',
workspacePath: 'host.html',
clientRetained: false,
createdAt: '2026-09-02T00:00:00.000Z',
updatedAt: '2026-09-02T00:00:00.000Z',
};
mockSessionActions.loadArtifacts.mockResolvedValue({
artifacts: [artifact],
});
const onSessionArtifactsChange = vi.fn();
renderApp({ onSessionArtifactsChange });
await flush();
expect(onSessionArtifactsChange).toHaveBeenCalledWith({
reason: 'restore',
sessionId: 'session-1',
sequence: 1,
artifacts: [artifact],
artifactsByTurn: new Map(),
});
mockSessionActions.loadArtifacts.mockResolvedValue({ artifacts: [] });
});
it('opens a split pane monitor in the right panel', async () => {
const { container } = renderApp();
await flush();

View file

@ -236,6 +236,7 @@ import {
useMessagesFromBlocks,
} from './hooks/useMessages';
import { useSessionArtifacts } from './hooks/useSessionArtifacts';
import { useSessionArtifactsChange } from './hooks/useSessionArtifactsChange';
import { useShallowMemo, useStableArray } from './hooks/useShallowMemo';
import {
I18nProvider,
@ -371,6 +372,7 @@ import {
type WebShellBottomStatusItem,
type WebShellPreparedSubmit,
type WebShellSubmitSnapshot,
type WebShellSessionArtifactsChange,
} from './customization';
import type { CommandDisplayCategoryOrder } from './utils/commandDisplay';
import { WebShellPortalRootContext } from './portalRoot';
@ -1128,6 +1130,8 @@ export interface WebShellProps {
* at most once per animation frame during active generation.
*/
onTranscriptChange?: (blocks: readonly DaemonTranscriptBlock[]) => void;
/** Called with the complete Artifact snapshot after restore or change. */
onSessionArtifactsChange?: (change: WebShellSessionArtifactsChange) => void;
/**
* Called when a critical error occurs (auth failure, session gone, etc).
* Each distinct connection error value is reported once; reporting resets
@ -2207,6 +2211,7 @@ export function App({
loadingPhrases,
onAgentTasksChange,
onTranscriptChange,
onSessionArtifactsChange,
onToast,
composerRef,
onComposerReady,
@ -3226,6 +3231,7 @@ export function App({
artifacts,
loading: artifactsLoading,
error: artifactsError,
hydrated: artifactsHydrated,
} = useSessionArtifacts();
const [artifactPanelExtraArtifacts, setArtifactPanelExtraArtifacts] =
useState<DaemonSessionArtifact[]>([]);
@ -3352,6 +3358,18 @@ export function App({
),
[displayMessages, artifacts, connection.workspaceCwd],
);
useSessionArtifactsChange({
sessionId: connection.sessionId,
ready:
connection.status === 'connected' &&
!connection.loadingTranscript &&
!connection.catchingUp &&
!artifactsLoading,
hydrated: artifactsHydrated,
artifacts,
artifactsByTurn,
onChange: onSessionArtifactsChange,
});
const fileChangesByTurn = useMemo(
() =>
getFileChangesByTurn(

View file

@ -13,6 +13,7 @@ import type {
import type { MarkdownChartReactErrorHandler } from '@datafe-open/markdown-chart-react';
import type {
DaemonInputAnnotation,
DaemonSessionArtifact,
GoalSnapshotV2,
} from '@qwen-code/sdk/daemon';
import type { DaemonStreamingState } from '@qwen-code/web-shell/daemon-react-sdk';
@ -227,6 +228,16 @@ export interface WebShellAssistantTurnFooterRenderInfo {
message: WebShellAssistantMessageInfo;
}
export type WebShellSessionArtifactsChangeReason = 'restore' | 'change';
export interface WebShellSessionArtifactsChange {
reason: WebShellSessionArtifactsChangeReason;
sessionId: string;
sequence: number;
artifacts: readonly DaemonSessionArtifact[];
artifactsByTurn: ReadonlyMap<string, readonly DaemonSessionArtifact[]>;
}
export type AssistantTurnFooterRenderer = (
info: WebShellAssistantTurnFooterRenderInfo,
) => ReactNode | null | undefined;

View file

@ -5195,73 +5195,115 @@ describe('DaemonSessionProvider', () => {
});
});
it('does not invalidate workspace data from standalone session events', async () => {
sdkMocks.capabilities.mockResolvedValue({
workspaceCwd: '/primary',
features: ['standalone_sessions_v1'],
});
sdkMocks.sessions.push(
createMockSession({
sessionId: 'standalone-signals',
workspaceCwd: '/private/standalone-signals',
session: {
sessionId: 'standalone-signals',
workspaceCwd: '/private/standalone-signals',
sourceType: 'standalone',
context: { kind: 'standalone' },
workingDirectory: { state: 'ready' },
},
events: async function* standaloneWorkspaceEvents() {
yield {
id: 21,
v: 1,
type: 'memory_changed',
data: {
scope: 'workspace',
filePath: '/private/standalone-signals/QWEN.md',
mode: 'append',
bytesWritten: 12,
},
} satisfies DaemonEvent;
yield {
id: 22,
v: 1,
type: 'settings_changed',
data: {
key: 'ui.theme',
scope: 'workspace',
value: 'Qwen Dark',
},
} satisfies DaemonEvent;
},
}),
);
let signals: DaemonWorkspaceEventSignals | undefined;
it.each(['standalone', 'live'] as const)(
'only invalidates session artifacts from %s session events',
async (kind) => {
const sessionId = `${kind}-signals`;
const workspaceCwd = `/private/${sessionId}`;
sdkMocks.capabilities.mockResolvedValue({
workspaceCwd: '/primary',
features: ['standalone_sessions_v1', 'multi_workspace_sessions'],
workspaces: [
{ id: 'primary', cwd: '/primary', primary: true, trusted: true },
{
id: 'live',
cwd: workspaceCwd,
kind: 'live',
primary: false,
trusted: true,
},
],
});
sdkMocks.sessions.push(
createMockSession({
sessionId,
workspaceCwd,
...(kind === 'standalone'
? {
session: {
sessionId,
workspaceCwd,
sourceType: 'standalone',
context: { kind: 'standalone' as const },
workingDirectory: { state: 'ready' as const },
},
}
: {}),
events: async function* standaloneWorkspaceEvents() {
yield {
id: 21,
v: 1,
type: 'memory_changed',
data: {
scope: 'workspace',
filePath: `${workspaceCwd}/QWEN.md`,
mode: 'append',
bytesWritten: 12,
},
} satisfies DaemonEvent;
yield {
id: 22,
v: 1,
type: 'settings_changed',
data: {
key: 'ui.theme',
scope: 'workspace',
value: 'Qwen Dark',
},
} satisfies DaemonEvent;
yield {
id: 23,
v: 1,
type: 'artifact_changed',
data: {
sessionId,
change: {
action: 'created',
artifactId: 'artifact-1',
artifact: {
id: 'artifact-1',
kind: 'html',
storage: 'workspace',
source: 'tool',
status: 'available',
title: 'Report',
workspacePath: 'report.html',
createdAt: '2026-09-02T00:00:00.000Z',
updatedAt: '2026-09-02T00:00:00.000Z',
},
},
},
} satisfies DaemonEvent;
},
}),
);
let signals: DaemonWorkspaceEventSignals | undefined;
function Harness() {
signals = useDaemonWorkspaceEventSignals();
return null;
}
function Harness() {
signals = useDaemonWorkspaceEventSignals();
return null;
}
await renderWithProvider(<Harness />, {
autoConnect: true,
sessionId: 'standalone-signals',
sessionContext: { kind: 'standalone' },
});
await act(async () => flushPromises());
await renderWithProvider(<Harness />, {
autoConnect: true,
sessionId,
sessionContext: { kind },
});
await act(async () => flushPromises());
expect(signals).toMatchObject({
memoryVersion: 0,
agentsVersion: 0,
toolsVersion: 0,
settingsVersion: 0,
skillsVersion: 0,
mcpVersion: 0,
artifactsVersion: 0,
initVersion: 0,
authVersion: 0,
});
});
expect(signals).toMatchObject({
memoryVersion: 0,
agentsVersion: 0,
toolsVersion: 0,
settingsVersion: 0,
skillsVersion: 0,
mcpVersion: 0,
artifactsVersion: 1,
initVersion: 0,
authVersion: 0,
});
},
);
it('deduplicates skill toggle settings events from the generic settings signal', async () => {
const mutation = {
@ -9735,6 +9777,15 @@ describe('DaemonSessionProvider', () => {
level: 'project',
},
},
{
id: 3,
v: 1,
type: 'artifact_changed',
data: {
sessionId: 'session-1',
change: { action: 'removed', artifactId: 'artifact-1' },
},
},
],
liveJournal: [],
},
@ -9761,6 +9812,7 @@ describe('DaemonSessionProvider', () => {
agentsVersion: 1,
toolsVersion: 0,
mcpVersion: 0,
artifactsVersion: 0,
initVersion: 0,
authVersion: 0,
});

View file

@ -2425,11 +2425,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
const sideEffectEvents = eventGroups.flatMap(
(group) => group.sideEffects,
);
if (activeWorkspaceScoped && sideEffectEvents.length > 0) {
bumpWorkspaceEventSignals(
if (sideEffectEvents.length > 0) {
bumpSessionEventSignals(
sideEffectEvents,
setWorkspaceEventSignals,
activeSession.workspaceCwd,
activeWorkspaceScoped ? activeSession.workspaceCwd : undefined,
false,
);
}
if (replayExceededCapacity) {
@ -2958,13 +2959,13 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
break;
}
}
if (activeWorkspaceScoped) {
bumpWorkspaceEventSignals(
uiEvents,
setWorkspaceEventSignals,
activeProductSessionContext.cwd,
);
}
bumpSessionEventSignals(
uiEvents,
setWorkspaceEventSignals,
activeWorkspaceScoped
? activeProductSessionContext.cwd
: undefined,
);
if (uiEvents.length > 0) {
const hasGenerationSignal = hasActiveGenerationSignal(uiEvents);
setPromptStatus((current) =>
@ -4951,10 +4952,11 @@ function getNumber(
return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined;
}
function bumpWorkspaceEventSignals(
function bumpSessionEventSignals(
events: readonly DaemonUiEvent[],
setSignals: Dispatch<SetStateAction<DaemonWorkspaceEventSignals>>,
workspaceCwd: string,
workspaceCwd?: string,
includeArtifactEvents = true,
): void {
let memory = 0;
let agents = 0;
@ -4974,6 +4976,11 @@ function bumpWorkspaceEventSignals(
let auth = 0;
for (const event of events) {
if (event.type === 'session.artifact.changed') {
if (includeArtifactEvents) artifacts += 1;
continue;
}
if (!workspaceCwd) continue;
switch (event.type) {
case 'workspace.memory.changed':
memory += 1;
@ -5013,9 +5020,6 @@ function bumpWorkspaceEventSignals(
failed: event.failed,
};
break;
case 'session.artifact.changed':
artifacts += 1;
break;
case 'workspace.initialized':
init += 1;
break;
@ -5047,7 +5051,9 @@ function bumpWorkspaceEventSignals(
return;
setSignals((current) => {
const existing = current.skillMutationsByCwd?.[workspaceCwd] ?? [];
const existing = workspaceCwd
? (current.skillMutationsByCwd?.[workspaceCwd] ?? [])
: [];
const existingIds = new Set(existing.map((mutation) => mutation.id));
const newSkillMutations = skillMutations.filter(
(mutation) => !existingIds.has(mutation.id),
@ -5081,7 +5087,7 @@ function bumpWorkspaceEventSignals(
: current.lastSkillMutation
? { lastSkillMutation: current.lastSkillMutation }
: {}),
...(newSkillMutations.length > 0
...(newSkillMutations.length > 0 && workspaceCwd
? {
skillMutationsByCwd: {
...current.skillMutationsByCwd,

View file

@ -33,15 +33,14 @@ const sdkMock = vi.hoisted(() => ({
status: 'connected',
sessionId: 'session-a',
capabilities: { features: ['session_artifacts'] },
catchingUp: false,
},
promptStatus: 'idle',
artifactsVersion: 0,
}));
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
useActions: () => sdkMock.actions,
useConnection: () => sdkMock.connection,
usePromptStatus: () => sdkMock.promptStatus,
useDaemonSessionOwnerGuard: () => sdkMock.ownerGuard,
useWorkspaceEventSignals: () => ({
artifactsVersion: sdkMock.artifactsVersion,
@ -106,8 +105,8 @@ beforeEach(() => {
status: 'connected',
sessionId: 'session-a',
capabilities: { features: ['session_artifacts'] },
catchingUp: false,
};
sdkMock.promptStatus = 'idle';
sdkMock.artifactsVersion = 0;
sdkMock.ownerVersion = 0;
sdkMock.ownerGuard.capture.mockImplementation(() => {
@ -149,6 +148,7 @@ describe('useSessionArtifacts', () => {
status: 'connected',
sessionId: 'session-b',
capabilities: { features: ['session_artifacts'] },
catchingUp: false,
};
sdkMock.ownerVersion += 1;
await rerenderHookHost();
@ -236,13 +236,11 @@ describe('useSessionArtifacts', () => {
const load2 = deferred<{ artifacts: DaemonSessionArtifact[] }>();
const load3 = deferred<{ artifacts: DaemonSessionArtifact[] }>();
const load4 = deferred<{ artifacts: DaemonSessionArtifact[] }>();
const load5 = deferred<{ artifacts: DaemonSessionArtifact[] }>();
sdkMock.actions.loadArtifacts
.mockReturnValueOnce(load1.promise)
.mockReturnValueOnce(load2.promise)
.mockReturnValueOnce(load3.promise)
.mockReturnValueOnce(load4.promise)
.mockReturnValueOnce(load5.promise);
.mockReturnValueOnce(load4.promise);
await renderHookHost();
expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(1);
@ -269,11 +267,8 @@ describe('useSessionArtifacts', () => {
]);
expect(latestState?.loading).toBe(false);
// Automatic refresh #3: prompt settling back to idle fails transiently.
sdkMock.promptStatus = 'streaming';
await rerenderHookHost();
expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(2);
sdkMock.promptStatus = 'idle';
// Automatic refresh #3: artifactsVersion fails and keeps last-good.
sdkMock.artifactsVersion = 2;
await rerenderHookHost();
expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(3);
await act(async () => {
@ -286,31 +281,17 @@ describe('useSessionArtifacts', () => {
'current-artifact',
]);
// Automatic refresh #4: artifactsVersion fails and still keeps last-good.
sdkMock.artifactsVersion = 2;
await rerenderHookHost();
expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(4);
await act(async () => {
load4.reject(new Error('Failed to fetch'));
await load4.promise.catch(() => undefined);
});
expect(latestState?.loading).toBe(false);
expect(latestState?.error).toBeNull();
expect(latestState?.artifacts.map((item) => item.id)).toEqual([
'current-artifact',
]);
// Re-rendering the same version is not a new automatic refresh.
await rerenderHookHost();
expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(4);
expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(3);
// The panel is not wedged: the next automatic refresh recovers.
sdkMock.artifactsVersion = 3;
await rerenderHookHost();
expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(5);
expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(4);
await act(async () => {
load5.resolve({ artifacts: [artifact('recovered-artifact')] });
await load5.promise;
load4.resolve({ artifacts: [artifact('recovered-artifact')] });
await load4.promise;
});
expect(latestState?.artifacts.map((item) => item.id)).toEqual([
'recovered-artifact',
@ -500,16 +481,12 @@ describe('useSessionArtifacts', () => {
]);
});
it('refreshes when the prompt settles from waiting to idle (#7427)', async () => {
// `'waiting'` is a real prompt status (queued prompt / observer text
// deltas before any generation signal); the settling trigger must not
// be specialized to `'streaming'` (#7427 review).
it('reconciles after same-session catch-up without clearing last-good artifacts', async () => {
const initialLoad = deferred<{ artifacts: DaemonSessionArtifact[] }>();
const settleLoad = deferred<{ artifacts: DaemonSessionArtifact[] }>();
const reconcileLoad = deferred<{ artifacts: DaemonSessionArtifact[] }>();
sdkMock.actions.loadArtifacts
.mockReturnValueOnce(initialLoad.promise)
.mockReturnValueOnce(settleLoad.promise);
sdkMock.promptStatus = 'waiting';
.mockReturnValueOnce(reconcileLoad.promise);
await renderHookHost();
await act(async () => {
@ -518,15 +495,23 @@ describe('useSessionArtifacts', () => {
});
expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(1);
sdkMock.promptStatus = 'idle';
sdkMock.connection.catchingUp = true;
sdkMock.artifactsVersion += 1;
await rerenderHookHost();
expect(latestState?.artifacts.map((item) => item.id)).toEqual([
'current-artifact',
]);
expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(1);
sdkMock.connection.catchingUp = false;
await rerenderHookHost();
expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(2);
await act(async () => {
settleLoad.resolve({ artifacts: [artifact('settled-artifact')] });
await settleLoad.promise;
reconcileLoad.resolve({ artifacts: [artifact('reconciled-artifact')] });
await reconcileLoad.promise;
});
expect(latestState?.artifacts.map((item) => item.id)).toEqual([
'settled-artifact',
'reconciled-artifact',
]);
});
});

View file

@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useActions,
useConnection,
usePromptStatus,
useDaemonSessionOwnerGuard,
useWorkspaceEventSignals,
} from '@qwen-code/web-shell/daemon-react-sdk';
@ -21,6 +20,7 @@ export interface SessionArtifactsState {
artifactById: ReadonlyMap<string, DaemonSessionArtifact>;
loading: boolean;
error: string | null;
hydrated: boolean;
refresh: () => Promise<void>;
}
@ -31,7 +31,6 @@ export function useSessionArtifacts(): SessionArtifactsState {
const ownerRef = useRef(ownerGuard.capture());
if (!ownerRef.current?.isCurrent()) ownerRef.current = ownerGuard.capture();
const owner = ownerRef.current;
const promptStatus = usePromptStatus();
const workspaceEventSignals = useWorkspaceEventSignals();
const artifactsVersion = workspaceEventSignals?.artifactsVersion;
const isConnected = connection.status === 'connected';
@ -43,50 +42,61 @@ export function useSessionArtifacts(): SessionArtifactsState {
const [loading, setLoading] = useState(false);
const requestIdRef = useRef(0);
const loadedOwnerRef = useRef<typeof owner | undefined>(undefined);
const loadedSessionIdRef = useRef<string | undefined>(undefined);
const loadingOwnerRef = useRef<typeof owner | undefined>(undefined);
const previousPromptStatusRef = useRef(promptStatus);
const loadingSessionIdRef = useRef<string | undefined>(undefined);
const previousArtifactsVersionRef = useRef(artifactsVersion);
const refresh = useCallback(async () => {
const requestId = ++requestIdRef.current;
if (!sessionId || !isConnected || !supportsArtifacts) {
if (!sessionId) {
loadedOwnerRef.current = undefined;
loadedSessionIdRef.current = undefined;
loadingOwnerRef.current = undefined;
loadingSessionIdRef.current = undefined;
setArtifacts([]);
setLoading(false);
return;
}
if (loadedOwnerRef.current !== owner) setArtifacts([]);
if (!isConnected || connection.catchingUp || !supportsArtifacts) {
loadingOwnerRef.current = undefined;
loadingSessionIdRef.current = undefined;
setLoading(false);
return;
}
if (
loadedOwnerRef.current !== owner ||
loadedSessionIdRef.current !== sessionId
) {
setArtifacts([]);
}
loadingOwnerRef.current = owner;
loadingSessionIdRef.current = sessionId;
setLoading(true);
try {
const result = await actions.loadArtifacts();
if (requestIdRef.current !== requestId || !owner.isCurrent()) return;
loadedOwnerRef.current = owner;
loadedSessionIdRef.current = sessionId;
setArtifacts(result.artifacts);
} catch {
// The artifacts panel treats a failed refresh as an empty error state.
} finally {
if (requestIdRef.current === requestId) {
setLoading(false);
}
if (requestIdRef.current === requestId) setLoading(false);
}
}, [actions, isConnected, owner, sessionId, supportsArtifacts]);
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
}, [
actions,
connection.catchingUp,
isConnected,
owner,
sessionId,
supportsArtifacts,
]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
const previous = previousPromptStatusRef.current;
previousPromptStatusRef.current = promptStatus;
if (previous !== 'idle' && promptStatus === 'idle') {
void refreshRef.current();
}
}, [promptStatus]);
useEffect(() => {
const previous = previousArtifactsVersionRef.current;
previousArtifactsVersionRef.current = artifactsVersion;
@ -95,28 +105,42 @@ export function useSessionArtifacts(): SessionArtifactsState {
artifactsVersion !== undefined &&
artifactsVersion !== previous
) {
void refreshRef.current();
void refresh();
}
}, [artifactsVersion]);
}, [artifactsVersion, refresh]);
const artifactById = useMemo(
() =>
new Map(
(loadedOwnerRef.current === owner ? artifacts : []).map((artifact) => [
artifact.id,
artifact,
]),
(sessionId &&
loadedOwnerRef.current === owner &&
loadedSessionIdRef.current === sessionId
? artifacts
: []
).map((artifact) => [artifact.id, artifact]),
),
[artifacts, owner],
[artifacts, owner, sessionId],
);
const visibleArtifacts =
loadedOwnerRef.current === owner ? artifacts : EMPTY_ARTIFACTS;
sessionId &&
loadedOwnerRef.current === owner &&
loadedSessionIdRef.current === sessionId
? artifacts
: EMPTY_ARTIFACTS;
return {
artifacts: visibleArtifacts,
artifactById,
loading: loading && loadingOwnerRef.current === owner,
loading:
loading &&
loadingOwnerRef.current === owner &&
loadingSessionIdRef.current === sessionId,
error: null,
hydrated: Boolean(
sessionId &&
loadedOwnerRef.current === owner &&
loadedSessionIdRef.current === sessionId,
),
refresh,
};
}

View file

@ -0,0 +1,424 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import type { WebShellSessionArtifactsChange } from '../customization';
import { useSessionArtifactsChange } from './useSessionArtifactsChange';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const sdkMock = vi.hoisted(() => ({
artifactsVersion: 0,
}));
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
useWorkspaceEventSignals: () => ({
artifactsVersion: sdkMock.artifactsVersion,
}),
}));
interface HostProps {
sessionId?: string;
ready: boolean;
hydrated: boolean;
artifacts: readonly DaemonSessionArtifact[];
artifactsByTurn: ReadonlyMap<string, readonly DaemonSessionArtifact[]>;
onChange?: (change: WebShellSessionArtifactsChange) => void;
}
let root: Root | null = null;
let container: HTMLDivElement | null = null;
function artifact(
id: string,
overrides: Partial<DaemonSessionArtifact> = {},
): DaemonSessionArtifact {
return {
id,
kind: 'html',
storage: 'workspace',
source: 'tool',
status: 'available',
title: id,
workspacePath: `${id}.html`,
retention: 'restorable',
clientRetained: false,
createdAt: '2026-09-02T00:00:00.000Z',
updatedAt: '2026-09-02T00:00:00.000Z',
...overrides,
};
}
function Host(props: HostProps) {
useSessionArtifactsChange({ ...props });
return null;
}
async function render(props: HostProps) {
if (!root) {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
}
await act(async () => {
root?.render(<Host {...props} />);
});
}
function props(
onChange: (change: WebShellSessionArtifactsChange) => void,
overrides: Partial<HostProps> = {},
): HostProps {
return {
sessionId: 'session-a',
ready: true,
hydrated: true,
artifacts: [],
artifactsByTurn: new Map(),
onChange,
...overrides,
};
}
beforeEach(() => {
sdkMock.artifactsVersion = 0;
});
afterEach(async () => {
if (root) {
await act(async () => root?.unmount());
root = null;
}
container?.remove();
container = null;
vi.restoreAllMocks();
});
describe('useSessionArtifactsChange', () => {
it('delivers empty and historical initial snapshots as restore', async () => {
const onChange = vi.fn();
await render(props(onChange));
expect(onChange).toHaveBeenLastCalledWith({
reason: 'restore',
sessionId: 'session-a',
sequence: 1,
artifacts: [],
artifactsByTurn: new Map(),
});
const historical = artifact('historical', { toolCallId: 'tool-old' });
await render(
props(onChange, {
sessionId: 'session-b',
artifacts: [historical],
artifactsByTurn: new Map(),
}),
);
expect(onChange).toHaveBeenLastCalledWith({
reason: 'restore',
sessionId: 'session-b',
sequence: 1,
artifacts: [historical],
artifactsByTurn: new Map(),
});
});
it('delivers a new tool Artifact before its turn projection appears', async () => {
const onChange = vi.fn();
await render(props(onChange));
const generated = artifact('generated', { toolCallId: 'tool-1' });
sdkMock.artifactsVersion = 1;
await render(
props(onChange, {
artifacts: [generated],
}),
);
expect(onChange).toHaveBeenLastCalledWith({
reason: 'change',
sessionId: 'session-a',
sequence: 2,
artifacts: [generated],
artifactsByTurn: new Map(),
});
const unrelated = artifact('unrelated');
sdkMock.artifactsVersion = 2;
await render(
props(onChange, {
artifacts: [generated, unrelated],
}),
);
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
sequence: 3,
artifacts: [generated, unrelated],
}),
);
await render(
props(onChange, {
artifacts: [generated, unrelated],
artifactsByTurn: new Map([['turn-1', [generated]]]),
}),
);
expect(onChange).toHaveBeenLastCalledWith({
reason: 'change',
sessionId: 'session-a',
sequence: 4,
artifacts: [generated, unrelated],
artifactsByTurn: new Map([['turn-1', [generated]]]),
});
});
it('reports complete snapshots for metadata, projection, and consecutive changes', async () => {
const onChange = vi.fn();
await render(props(onChange));
const first = artifact('first');
sdkMock.artifactsVersion = 1;
await render(
props(onChange, {
artifacts: [first],
}),
);
const updated = artifact('first', {
title: 'Updated',
metadata: { z: 1, b: 2, a: 1 },
updatedAt: '2026-09-02T00:01:00.000Z',
});
sdkMock.artifactsVersion = 2;
await render(
props(onChange, {
artifacts: [updated],
}),
);
const second = artifact('second');
sdkMock.artifactsVersion = 3;
await render(
props(onChange, {
artifacts: [updated, second],
}),
);
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
reason: 'change',
sequence: 4,
artifacts: [updated, second],
}),
);
sdkMock.artifactsVersion = 4;
await render(
props(onChange, {
artifacts: [
{
...updated,
metadata: { a: 1, b: 2, z: 1 },
},
second,
],
}),
);
expect(onChange).toHaveBeenCalledTimes(4);
await render(
props(onChange, {
artifacts: [updated, second],
artifactsByTurn: new Map([['turn-2', [second]]]),
}),
);
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
sequence: 5,
artifactsByTurn: new Map([['turn-2', [second]]]),
}),
);
});
it('preserves the baseline through reconnect and reconciles only real changes', async () => {
const onChange = vi.fn();
const first = artifact('first');
const beforeReconnect = [first];
await render(
props(onChange, {
artifacts: beforeReconnect,
artifactsByTurn: new Map(),
}),
);
await render(
props(onChange, {
ready: false,
artifacts: beforeReconnect,
}),
);
sdkMock.artifactsVersion = 1;
await render(props(onChange, { artifacts: beforeReconnect }));
await render(props(onChange, { ready: false, artifacts: beforeReconnect }));
expect(onChange).toHaveBeenCalledTimes(1);
const missed = artifact('missed', { toolCallId: 'trimmed-tool' });
await render(
props(onChange, {
artifacts: [first, missed],
}),
);
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
reason: 'change',
sequence: 2,
artifacts: [first, missed],
}),
);
sdkMock.artifactsVersion = 2;
const late = artifact('late', { toolCallId: 'tool-late' });
await render(props(onChange, { artifacts: [first, missed, late] }));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
reason: 'change',
sequence: 3,
artifacts: [first, missed, late],
artifactsByTurn: new Map(),
}),
);
await render(
props(onChange, {
artifacts: [first, missed, late],
artifactsByTurn: new Map([['turn-late', [late]]]),
}),
);
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
reason: 'change',
sequence: 4,
}),
);
});
it('delivers unprojected Artifacts after a reconnect without snapshot changes', async () => {
const onChange = vi.fn();
const first = artifact('first');
const baseline = [first];
await render(props(onChange, { artifacts: baseline }));
await render(
props(onChange, {
ready: false,
artifacts: baseline,
}),
);
await render(props(onChange, { artifacts: baseline }));
await render(props(onChange, { ready: false, artifacts: baseline }));
await render(props(onChange, { artifacts: baseline }));
sdkMock.artifactsVersion = 1;
const generated = artifact('generated', { toolCallId: 'tool-new' });
await render(props(onChange, { artifacts: [first, generated] }));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
reason: 'change',
sequence: 2,
artifacts: [first, generated],
artifactsByTurn: new Map(),
}),
);
await render(
props(onChange, {
artifacts: [first, generated],
artifactsByTurn: new Map([['turn-new', [generated]]]),
}),
);
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
reason: 'change',
sequence: 3,
}),
);
});
it('keeps delivering after an unprojected Artifact enters the baseline', async () => {
const onChange = vi.fn();
const historical = artifact('historical', { toolCallId: 'tool-old' });
await render(props(onChange, { artifacts: [historical] }));
sdkMock.artifactsVersion = 1;
const fresh = artifact('fresh');
await render(props(onChange, { artifacts: [historical, fresh] }));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
reason: 'change',
sequence: 2,
artifacts: [historical, fresh],
}),
);
});
it('delivers unprojected Artifacts after the initial ready transition', async () => {
const onChange = vi.fn();
const historical = artifact('historical', { toolCallId: 'tool-old' });
await render(
props(onChange, {
ready: false,
artifacts: [historical],
}),
);
await render(props(onChange, { artifacts: [historical] }));
expect(onChange).toHaveBeenCalledTimes(1);
sdkMock.artifactsVersion = 1;
const generated = artifact('generated', { toolCallId: 'tool-new' });
await render(props(onChange, { artifacts: [historical, generated] }));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
reason: 'change',
sequence: 2,
artifacts: [historical, generated],
artifactsByTurn: new Map(),
}),
);
});
it('waits until ready before delivering a changed snapshot', async () => {
const onChange = vi.fn();
await render(props(onChange));
const generated = artifact('generated');
sdkMock.artifactsVersion = 1;
await render(
props(onChange, {
ready: false,
hydrated: true,
artifacts: [generated],
}),
);
expect(onChange).toHaveBeenCalledTimes(1);
await render(props(onChange, { artifacts: [generated] }));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
reason: 'change',
sequence: 2,
}),
);
});
it('isolates a throwing listener and advances to later snapshots', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const onChange = vi
.fn<(change: WebShellSessionArtifactsChange) => void>()
.mockImplementationOnce(() => {
throw new Error('host failed');
});
await render(props(onChange));
sdkMock.artifactsVersion = 1;
await render(
props(onChange, {
artifacts: [artifact('later')],
}),
);
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange.mock.calls[1]?.[0].sequence).toBe(2);
expect(error).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,145 @@
import { useEffect, useRef } from 'react';
import { useWorkspaceEventSignals } from '@qwen-code/web-shell/daemon-react-sdk';
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import type {
WebShellSessionArtifactsChange,
WebShellSessionArtifactsChangeReason,
} from '../customization';
interface SessionArtifactsChangeOptions {
sessionId?: string;
ready: boolean;
hydrated: boolean;
artifacts: readonly DaemonSessionArtifact[];
artifactsByTurn: ReadonlyMap<string, readonly DaemonSessionArtifact[]>;
onChange?: (change: WebShellSessionArtifactsChange) => void;
}
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => [key, canonicalize(entry)]),
);
}
return value;
}
function snapshotSignature(
artifacts: readonly DaemonSessionArtifact[],
artifactsByTurn: ReadonlyMap<string, readonly DaemonSessionArtifact[]>,
): string {
return JSON.stringify(
canonicalize({
artifacts,
artifactsByTurn: Array.from(artifactsByTurn.entries()).sort(
([left], [right]) => left.localeCompare(right),
),
}),
);
}
function cloneProjection(
artifactsByTurn: ReadonlyMap<string, readonly DaemonSessionArtifact[]>,
): ReadonlyMap<string, readonly DaemonSessionArtifact[]> {
return new Map(
Array.from(artifactsByTurn, ([turnId, artifacts]) => [
turnId,
[...artifacts],
]),
);
}
export function useSessionArtifactsChange({
sessionId,
ready,
hydrated,
artifacts,
artifactsByTurn,
onChange,
}: SessionArtifactsChangeOptions): void {
const artifactsVersion = useWorkspaceEventSignals()?.artifactsVersion;
const stateRef = useRef<{
sessionId?: string;
artifactsVersion?: number;
pendingReason?: WebShellSessionArtifactsChangeReason;
lastSignature?: string;
sequence: number;
}>({
sessionId,
artifactsVersion,
sequence: 0,
});
useEffect(() => {
let state = stateRef.current;
if (state.sessionId !== sessionId) {
state = {
sessionId,
artifactsVersion,
sequence: 0,
};
stateRef.current = state;
} else {
const previousVersion = state.artifactsVersion;
state.artifactsVersion = artifactsVersion;
if (
previousVersion !== undefined &&
artifactsVersion !== undefined &&
previousVersion !== artifactsVersion
) {
state.pendingReason = 'change';
}
}
if (!sessionId || !onChange || !ready || !hydrated) return;
const signature = snapshotSignature(artifacts, artifactsByTurn);
if (
state.lastSignature === undefined &&
state.pendingReason === undefined
) {
state.pendingReason = 'restore';
}
if (
state.lastSignature !== undefined &&
state.lastSignature !== signature &&
state.pendingReason === undefined
) {
state.pendingReason = 'change';
}
if (state.lastSignature === signature) {
state.pendingReason = undefined;
return;
}
if (!state.pendingReason) return;
const change: WebShellSessionArtifactsChange = {
reason: state.pendingReason,
sessionId,
sequence: state.sequence + 1,
artifacts: [...artifacts],
artifactsByTurn: cloneProjection(artifactsByTurn),
};
state.sequence = change.sequence;
state.lastSignature = signature;
state.pendingReason = undefined;
try {
onChange(change);
} catch (error) {
console.error(
'[WebShell] onSessionArtifactsChange listener failed:',
error,
);
}
}, [
artifacts,
artifactsVersion,
artifactsByTurn,
hydrated,
onChange,
ready,
sessionId,
]);
}

View file

@ -236,6 +236,8 @@ export type {
WebShellMonitorTask,
WebShellPreparedSubmit,
WebShellSubmitSnapshot,
WebShellSessionArtifactsChange,
WebShellSessionArtifactsChangeReason,
WebShellModelInfo,
WebShellSkillInfo,
} from './customization';