From 903638db94ad25e2a2d8050b7d78f97b3eda732f Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Fri, 14 Aug 2026 18:51:04 +0000 Subject: [PATCH 1/5] fix(ui): show embedded subagent history while visibility stays inactive Busy context-panel session chats could render only the working-status row when the iframe booted inactive or lost its visibility handshake, because message reads shared the composer/background-work gate. Keep message subscriptions enabled in the mounted session-chat panel so materialized history remains visible (#2903, #2892). Co-authored-by: Serhii Dziupin --- packages/ui/src/App.tsx | 5 + .../ui/src/components/chat/ChatContainer.tsx | 22 ++- ...ue-2903-subagent-status-line-only.test.tsx | 143 ++++++++++++++++++ ...815-sessionChatIframesMountAllTabs.test.ts | 3 +- packages/ui/src/components/views/ChatView.tsx | 14 +- packages/ui/src/stores/DOCUMENTATION.md | 5 +- 6 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 1929683e..ad4bc2f4 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -209,6 +209,11 @@ const EmbeddedSessionChatContent: React.FC<{ diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index ff38aeba..8f0c2114 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -533,12 +533,26 @@ const DraftWelcome: React.FC = () => { type ChatContainerProps = { active?: boolean; + /** + * When set, controls `useSessionMessageRecords` independently of `active`. + * Defaults to `active`. Embedded session-chat panels pass `true` so a + * delayed/lost visibility handshake cannot hide an already-materialized + * transcript (leaving only the working-status row — issue #2903). + */ + messagesEnabled?: boolean; autoOpenDraft?: boolean; readOnly?: boolean; initialAllowPromptingSubagentSessions?: boolean; }; -export const ChatContainer: React.FC = ({ active = true, autoOpenDraft = true, readOnly = false, initialAllowPromptingSubagentSessions }) => { +export const ChatContainer: React.FC = ({ + active = true, + messagesEnabled: messagesEnabledProp, + autoOpenDraft = true, + readOnly = false, + initialAllowPromptingSubagentSessions, +}) => { + const messagesEnabled = messagesEnabledProp ?? active; const { t } = useI18n(); // Session UI state const currentSessionId = useSessionUIStore((s) => s.currentSessionId); @@ -591,9 +605,11 @@ export const ChatContainer: React.FC = ({ active = true, aut ); const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '', effectiveSessionDirectory); const hasRenderableSessionSnapshot = useSessionRenderable(currentSessionId ?? '', effectiveSessionDirectory); - // Messages from sync system + // Messages from sync system. Keep this gated by `messagesEnabled`, not + // `active`, so embedded panels can show history while the composer stays + // inactive until the parent confirms visibility. const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, { - enabled: active, + enabled: messagesEnabled, suspendPartUpdates: Boolean(streamingMessageId), suspendPartUpdatesForMessageId: streamingMessageId, }); diff --git a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx new file mode 100644 index 00000000..8823e267 --- /dev/null +++ b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx @@ -0,0 +1,143 @@ +/** + * Regression coverage for https://github.com/openchamber/openchamber/issues/2903 + * + * Busy embedded session-chat panels were rendering only the working-status row + * ("…is running command") because ChatContainer gated message reads on the + * same visibility flag used to keep the composer from stealing focus. When the + * iframe booted inactive (or a visibility postMessage was lost), + * useSessionMessageRecords returned [] while session status stayed busy — so + * the empty-state branch was skipped and the transcript showed status only. + * + * Idle sessions hit the empty state instead (#2892). Same root cause. + * + * Fix: embedded session-chat keeps `messagesEnabled={true}` so history stays + * subscribed while `active={embeddedBackgroundWorkEnabled}` still gates + * composer focus and background work. + */ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Message, Part } from '@opencode-ai/sdk/v2/client'; + +import { getSessionMaterializationStatus, materializeSessionSnapshots } from '@/sync/materialization'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const appSource = readFileSync(join(__dirname, '..', '..', '..', 'App.tsx'), 'utf-8'); +const chatContainerSource = readFileSync(join(__dirname, '..', 'ChatContainer.tsx'), 'utf-8'); +const chatViewSource = readFileSync(join(__dirname, '..', '..', 'views', 'ChatView.tsx'), 'utf-8'); +const syncContextSource = readFileSync(join(__dirname, '..', '..', '..', 'sync', 'sync-context.tsx'), 'utf-8'); + +const SESSION_ID = 'ses_subagent_2903'; + +const createRecord = (id: string, role: 'user' | 'assistant', created: number) => ({ + info: { + id, + sessionID: SESSION_ID, + role, + time: { created }, + ...(role === 'assistant' + ? { parentID: `u_${created}`, providerID: 'deepseek', modelID: 'deepseek-v4-flash' } + : {}), + } as Message, + parts: [{ + id: `prt_${id}`, + messageID: id, + sessionID: SESSION_ID, + type: 'text', + text: role === 'user' ? `prompt ${created}` : `output ${created}`, + }] as Part[], +}); + +/** 14-message subagent transcript, matching the issue reproduction fixture. */ +const buildFourteenMessageSnapshot = () => { + const records = Array.from({ length: 14 }, (_, index) => { + const n = index + 1; + return createRecord( + n % 2 === 1 ? `u_${n}` : `a_${n}`, + n % 2 === 1 ? 'user' : 'assistant', + n, + ); + }); + return materializeSessionSnapshots({ message: {}, part: {} }, SESSION_ID, records); +}; + +/** + * Mirrors the cold-start branch of useSessionMessageRecords when + * `options.enabled === false` and no prior snapshot exists for the session. + */ +const readRecordsThroughEnabledGate = ( + storeMessages: Message[] | undefined, + enabled: boolean, +): Message[] => { + if (enabled === false) { + // Cold iframe: snapshotRef is empty / wrong session → EMPTY records. + return []; + } + return storeMessages ?? []; +}; + +describe('issue #2903 busy embedded subagent status-line-only', () => { + test('materialized 14-message subagent is renderable', () => { + const snapshot = buildFourteenMessageSnapshot(); + expect(snapshot.message[SESSION_ID]).toHaveLength(14); + expect(getSessionMaterializationStatus(snapshot, SESSION_ID)).toEqual({ + hasMessages: true, + renderable: true, + missingPartMessageIDs: [], + }); + }); + + test('inactive enabled:false hides a fully-renderable session (0 records)', () => { + const snapshot = buildFourteenMessageSnapshot(); + expect(getSessionMaterializationStatus(snapshot, SESSION_ID).renderable).toBe(true); + expect(readRecordsThroughEnabledGate(snapshot.message[SESSION_ID], false)).toHaveLength(0); + }); + + test('enabled:true reveals all 14 materialized records', () => { + const snapshot = buildFourteenMessageSnapshot(); + expect(readRecordsThroughEnabledGate(snapshot.message[SESSION_ID], true)).toHaveLength(14); + }); + + test('sync gate still returns empty on cold disabled reads', () => { + // Mutation check: the real hook still has the enabled===false early return + // that produced the bug when ChatContainer passed enabled: active. + expect(syncContextSource).toContain('if (options?.enabled === false)'); + expect(syncContextSource).toContain('EMPTY_SESSION_MESSAGE_RECORDS'); + }); + + test('embedded session-chat keeps message history enabled while visibility gates active', () => { + expect(appSource).toContain('messagesEnabled={true}'); + expect(appSource).toContain('active={embeddedBackgroundWorkEnabled}'); + expect(appSource).toContain('const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(false);'); + expect(chatViewSource).toContain('messagesEnabled?: boolean'); + expect(chatContainerSource).toContain('messagesEnabled: messagesEnabledProp'); + expect(chatContainerSource).toContain('const messagesEnabled = messagesEnabledProp ?? active;'); + expect(chatContainerSource).toContain('enabled: messagesEnabled'); + expect(chatContainerSource.includes('enabled: active')).toBe(false); + }); + + test('empty+busy branch skips empty state so StatusRowContainer can stand alone', () => { + // Busy + zero messages skips ChatEmptyState and falls through to the full + // ChatViewport, whose transcript always includes StatusRowContainer — the + // "one status line, no history" symptom when records stay empty. + expect(chatContainerSource).toContain('if (sessionMessages.length === 0 && !sessionIsWorking)'); + expect(chatContainerSource).toContain(''); + + const emptyBusyGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)'; + const emptyStateReturn = chatContainerSource.indexOf(emptyBusyGuard); + expect(emptyStateReturn).toBeGreaterThan(-1); + const emptyStateBlock = chatContainerSource.slice( + emptyStateReturn, + emptyStateReturn + 1600, + ); + expect(emptyStateBlock).toContain(''); + }); + + test('visibility handshake remains as defense-in-depth for background work', () => { + expect(appSource).toContain('requestEmbeddedSessionVisibility();'); + expect(appSource).toContain('EMBEDDED_VISIBILITY_UPDATE'); + }); +}); diff --git a/packages/ui/src/components/layout/__tests__/issue-2815-sessionChatIframesMountAllTabs.test.ts b/packages/ui/src/components/layout/__tests__/issue-2815-sessionChatIframesMountAllTabs.test.ts index ccb2fea0..619a8cd2 100644 --- a/packages/ui/src/components/layout/__tests__/issue-2815-sessionChatIframesMountAllTabs.test.ts +++ b/packages/ui/src/components/layout/__tests__/issue-2815-sessionChatIframesMountAllTabs.test.ts @@ -147,11 +147,12 @@ describe('issue #2815 active-only chat iframe source guard', () => { expect(requestIndex).toBeGreaterThan(listenerIndex); }); - test('gates embedded chat subscriptions and background work on visibility', () => { + test('gates embedded chat background work on visibility but keeps message history enabled', () => { expect(appSource).toContain( 'const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;', ); expect(appSource).toContain('active={embeddedBackgroundWorkEnabled}'); + expect(appSource).toContain('messagesEnabled={true}'); expect(appSource).toContain( 'useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled });', ); diff --git a/packages/ui/src/components/views/ChatView.tsx b/packages/ui/src/components/views/ChatView.tsx index bcba8dc7..29d83cff 100644 --- a/packages/ui/src/components/views/ChatView.tsx +++ b/packages/ui/src/components/views/ChatView.tsx @@ -5,17 +5,29 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; type ChatViewProps = { active?: boolean; + /** + * Controls message-history subscription independently of `active`. + * Embedded session-chat panels keep this true so history stays visible + * while composer focus / background work remain gated by visibility. + */ + messagesEnabled?: boolean; readOnly?: boolean; initialAllowPromptingSubagentSessions?: boolean; }; -export const ChatView: React.FC = ({ active = true, readOnly = false, initialAllowPromptingSubagentSessions }) => { +export const ChatView: React.FC = ({ + active = true, + messagesEnabled, + readOnly = false, + initialAllowPromptingSubagentSessions, +}) => { const currentSessionId = useSessionUIStore((state) => state.currentSessionId); return ( diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 14df83a7..4f17d6e4 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -45,7 +45,10 @@ its message listener, the iframe requests its authoritative visibility from the parent. The parent accepts requests only from a currently mounted chat frame and answers from the current active tab. Do not rely only on a parent `onLoad` notification: it can arrive before the iframe listener exists and leave a -visible chat with background work and message subscriptions disabled. +visible chat with background work disabled. Message-history subscriptions in the +mounted session-chat iframe stay enabled independently of that visibility flag +so a delayed or lost handshake cannot hide an already-materialized transcript +(busy subagents would otherwise show only the working-status row). ### Session / project coordination stores From 0a07bc7e037cd98ccd1a0eeac19361b67606e234 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Sat, 15 Aug 2026 03:40:37 +0000 Subject: [PATCH 2/5] test(ui): assert #2903 records through the real snapshot builder Replace the fake enabled-gate helper's store fixture with buildSessionMessageRecordsSnapshot so the regression covers the same record shape ChatContainer renders. Co-authored-by: Serhii Dziupin --- ...ue-2903-subagent-status-line-only.test.tsx | 62 ++++++++++++------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx index 8823e267..a2b44f9c 100644 --- a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx +++ b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx @@ -21,6 +21,8 @@ import { fileURLToPath } from 'node:url'; import type { Message, Part } from '@opencode-ai/sdk/v2/client'; import { getSessionMaterializationStatus, materializeSessionSnapshots } from '@/sync/materialization'; +import { buildSessionMessageRecordsSnapshot } from '@/sync/sync-context'; +import { INITIAL_STATE } from '@/sync/types'; const __dirname = dirname(fileURLToPath(import.meta.url)); const appSource = readFileSync(join(__dirname, '..', '..', '..', 'App.tsx'), 'utf-8'); @@ -63,47 +65,66 @@ const buildFourteenMessageSnapshot = () => { }; /** - * Mirrors the cold-start branch of useSessionMessageRecords when - * `options.enabled === false` and no prior snapshot exists for the session. + * Cold-start `useSessionMessageRecords` when `enabled === false` and no prior + * snapshot exists for the session: getSnapshot returns EMPTY records even + * though the store already holds a renderable transcript. */ const readRecordsThroughEnabledGate = ( - storeMessages: Message[] | undefined, + storeMessages: ReturnType['list'], enabled: boolean, -): Message[] => { +) => { if (enabled === false) { - // Cold iframe: snapshotRef is empty / wrong session → EMPTY records. return []; } - return storeMessages ?? []; + return storeMessages; }; describe('issue #2903 busy embedded subagent status-line-only', () => { - test('materialized 14-message subagent is renderable', () => { - const snapshot = buildFourteenMessageSnapshot(); - expect(snapshot.message[SESSION_ID]).toHaveLength(14); - expect(getSessionMaterializationStatus(snapshot, SESSION_ID)).toEqual({ + test('materialized 14-message subagent is renderable and snapshottable', () => { + const materialized = buildFourteenMessageSnapshot(); + expect(materialized.message[SESSION_ID]).toHaveLength(14); + expect(getSessionMaterializationStatus(materialized, SESSION_ID)).toEqual({ hasMessages: true, renderable: true, missingPartMessageIDs: [], }); + + const records = buildSessionMessageRecordsSnapshot( + { ...INITIAL_STATE, message: materialized.message, part: materialized.part }, + SESSION_ID, + ); + expect(records.list).toHaveLength(14); + expect(records.list.map((record) => record.info.id)).toEqual( + materialized.message[SESSION_ID].map((message) => message.id), + ); }); test('inactive enabled:false hides a fully-renderable session (0 records)', () => { - const snapshot = buildFourteenMessageSnapshot(); - expect(getSessionMaterializationStatus(snapshot, SESSION_ID).renderable).toBe(true); - expect(readRecordsThroughEnabledGate(snapshot.message[SESSION_ID], false)).toHaveLength(0); + const materialized = buildFourteenMessageSnapshot(); + const records = buildSessionMessageRecordsSnapshot( + { ...INITIAL_STATE, message: materialized.message, part: materialized.part }, + SESSION_ID, + ); + expect(getSessionMaterializationStatus(materialized, SESSION_ID).renderable).toBe(true); + expect(records.list).toHaveLength(14); + expect(readRecordsThroughEnabledGate(records.list, false)).toHaveLength(0); }); test('enabled:true reveals all 14 materialized records', () => { - const snapshot = buildFourteenMessageSnapshot(); - expect(readRecordsThroughEnabledGate(snapshot.message[SESSION_ID], true)).toHaveLength(14); + const materialized = buildFourteenMessageSnapshot(); + const records = buildSessionMessageRecordsSnapshot( + { ...INITIAL_STATE, message: materialized.message, part: materialized.part }, + SESSION_ID, + ); + expect(readRecordsThroughEnabledGate(records.list, true)).toHaveLength(14); }); test('sync gate still returns empty on cold disabled reads', () => { - // Mutation check: the real hook still has the enabled===false early return - // that produced the bug when ChatContainer passed enabled: active. - expect(syncContextSource).toContain('if (options?.enabled === false)'); - expect(syncContextSource).toContain('EMPTY_SESSION_MESSAGE_RECORDS'); + const hookStart = syncContextSource.indexOf('export function useSessionMessageRecords('); + const hookBody = syncContextSource.slice(hookStart, hookStart + 1800); + expect(hookBody).toContain('if (options?.enabled === false)'); + expect(hookBody).toContain('EMPTY_SESSION_MESSAGE_RECORDS'); + expect(hookBody).toContain('snapshotRef.current.sessionID === sessionID ? snapshotRef.current.list'); }); test('embedded session-chat keeps message history enabled while visibility gates active', () => { @@ -118,9 +139,6 @@ describe('issue #2903 busy embedded subagent status-line-only', () => { }); test('empty+busy branch skips empty state so StatusRowContainer can stand alone', () => { - // Busy + zero messages skips ChatEmptyState and falls through to the full - // ChatViewport, whose transcript always includes StatusRowContainer — the - // "one status line, no history" symptom when records stay empty. expect(chatContainerSource).toContain('if (sessionMessages.length === 0 && !sessionIsWorking)'); expect(chatContainerSource).toContain(''); From de0455e10e524fd9a1e74f6c47b4752a62c1da50 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Sat, 15 Aug 2026 03:42:45 +0000 Subject: [PATCH 3/5] test(ui): drop tautological #2903 enabled-gate helper The helper reimplemented `if (!enabled) return []` locally, so those cases never exercised the real hook. Keep the snapshot-builder and source-contract coverage instead. Co-authored-by: Serhii Dziupin --- ...ue-2903-subagent-status-line-only.test.tsx | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx index a2b44f9c..d8658eda 100644 --- a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx +++ b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx @@ -64,21 +64,6 @@ const buildFourteenMessageSnapshot = () => { return materializeSessionSnapshots({ message: {}, part: {} }, SESSION_ID, records); }; -/** - * Cold-start `useSessionMessageRecords` when `enabled === false` and no prior - * snapshot exists for the session: getSnapshot returns EMPTY records even - * though the store already holds a renderable transcript. - */ -const readRecordsThroughEnabledGate = ( - storeMessages: ReturnType['list'], - enabled: boolean, -) => { - if (enabled === false) { - return []; - } - return storeMessages; -}; - describe('issue #2903 busy embedded subagent status-line-only', () => { test('materialized 14-message subagent is renderable and snapshottable', () => { const materialized = buildFourteenMessageSnapshot(); @@ -99,26 +84,6 @@ describe('issue #2903 busy embedded subagent status-line-only', () => { ); }); - test('inactive enabled:false hides a fully-renderable session (0 records)', () => { - const materialized = buildFourteenMessageSnapshot(); - const records = buildSessionMessageRecordsSnapshot( - { ...INITIAL_STATE, message: materialized.message, part: materialized.part }, - SESSION_ID, - ); - expect(getSessionMaterializationStatus(materialized, SESSION_ID).renderable).toBe(true); - expect(records.list).toHaveLength(14); - expect(readRecordsThroughEnabledGate(records.list, false)).toHaveLength(0); - }); - - test('enabled:true reveals all 14 materialized records', () => { - const materialized = buildFourteenMessageSnapshot(); - const records = buildSessionMessageRecordsSnapshot( - { ...INITIAL_STATE, message: materialized.message, part: materialized.part }, - SESSION_ID, - ); - expect(readRecordsThroughEnabledGate(records.list, true)).toHaveLength(14); - }); - test('sync gate still returns empty on cold disabled reads', () => { const hookStart = syncContextSource.indexOf('export function useSessionMessageRecords('); const hookBody = syncContextSource.slice(hookStart, hookStart + 1800); From 4e6ac4080171c039df293cb5d1c6ff344d983f5e Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Sat, 15 Aug 2026 03:51:33 +0000 Subject: [PATCH 4/5] fix(ui): load embedded history while visibility stays inactive Keep session-message loads and retries on messagesEnabled so a mounted session-chat panel can materialize history even before the visibility handshake, and cover the enabled-gate with the real hook. Co-authored-by: serkraser --- .../ui/src/components/chat/ChatContainer.tsx | 17 +- ...ue-2903-subagent-status-line-only.test.tsx | 215 ++++++++++++++---- 2 files changed, 178 insertions(+), 54 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 8f0c2114..a50ea6e7 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -534,10 +534,11 @@ const DraftWelcome: React.FC = () => { type ChatContainerProps = { active?: boolean; /** - * When set, controls `useSessionMessageRecords` independently of `active`. - * Defaults to `active`. Embedded session-chat panels pass `true` so a - * delayed/lost visibility handshake cannot hide an already-materialized - * transcript (leaving only the working-status row — issue #2903). + * When set, controls message-history reads and session-message loads + * independently of `active`. Defaults to `active`. Embedded session-chat + * panels pass `true` so a delayed/lost visibility handshake cannot hide + * an already-materialized transcript (leaving only the working-status + * row — issue #2903). */ messagesEnabled?: boolean; autoOpenDraft?: boolean; @@ -1058,9 +1059,9 @@ export const ChatContainer: React.FC = ({ Boolean(currentSessionId) && !hasRenderableSessionSnapshot; const retrySessionLoad = React.useCallback(() => { - if (!active || !currentSessionId) return; + if (!messagesEnabled || !currentSessionId) return; void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory); - }, [active, currentSessionId, effectiveSessionDirectory, sync]); + }, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]); React.useEffect(() => { if (!active || !currentSessionId) return; @@ -1085,10 +1086,10 @@ export const ChatContainer: React.FC = ({ }, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]); React.useEffect(() => { - if (!active || !currentSessionId) return; + if (!messagesEnabled || !currentSessionId) return; if (hasRenderableSessionSnapshot) return; void ensureSessionRenderable(currentSessionId); - }, [active, currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot]); + }, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]); if (!currentSessionId && !draftOpen) { // With auto-open, the draft welcome opens on the next tick (effect below), diff --git a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx index d8658eda..cd72ed46 100644 --- a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx +++ b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx @@ -14,15 +14,45 @@ * subscribed while `active={embeddedBackgroundWorkEnabled}` still gates * composer focus and background work. */ -import { describe, expect, test } from 'bun:test'; +import { describe, expect, mock, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; import type { Message, Part } from '@opencode-ai/sdk/v2/client'; -import { getSessionMaterializationStatus, materializeSessionSnapshots } from '@/sync/materialization'; -import { buildSessionMessageRecordsSnapshot } from '@/sync/sync-context'; -import { INITIAL_STATE } from '@/sync/types'; +mock.module('sonner', () => ({ + toast: { dismiss: () => undefined, error: () => undefined, info: () => undefined, success: () => undefined }, +})); +mock.module('@/components/ui', () => ({ + toast: { info: () => undefined, error: () => undefined, success: () => undefined }, +})); +mock.module('@/lib/opencode/client', () => ({ + opencodeClient: { + getDirectory: () => '/repo', + setDirectory: () => undefined, + getSdkClient: () => ({}), + getScopedSdkClient: () => ({}), + }, +})); +mock.module('@/stores/permissionStore', () => ({ + usePermissionStore: { getState: () => ({ isSessionAutoAccepting: () => false, hydrate: async () => undefined }) }, +})); +mock.module('@/stores/useConfigStore', () => ({ + useConfigStore: { + getState: () => ({ isConnected: true, hasEverConnected: true, settingsMessageStreamTransport: 'auto' }), + setState: () => undefined, + }, +})); +mock.module('@/stores/useTodosPersistStore', () => ({ + useTodosPersistStore: { getState: () => ({ setSessionTodos: () => undefined }) }, +})); + +const { useSessionMessageRecords } = await import('@/sync/sync-context'); +const { ChildStoreManager } = await import('@/sync/child-store'); +const { getSessionMaterializationStatus } = await import('@/sync/materialization'); +import type { State } from '@/sync/types'; const __dirname = dirname(fileURLToPath(import.meta.url)); const appSource = readFileSync(join(__dirname, '..', '..', '..', 'App.tsx'), 'utf-8'); @@ -31,57 +61,148 @@ const chatViewSource = readFileSync(join(__dirname, '..', '..', 'views', 'ChatVi const syncContextSource = readFileSync(join(__dirname, '..', '..', '..', 'sync', 'sync-context.tsx'), 'utf-8'); const SESSION_ID = 'ses_subagent_2903'; +const DIRECTORY = '/repo'; -const createRecord = (id: string, role: 'user' | 'assistant', created: number) => ({ - info: { - id, - sessionID: SESSION_ID, - role, - time: { created }, - ...(role === 'assistant' - ? { parentID: `u_${created}`, providerID: 'deepseek', modelID: 'deepseek-v4-flash' } - : {}), - } as Message, - parts: [{ - id: `prt_${id}`, - messageID: id, - sessionID: SESSION_ID, - type: 'text', - text: role === 'user' ? `prompt ${created}` : `output ${created}`, - }] as Part[], -}); - -/** 14-message subagent transcript, matching the issue reproduction fixture. */ -const buildFourteenMessageSnapshot = () => { - const records = Array.from({ length: 14 }, (_, index) => { - const n = index + 1; - return createRecord( - n % 2 === 1 ? `u_${n}` : `a_${n}`, - n % 2 === 1 ? 'user' : 'assistant', - n, - ); - }); - return materializeSessionSnapshots({ message: {}, part: {} }, SESSION_ID, records); +const installMinimalDom = () => { + const descriptors = new Map(); + const setGlobal = (name: string, value: unknown) => { + descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + }; + class ElementStub {} + const documentStub: Record = { + nodeType: 9, + defaultView: globalThis, + activeElement: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + const container = { + nodeType: 1, + tagName: 'DIV', + nodeName: 'DIV', + namespaceURI: 'http://www.w3.org/1999/xhtml', + ownerDocument: documentStub, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + documentStub.documentElement = container; + documentStub.body = container; + setGlobal('document', documentStub); + setGlobal('window', globalThis); + setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' }); + setGlobal('Element', ElementStub); + setGlobal('HTMLElement', ElementStub); + setGlobal('HTMLIFrameElement', ElementStub); + setGlobal('IS_REACT_ACT_ENVIRONMENT', true); + setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0)); + setGlobal('cancelAnimationFrame', (id: ReturnType) => clearTimeout(id)); + return { + container: container as unknown as Element, + restore: () => { + for (const [name, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + }, + }; }; +const createMessage = (id: string, role: 'user' | 'assistant', created: number): Message => ({ + id, + sessionID: SESSION_ID, + role, + ...(role === 'assistant' ? { parentID: `u_${created}` } : {}), + time: { created }, +} as Message); + +const createPart = (id: string, messageID: string, text: string): Part => ({ + id, + messageID, + sessionID: SESSION_ID, + type: 'text', + text, +} as Part); + +/** 14-message subagent transcript, matching the issue reproduction fixture. */ +const buildMaterializedSubagentSession = () => { + const messages: Message[] = []; + const part: Record = {}; + for (let index = 0; index < 14; index += 1) { + const created = index + 1; + const role: 'user' | 'assistant' = created % 2 === 1 ? 'user' : 'assistant'; + const id = role === 'user' ? `u_${created}` : `a_${created}`; + messages.push(createMessage(id, role, created)); + part[id] = [createPart(`prt_${id}`, id, role === 'user' ? `prompt ${created}` : `output ${created}`)]; + } + return { messages, part }; +}; + +const syncContext = (globalThis as unknown as { + __openchamber_sync_context__?: React.Context; +}).__openchamber_sync_context__; + +if (!syncContext) { + throw new Error('sync context was not published on globalThis by @/sync/sync-context'); +} + describe('issue #2903 busy embedded subagent status-line-only', () => { - test('materialized 14-message subagent is renderable and snapshottable', () => { - const materialized = buildFourteenMessageSnapshot(); - expect(materialized.message[SESSION_ID]).toHaveLength(14); - expect(getSessionMaterializationStatus(materialized, SESSION_ID)).toEqual({ + test('cold disabled reads hide a fully materialized 14-message subagent; enabled reads return all 14', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const childStores = new ChildStoreManager(); + const store = childStores.ensureChild(DIRECTORY, { bootstrap: false }); + const { messages, part } = buildMaterializedSubagentSession(); + store.setState({ + status: 'complete', + session: [{ + id: SESSION_ID, + title: 'Audit Searchbar implementation', + time: { created: 1, updated: 1 }, + version: '1', + directory: DIRECTORY, + } as State['session'][number]], + message: { [SESSION_ID]: messages }, + part, + } as Partial); + + expect(getSessionMaterializationStatus(store.getState(), SESSION_ID)).toEqual({ hasMessages: true, renderable: true, missingPartMessageIDs: [], }); - const records = buildSessionMessageRecordsSnapshot( - { ...INITIAL_STATE, message: materialized.message, part: materialized.part }, - SESSION_ID, - ); - expect(records.list).toHaveLength(14); - expect(records.list.map((record) => record.info.id)).toEqual( - materialized.message[SESSION_ID].map((message) => message.id), - ); + const system = { childStores, messageLoader: {}, sdk: {}, runtimeKey: 'test', directory: DIRECTORY }; + const Provider = syncContext.Provider as React.Provider; + let inactiveCount = -1; + let activeCount = -1; + let enabled = false; + + const Harness = () => { + const records = useSessionMessageRecords(SESSION_ID, DIRECTORY, { enabled }); + if (enabled) { + activeCount = records.length; + } else { + inactiveCount = records.length; + } + return null; + }; + + try { + await act(async () => { + root.render(React.createElement(Provider, { value: system }, React.createElement(Harness))); + }); + expect(inactiveCount).toBe(0); + + enabled = true; + await act(async () => { + root.render(React.createElement(Provider, { value: system }, React.createElement(Harness))); + }); + expect(activeCount).toBe(14); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } }); test('sync gate still returns empty on cold disabled reads', () => { @@ -101,6 +222,8 @@ describe('issue #2903 busy embedded subagent status-line-only', () => { expect(chatContainerSource).toContain('const messagesEnabled = messagesEnabledProp ?? active;'); expect(chatContainerSource).toContain('enabled: messagesEnabled'); expect(chatContainerSource.includes('enabled: active')).toBe(false); + expect(chatContainerSource).toContain('if (!messagesEnabled || !currentSessionId) return;'); + expect(chatContainerSource).toContain('void ensureSessionRenderable(currentSessionId);'); }); test('empty+busy branch skips empty state so StatusRowContainer can stand alone', () => { From 47acc4830077623cca9e664f0856679fccf80258 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Sat, 15 Aug 2026 03:51:35 +0000 Subject: [PATCH 5/5] docs(changelog): note context-panel subagent history fix Co-authored-by: serkraser --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd02195..a980e698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. - **Usage/Claude:** Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes. - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. +- Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). ## [1.18.4] - 2026-08-14