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
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..a50ea6e7 100644
--- a/packages/ui/src/components/chat/ChatContainer.tsx
+++ b/packages/ui/src/components/chat/ChatContainer.tsx
@@ -533,12 +533,27 @@ const DraftWelcome: React.FC = () => {
type ChatContainerProps = {
active?: boolean;
+ /**
+ * 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;
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 +606,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,
});
@@ -1042,9 +1059,9 @@ export const ChatContainer: React.FC = ({ active = true, aut
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;
@@ -1069,10 +1086,10 @@ export const ChatContainer: React.FC = ({ active = true, aut
}, [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
new file mode 100644
index 00000000..cd72ed46
--- /dev/null
+++ b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx
@@ -0,0 +1,249 @@
+/**
+ * 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, 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';
+
+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');
+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 DIRECTORY = '/repo';
+
+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('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 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', () => {
+ 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', () => {
+ 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);
+ 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', () => {
+ 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