mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-21 14:21:48 +00:00
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:
parent
a2ed9928bb
commit
f4b49b0489
12 changed files with 994 additions and 160 deletions
102
docs/design/2026-09-02-web-shell-session-artifacts-change.md
Normal file
102
docs/design/2026-09-02-web-shell-session-artifacts-change.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
145
packages/web-shell/client/hooks/useSessionArtifactsChange.ts
Normal file
145
packages/web-shell/client/hooks/useSessionArtifactsChange.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -236,6 +236,8 @@ export type {
|
|||
WebShellMonitorTask,
|
||||
WebShellPreparedSubmit,
|
||||
WebShellSubmitSnapshot,
|
||||
WebShellSessionArtifactsChange,
|
||||
WebShellSessionArtifactsChangeReason,
|
||||
WebShellModelInfo,
|
||||
WebShellSkillInfo,
|
||||
} from './customization';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue