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 <makeittech@users.noreply.github.com>
This commit is contained in:
committed by
Cursor Agent
co-authored by
Serhii Dziupin
parent
1060ad1913
commit
903638db94
@@ -209,6 +209,11 @@ const EmbeddedSessionChatContent: React.FC<{
|
||||
<OpenCodeUpdateToast />
|
||||
<ChatView
|
||||
active={embeddedBackgroundWorkEnabled}
|
||||
// Always subscribe to message history in the mounted session-chat
|
||||
// iframe. Visibility still gates composer focus and background work so
|
||||
// a boot-inactive / lost-handshake race cannot leave a busy subagent
|
||||
// showing only its status row (#2903 / #2892).
|
||||
messagesEnabled={true}
|
||||
readOnly={embeddedSessionChat.readOnly}
|
||||
initialAllowPromptingSubagentSessions={embeddedSessionChat.allowPromptingSubagentSessions}
|
||||
/>
|
||||
|
||||
@@ -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<ChatContainerProps> = ({ active = true, autoOpenDraft = true, readOnly = false, initialAllowPromptingSubagentSessions }) => {
|
||||
export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
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<ChatContainerProps> = ({ 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,
|
||||
});
|
||||
|
||||
+143
@@ -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('<ChatEmptyState');
|
||||
expect(chatContainerSource).toContain('<StatusRowContainer />');
|
||||
|
||||
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('<ChatEmptyState');
|
||||
expect(emptyStateBlock).not.toContain('<StatusRowContainer />');
|
||||
});
|
||||
|
||||
test('visibility handshake remains as defense-in-depth for background work', () => {
|
||||
expect(appSource).toContain('requestEmbeddedSessionVisibility();');
|
||||
expect(appSource).toContain('EMBEDDED_VISIBILITY_UPDATE');
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -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 });',
|
||||
);
|
||||
|
||||
@@ -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<ChatViewProps> = ({ active = true, readOnly = false, initialAllowPromptingSubagentSessions }) => {
|
||||
export const ChatView: React.FC<ChatViewProps> = ({
|
||||
active = true,
|
||||
messagesEnabled,
|
||||
readOnly = false,
|
||||
initialAllowPromptingSubagentSessions,
|
||||
}) => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
|
||||
return (
|
||||
<ChatErrorBoundary sessionId={currentSessionId || undefined}>
|
||||
<ChatContainer
|
||||
active={active}
|
||||
messagesEnabled={messagesEnabled}
|
||||
readOnly={readOnly}
|
||||
initialAllowPromptingSubagentSessions={initialAllowPromptingSubagentSessions}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user