From f4b49b04899321e50ccbc756cd738203eb53aace Mon Sep 17 00:00:00 2001 From: dreamWB <22347282+dreamWB@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:33:15 +0000 Subject: [PATCH] feat(web-shell): expose session artifact snapshots (#10747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: 易良 <1204183885@qq.com> --- ...9-02-web-shell-session-artifacts-change.md | 102 +++++ packages/web-shell/README.md | 49 +- packages/web-shell/client/App.test.tsx | 38 +- packages/web-shell/client/App.tsx | 18 + packages/web-shell/client/customization.tsx | 11 + .../session/DaemonSessionProvider.test.tsx | 180 +++++--- .../daemon/session/DaemonSessionProvider.tsx | 40 +- .../client/hooks/useSessionArtifacts.test.tsx | 65 ++- .../client/hooks/useSessionArtifacts.ts | 80 ++-- .../hooks/useSessionArtifactsChange.test.tsx | 424 ++++++++++++++++++ .../client/hooks/useSessionArtifactsChange.ts | 145 ++++++ packages/web-shell/client/index.tsx | 2 + 12 files changed, 994 insertions(+), 160 deletions(-) create mode 100644 docs/design/2026-09-02-web-shell-session-artifacts-change.md create mode 100644 packages/web-shell/client/hooks/useSessionArtifactsChange.test.tsx create mode 100644 packages/web-shell/client/hooks/useSessionArtifactsChange.ts diff --git a/docs/design/2026-09-02-web-shell-session-artifacts-change.md b/docs/design/2026-09-02-web-shell-session-artifacts-change.md new file mode 100644 index 0000000000..1d2971561f --- /dev/null +++ b/docs/design/2026-09-02-web-shell-session-artifacts-change.md @@ -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; +} +``` + +`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. diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index c2b4ab10b0..d03c9771b9 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -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` | 新 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` | 新 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 + { + 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 diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 1d88fac137..38473aa6d0 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.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(); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 40f17eb4f2..a4aeba00b3 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -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([]); @@ -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( diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index 462bed4ba0..7769a433e9 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -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; +} + export type AssistantTurnFooterRenderer = ( info: WebShellAssistantTurnFooterRenderInfo, ) => ReactNode | null | undefined; diff --git a/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx index 10f88a80e1..e6d9a02f30 100644 --- a/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx @@ -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(, { - autoConnect: true, - sessionId: 'standalone-signals', - sessionContext: { kind: 'standalone' }, - }); - await act(async () => flushPromises()); + await renderWithProvider(, { + 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, }); diff --git a/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx index 7f7621c6b2..2d1270140f 100644 --- a/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx +++ b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx @@ -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>, - 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, diff --git a/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx b/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx index b203fc70b8..089750e09b 100644 --- a/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx +++ b/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx @@ -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', ]); }); }); diff --git a/packages/web-shell/client/hooks/useSessionArtifacts.ts b/packages/web-shell/client/hooks/useSessionArtifacts.ts index 7cefd8549a..97b55d9e85 100644 --- a/packages/web-shell/client/hooks/useSessionArtifacts.ts +++ b/packages/web-shell/client/hooks/useSessionArtifacts.ts @@ -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; loading: boolean; error: string | null; + hydrated: boolean; refresh: () => Promise; } @@ -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(undefined); + const loadedSessionIdRef = useRef(undefined); const loadingOwnerRef = useRef(undefined); - const previousPromptStatusRef = useRef(promptStatus); + const loadingSessionIdRef = useRef(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, }; } diff --git a/packages/web-shell/client/hooks/useSessionArtifactsChange.test.tsx b/packages/web-shell/client/hooks/useSessionArtifactsChange.test.tsx new file mode 100644 index 0000000000..57b8005828 --- /dev/null +++ b/packages/web-shell/client/hooks/useSessionArtifactsChange.test.tsx @@ -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; + onChange?: (change: WebShellSessionArtifactsChange) => void; +} + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +function artifact( + id: string, + overrides: Partial = {}, +): 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(); + }); +} + +function props( + onChange: (change: WebShellSessionArtifactsChange) => void, + overrides: Partial = {}, +): 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(); + }); +}); diff --git a/packages/web-shell/client/hooks/useSessionArtifactsChange.ts b/packages/web-shell/client/hooks/useSessionArtifactsChange.ts new file mode 100644 index 0000000000..276b19a5b5 --- /dev/null +++ b/packages/web-shell/client/hooks/useSessionArtifactsChange.ts @@ -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; + 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 { + return JSON.stringify( + canonicalize({ + artifacts, + artifactsByTurn: Array.from(artifactsByTurn.entries()).sort( + ([left], [right]) => left.localeCompare(right), + ), + }), + ); +} + +function cloneProjection( + artifactsByTurn: ReadonlyMap, +): ReadonlyMap { + 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, + ]); +} diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index 7da2594871..9b20fa1523 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -236,6 +236,8 @@ export type { WebShellMonitorTask, WebShellPreparedSubmit, WebShellSubmitSnapshot, + WebShellSessionArtifactsChange, + WebShellSessionArtifactsChangeReason, WebShellModelInfo, WebShellSkillInfo, } from './customization';