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