diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 5ad34ba108..28bd0cad09 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -789,7 +789,7 @@
"react": 1
},
"importSpecifiers": 93,
- "nonTriviaTokens": 12430
+ "nonTriviaTokens": 12429
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 0,
diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts
index 3b7f2b6b0f..ac7e4a62e1 100644
--- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts
+++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts
@@ -25,7 +25,8 @@ import type { SessionSummary } from '@maka/core/session';
import { armLiveTurn, applyLiveTurnBufferEvent, reconcileLiveTurnBuffer } from '@maka/ui';
import type { StoredMessage } from '@maka/core/session';
import { act, createElement } from 'react';
-import { LiveTurnReconciler } from '../../renderer/features/conversation/index.js';
+import { parentTaskStatusFromFacts } from '../../renderer/features/workbar/testing.js';
+import { activeHostTurn, LiveTurnReconciler } from '../../renderer/features/conversation/index.js';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
import { normalizeSessionSummaryForDisplay } from '../../renderer/application/contracts/session-status-presentation.js';
import {
@@ -69,7 +70,7 @@ it('reconciles late predecessor content after its durable answer is already load
const controller = createAppShellSessionUiStateController();
const b = { turnId: 'B', steps: [{ stepId: 'bash', tools: [{ toolUseId: 'bash', toolName: 'Bash', args: {}, status: 'running' as const }] }] };
controller.setLiveTurnBySession(() => ({ session: [b] }));
- controller.setExecution('session', { type: 'host_execution', available: true,
+ controller.setExecution('session', { type: 'host_execution', pendingInteractionKinds: [], available: true,
rootTurn: { sessionId: 'session', turnId: 'B', runId: 'run-B', status: 'running' } });
const messages: StoredMessage[] = [
{ type: 'assistant', id: 'answer-A', turnId: 'A', ts: 1, text: 'Alpha completed full answer', modelId: 'test' },
@@ -160,6 +161,7 @@ describe('app shell session UI state controller', () => {
const controller = createAppShellSessionUiStateController();
const projection = {
type: 'host_execution' as const,
+ pendingInteractionKinds: [],
available: true,
rootTurn: { sessionId: 'session', turnId: 'turn', runId: 'run', status: 'running' as const },
};
@@ -317,6 +319,83 @@ describe('app shell session UI state controller', () => {
});
});
+describe('producer-side execution history invalidation', () => {
+ const runningRoot = {
+ sessionId: 'session',
+ turnId: 'turn-1',
+ runId: 'run-1',
+ status: 'running' as const,
+ };
+
+ it('reports a failure before the first seed as unavailable, not as unread', () => {
+ const controller = createAppShellSessionUiStateController();
+ controller.setExecution('session', null);
+ assert.deepEqual(controller.getState().executionBySession.session, {
+ type: 'host_execution',
+ available: false,
+ rootTurn: null,
+ pendingInteractionKinds: [],
+ });
+ });
+
+ it('marks a known projection unavailable while keeping the identity Stop needs', () => {
+ const controller = createAppShellSessionUiStateController();
+ controller.setExecution('session', {
+ type: 'host_execution',
+ available: true,
+ rootTurn: runningRoot,
+ pendingInteractionKinds: ['permission'],
+ });
+ controller.setExecution('session', null);
+ const projection = controller.getState().executionBySession.session;
+ assert.equal(projection?.available, false);
+ assert.equal(activeHostTurn(projection)?.turnId, 'turn-1');
+ assert.deepEqual(projection?.pendingInteractionKinds, ['permission']);
+ });
+
+ it('does not let the cleanup path claim a failure it did not observe', () => {
+ const controller = createAppShellSessionUiStateController();
+ controller.setExecution('session', undefined);
+ assert.equal(controller.getState().executionBySession.session, undefined);
+ });
+
+ it('retains Stop identity but hides a normal resubscription until the new seed', () => {
+ const controller = createAppShellSessionUiStateController();
+ controller.setExecution('session', {
+ type: 'host_execution', available: true, rootTurn: runningRoot, pendingInteractionKinds: [],
+ });
+ controller.setExecution('session', undefined);
+ const pending = controller.getState().executionBySession.session;
+ assert.equal(activeHostTurn(pending)?.turnId, 'turn-1');
+ assert.equal(parentTaskStatusFromFacts({ execution: pending, latestTurnRead: { status: 'pending' } }), null);
+ controller.setExecution('session', null);
+ const failed = controller.getState().executionBySession.session;
+ assert.equal(activeHostTurn(failed)?.turnId, 'turn-1');
+ assert.equal(parentTaskStatusFromFacts({ execution: failed, latestTurnRead: { status: 'pending' } }), 'unavailable');
+ });
+
+ it('rereads history once observation recovers', () => {
+ const controller = createAppShellSessionUiStateController();
+ const epoch = () => controller.getState().executionHistoryEpochBySession.session;
+ controller.setExecution('session', {
+ type: 'host_execution',
+ available: true,
+ rootTurn: runningRoot,
+ pendingInteractionKinds: [],
+ });
+ assert.equal(epoch(), undefined);
+ controller.setExecution('session', null);
+ assert.equal(epoch(), undefined, 'a failure is not a new history');
+ controller.setExecution('session', {
+ type: 'host_execution',
+ available: true,
+ rootTurn: null,
+ pendingInteractionKinds: [],
+ });
+ assert.equal(epoch(), 1, 'the settled projection invalidates the frozen history');
+ });
+});
+
describe('shellSessionRowEqual', () => {
const row: DesktopSessionSummary = {
id: 'session-1',
diff --git a/apps/desktop/src/main/__tests__/parent-interaction-focus.test.ts b/apps/desktop/src/main/__tests__/parent-interaction-focus.test.ts
new file mode 100644
index 0000000000..709b0eba1f
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/parent-interaction-focus.test.ts
@@ -0,0 +1,262 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { strict as assert } from 'node:assert';
+import { describe, it } from 'node:test';
+import { parseHTML } from 'linkedom';
+import { focusParentConversation } from '../../renderer/features/workbar/testing.js';
+
+/**
+ * The action resolves its workspace from the element the user acted on, so the
+ * fixture is the real markup shape: a marked frame, a marked parent region
+ * inside it, and the Workbar column as a sibling of that region.
+ */
+function frame(id: string, hidden = false): string {
+ return `
+
`;
+}
+
+/**
+ * linkedom is neither a style engine nor a layout engine, so this fixture states
+ * the two facts production reads from those engines: every element reports one
+ * client rect, and the inline `style` attribute is resolved the way a browser
+ * resolves `visibility` (inherited) and `display` (not inherited).
+ *
+ * That makes this a unit test of the selection rules only. Whether a real
+ * Chromium computes those styles, and where focus lands after a real click, is
+ * asserted by the browser story play.
+ */
+function setup(html: string) {
+ const parsed = parseHTML(`${html}`);
+ const document = parsed.document as unknown as Document;
+ const focusTargets = parsed.window.HTMLElement.prototype as unknown as {
+ focus(this: { id: string }): void;
+ };
+ const focused: string[] = [];
+ for (const element of document.querySelectorAll('*')) {
+ Object.defineProperty(element, 'getClientRects', {
+ configurable: true,
+ value: () => [{ width: 10, height: 10 }],
+ });
+ }
+ const declared = (element: Element, property: string) =>
+ new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([a-z-]+)`).exec(
+ element.getAttribute('style') ?? '',
+ )?.[1];
+ (parsed.window as unknown as { getComputedStyle: (element: Element) => unknown }).getComputedStyle =
+ (element: Element) => {
+ let visibility = 'visible';
+ for (let ancestor: Element | null = element; ancestor; ancestor = ancestor.parentElement) {
+ const own = declared(ancestor, 'visibility');
+ if (own) {
+ visibility = own;
+ break;
+ }
+ }
+ return { display: declared(element, 'display') ?? 'block', visibility };
+ };
+ focusTargets.focus = function focus(this: { id: string }) {
+ focused.push(this.id);
+ };
+ const element = (id: string) => {
+ const found = document.querySelector(`#${id}`);
+ assert.ok(found, `fixture element ${id} is missing`);
+ return found;
+ };
+ return { document, element, focused };
+}
+
+/** The parent region of one frame, with whatever the case needs inside its interaction slot. */
+function frameWithSlot(id: string, slot: string): string {
+ return `
+ `;
+}
+
+describe('focusParentConversation', () => {
+ it('focuses the originating workspace parent control and never the side chat', () => {
+ const { element, focused } = setup(frame('a'));
+ focusParentConversation(element('a-open'));
+ assert.deepEqual(focused, ['a-allow']);
+ });
+
+ it('prefers the parent composer when the interaction slot has no usable control', () => {
+ const { element, focused } = setup(`
+ `);
+ focusParentConversation(element('a-open'));
+ assert.deepEqual(focused, ['a-composer']);
+ });
+
+ it('does not focus a boundary retry that merely shares the interaction slot', () => {
+ const { element, focused } = setup(`
+ `);
+ focusParentConversation(element('a-open'));
+ assert.deepEqual(focused, ['a-composer']);
+ });
+
+ it('never crosses into an adjacent workspace, even for the same action', () => {
+ const { element, focused } = setup(`${frame('a')}${frame('b')}`);
+ focusParentConversation(element('b-open'));
+ assert.deepEqual(focused, ['b-allow']);
+ });
+
+ it('focuses nothing when the originating workspace is hidden', () => {
+ const { element, focused } = setup(frame('a', true));
+ focusParentConversation(element('a-open'));
+ assert.deepEqual(focused, []);
+ });
+
+ it('focuses nothing for an origin outside any marked workspace', () => {
+ const { document, focused } = setup(frame('a'));
+ const detached = document.createElement('button');
+ focusParentConversation(detached);
+ focusParentConversation();
+ assert.deepEqual(focused, []);
+ });
+
+ it('skips a control a disabled fieldset disables', () => {
+ const { element, focused } = setup(
+ frameWithSlot(
+ 'a',
+ `
+ 审批
+ 拒绝
+
+ 允许 `,
+ ),
+ );
+ focusParentConversation(element('a-open'));
+ assert.deepEqual(focused, ['a-allow']);
+ });
+
+ it('keeps a control the disabled fieldset exempts through its first legend', () => {
+ const { element, focused } = setup(
+ frameWithSlot(
+ 'a',
+ `
+ 本任务允许
+ 拒绝
+ `,
+ ),
+ );
+ focusParentConversation(element('a-open'));
+ assert.deepEqual(focused, ['a-exempt']);
+ });
+
+ it('skips a control whose only eligible sibling sits under an inert ancestor', () => {
+ const { element, focused } = setup(
+ frameWithSlot(
+ 'a',
+ `
+ 允许 `,
+ ),
+ );
+ focusParentConversation(element('a-open'));
+ assert.deepEqual(focused, ['a-allow']);
+ });
+
+ it('falls back to a composer outside the inert subtree', () => {
+ const { element, focused } = setup(
+ frameWithSlot('b', `本任务允许
`),
+ );
+ focusParentConversation(element('b-open'));
+ assert.deepEqual(focused, ['b-composer']);
+ });
+
+ it('skips computed-invisible targets before the composer fallback', () => {
+ const { element, focused } = setup(
+ frameWithSlot(
+ 'a',
+ `本任务允许
+ 拒绝
+ 拒绝
+ 允许 `,
+ ),
+ );
+ focusParentConversation(element('a-open'));
+ assert.deepEqual(focused, ['a-allow']);
+ });
+
+ it('skips a composer that is computed-invisible in the parent region', () => {
+ const { element, focused } = setup(`
+ `);
+ focusParentConversation(element('a-open'));
+ assert.deepEqual(focused, []);
+ });
+});
diff --git a/apps/desktop/src/main/__tests__/parent-task-status-hook.test.ts b/apps/desktop/src/main/__tests__/parent-task-status-hook.test.ts
new file mode 100644
index 0000000000..84c4cc398e
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/parent-task-status-hook.test.ts
@@ -0,0 +1,383 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { deferred } from '@maka/core/test-only/async-primitives';
+import { strict as assert } from 'node:assert';
+import { afterEach, describe, it } from 'node:test';
+import { act, createElement, useLayoutEffect } from 'react';
+import type { SessionEvent } from '@maka/core/events';
+import type { TurnRecord } from '@maka/core/session';
+import { LocaleProvider } from '@maka/ui';
+import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
+import {
+ createFakeWorkbarServices,
+ hostExecutionProjection,
+ useParentTaskStatus,
+ WorkbarServicesProvider,
+ type SessionExecutionProjection,
+ type VisibleParentTaskStatus,
+ type WorkbarServices,
+} from '../../renderer/features/workbar/testing.js';
+
+type StatusCommit = {
+ readonly sessionId: string | undefined;
+ readonly status: VisibleParentTaskStatus | null;
+};
+
+type ProbeProps = {
+ sessionId: string | undefined;
+ execution: SessionExecutionProjection | undefined;
+ historyEpoch: number | undefined;
+};
+
+let commits: StatusCommit[] = [];
+
+function StatusProbe(props: ProbeProps) {
+ const status = useParentTaskStatus({
+ sessionId: props.sessionId,
+ execution: props.execution,
+ historyEpoch: props.historyEpoch,
+ });
+ useLayoutEffect(() => {
+ commits.push({ sessionId: props.sessionId, status });
+ });
+ return null;
+}
+
+function renderStatus(
+ root: ReturnType['root'],
+ services: WorkbarServices,
+ props: Partial & { sessionId: string | undefined },
+) {
+ root.render(
+ createElement(LocaleProvider, {
+ locale: 'en',
+ children: createElement(WorkbarServicesProvider, {
+ services,
+ children: createElement(StatusProbe, {
+ execution: undefined,
+ historyEpoch: 0,
+ ...props,
+ }),
+ }),
+ }),
+ );
+}
+
+function lastStatus(): VisibleParentTaskStatus | null | undefined {
+ return commits.at(-1)?.status;
+}
+
+/** The hook is props-driven: these readers stand in for the owning producers. */
+function parentFacts(options?: {
+ listTurns?: (sessionId: string) => Promise;
+ onSubscribe?: () => void;
+}) {
+ const reads: string[] = [];
+ const defaults = createFakeWorkbarServices();
+ const services = createFakeWorkbarServices({
+ sideChat: {
+ ...defaults.sideChat,
+ listTurns: async (sessionId) => {
+ reads.push(sessionId);
+ return options?.listTurns ? options.listTurns(sessionId) : [];
+ },
+ subscribeEvents: (
+ _sessionId: string,
+ _handler: (event: SessionEvent) => void,
+ onReady?: () => void,
+ ) => {
+ options?.onSubscribe?.();
+ onReady?.();
+ return () => undefined;
+ },
+ },
+ });
+ return { services, reads };
+}
+
+const runningTurn = {
+ sessionId: 'parent-1',
+ turnId: 'turn-1',
+ runId: 'run-1',
+ status: 'running' as const,
+};
+
+function completedTurn(sessionId = 'parent-1') {
+ return {
+ sessionId,
+ turnId: 'done',
+ runId: 'run',
+ status: 'completed' as const,
+ terminalEventId: 'done',
+ };
+}
+
+describe('useParentTaskStatus', () => {
+ afterEach(() => {
+ commits = [];
+ cleanupFakeDom();
+ });
+
+ it('reports nothing and reads nothing before the owning projection arrives', async () => {
+ const { root } = installReactRenderer();
+ const facts = parentFacts();
+ await act(async () => renderStatus(root, facts.services, { sessionId: 'parent-1' }));
+ assert.equal(lastStatus(), null);
+ assert.deepEqual(facts.reads, []);
+
+ // A projection that reports itself unavailable is the real failure case.
+ await act(async () =>
+ renderStatus(root, facts.services, {
+ sessionId: 'parent-1',
+ execution: hostExecutionProjection(false, null),
+ }),
+ );
+ assert.equal(lastStatus(), 'unavailable');
+ assert.deepEqual(facts.reads, []);
+ });
+
+ it('reads nothing when no Side Conversation is visible', async () => {
+ const { root } = installReactRenderer();
+ const facts = parentFacts();
+ await act(async () =>
+ renderStatus(root, facts.services, {
+ sessionId: undefined,
+ execution: hostExecutionProjection(true, null),
+ }),
+ );
+ assert.equal(lastStatus(), null);
+ assert.deepEqual(facts.reads, []);
+ });
+
+ it('reads settled history once and stays silent while the read is in flight', async () => {
+ const { root } = installReactRenderer();
+ const turns = deferred();
+ const facts = parentFacts({ listTurns: async () => turns.promise });
+ const execution = hostExecutionProjection(true, null);
+ await act(async () =>
+ renderStatus(root, facts.services, { sessionId: 'parent-1', execution }),
+ );
+ assert.equal(lastStatus(), null, 'an initial history load is not unavailable');
+ assert.deepEqual(facts.reads, ['parent-1']);
+
+ await act(async () => {
+ turns.resolve([{ turnId: 'done', status: 'completed' }]);
+ await turns.promise;
+ });
+ assert.equal(lastStatus(), 'last_turn_completed');
+
+ await act(async () =>
+ renderStatus(root, facts.services, { sessionId: 'parent-1', execution }),
+ );
+ assert.deepEqual(facts.reads, ['parent-1'], 'equivalent projections do not reread');
+ });
+
+ it('reads the live root turn instead of history while the parent is busy', async () => {
+ const { root } = installReactRenderer();
+ const facts = parentFacts();
+ await act(async () =>
+ renderStatus(root, facts.services, {
+ sessionId: 'parent-1',
+ execution: hostExecutionProjection(true, runningTurn, ['permission']),
+ }),
+ );
+ assert.equal(lastStatus(), 'waiting_approval');
+ assert.deepEqual(facts.reads, []);
+ });
+
+ it('retries a failed read on a bounded backoff, then reports unavailable', async (context) => {
+ context.mock.timers.enable({ apis: ['setTimeout'] });
+ const { root } = installReactRenderer();
+ const facts = parentFacts({
+ listTurns: async () => {
+ throw new Error('listTurns rejected');
+ },
+ });
+ const execution = hostExecutionProjection(true, null);
+ await act(async () =>
+ renderStatus(root, facts.services, { sessionId: 'parent-1', execution }),
+ );
+ assert.equal(facts.reads.length, 1);
+ assert.equal(lastStatus(), null, 'a first failure is still a load, not a verdict');
+
+ await act(async () => context.mock.timers.tick(99));
+ assert.equal(facts.reads.length, 1, 'no early retry');
+ await act(async () => context.mock.timers.tick(1));
+ assert.equal(facts.reads.length, 2);
+ assert.equal(lastStatus(), null);
+
+ await act(async () => context.mock.timers.tick(199));
+ assert.equal(facts.reads.length, 2);
+ await act(async () => context.mock.timers.tick(1));
+ assert.equal(facts.reads.length, 3);
+ assert.equal(lastStatus(), 'unavailable');
+
+ await act(async () => context.mock.timers.tick(60_000));
+ assert.equal(facts.reads.length, 3, 'auto recovery is bounded');
+ });
+
+ it('recovers without a new Host frame when the producer invalidates history', async (context) => {
+ context.mock.timers.enable({ apis: ['setTimeout'] });
+ const { root } = installReactRenderer();
+ let attempt = 0;
+ const facts = parentFacts({
+ listTurns: async () => {
+ attempt += 1;
+ if (attempt === 1) throw new Error('listTurns rejected');
+ return [{ turnId: 'recovered', status: 'failed' }];
+ },
+ });
+ const execution = hostExecutionProjection(true, null);
+ await act(async () =>
+ renderStatus(root, facts.services, { sessionId: 'parent-1', execution, historyEpoch: 0 }),
+ );
+ assert.equal(facts.reads.length, 1);
+ // The producer reports a new invalidation revision before the retry fires.
+ await act(async () =>
+ renderStatus(root, facts.services, { sessionId: 'parent-1', execution, historyEpoch: 1 }),
+ );
+ assert.equal(facts.reads.length, 2);
+ assert.equal(lastStatus(), 'last_turn_failed');
+ await act(async () => context.mock.timers.tick(60_000));
+ assert.equal(facts.reads.length, 2, 'the superseded attempt never fires again');
+ });
+
+ it('drops a history result that belongs to a superseded revision', async () => {
+ const { root } = installReactRenderer();
+ const first = deferred();
+ const second = deferred();
+ let reads = 0;
+ const facts = parentFacts({
+ listTurns: async () => (++reads === 1 ? first.promise : second.promise),
+ });
+ const execution = hostExecutionProjection(true, null);
+ await act(async () =>
+ renderStatus(root, facts.services, { sessionId: 'parent-1', execution, historyEpoch: 0 }),
+ );
+ await act(async () =>
+ renderStatus(root, facts.services, { sessionId: 'parent-1', execution, historyEpoch: 1 }),
+ );
+ assert.equal(reads, 2);
+ await act(async () => {
+ first.resolve([{ turnId: 'stale', status: 'completed' }]);
+ await first.promise;
+ });
+ assert.notEqual(lastStatus(), 'last_turn_completed');
+ await act(async () => {
+ second.resolve([{ turnId: 'fresh', status: 'failed' }]);
+ await second.promise;
+ });
+ assert.equal(lastStatus(), 'last_turn_failed');
+ });
+
+ it('cancels the pending read when a live root turn appears', async () => {
+ const { root } = installReactRenderer();
+ const turns = deferred();
+ const facts = parentFacts({ listTurns: async () => turns.promise });
+ await act(async () =>
+ renderStatus(root, facts.services, {
+ sessionId: 'parent-1',
+ execution: hostExecutionProjection(true, null),
+ }),
+ );
+ assert.deepEqual(facts.reads, ['parent-1']);
+ await act(async () =>
+ renderStatus(root, facts.services, {
+ sessionId: 'parent-1',
+ execution: hostExecutionProjection(true, runningTurn),
+ }),
+ );
+ assert.equal(lastStatus(), 'running');
+ await act(async () => {
+ turns.resolve([{ turnId: 'stale', status: 'failed' }]);
+ await turns.promise;
+ });
+ assert.equal(lastStatus(), 'running');
+ });
+
+ it('never shows one Session the answer read for another', async () => {
+ const { root } = installReactRenderer();
+ const first = deferred();
+ const second = deferred();
+ const facts = parentFacts({
+ listTurns: async (sessionId) => (sessionId === 'parent-1' ? first.promise : second.promise),
+ });
+ await act(async () =>
+ renderStatus(root, facts.services, {
+ sessionId: 'parent-1',
+ execution: hostExecutionProjection(true, null),
+ }),
+ );
+ await act(async () =>
+ renderStatus(root, facts.services, {
+ sessionId: 'parent-2',
+ execution: hostExecutionProjection(true, null),
+ }),
+ );
+ assert.equal(lastStatus(), null);
+ await act(async () => {
+ first.resolve([{ turnId: 'stale', status: 'completed' }]);
+ await first.promise;
+ });
+ assert.equal(lastStatus(), null, 'the previous Session’s read is fenced out');
+ await act(async () => {
+ second.resolve([{ turnId: 'fresh', status: 'failed' }]);
+ await second.promise;
+ });
+ assert.equal(lastStatus(), 'last_turn_failed');
+ });
+
+ it('ignores a late read after unmount', async () => {
+ const { root } = installReactRenderer();
+ const turns = deferred();
+ const facts = parentFacts({ listTurns: async () => turns.promise });
+ await act(async () =>
+ renderStatus(root, facts.services, {
+ sessionId: 'parent-1',
+ execution: hostExecutionProjection(true, null),
+ }),
+ );
+ await act(async () => root.unmount());
+ const afterUnmount = commits.length;
+ await act(async () => {
+ turns.resolve([{ turnId: 'late', status: 'completed' }]);
+ await turns.promise;
+ });
+ assert.equal(commits.length, afterUnmount);
+ });
+
+ it('registers no extra Host observation of its own', async () => {
+ const { root } = installReactRenderer();
+ let subscriptions = 0;
+ const facts = parentFacts({
+ onSubscribe: () => {
+ subscriptions += 1;
+ },
+ });
+ await act(async () =>
+ renderStatus(root, facts.services, {
+ sessionId: 'parent-1',
+ execution: hostExecutionProjection(true, null),
+ }),
+ );
+ assert.deepEqual(facts.reads, ['parent-1']);
+ assert.equal(subscriptions, 0);
+ });
+});
diff --git a/apps/desktop/src/main/__tests__/parent-task-status-notice.test.ts b/apps/desktop/src/main/__tests__/parent-task-status-notice.test.ts
new file mode 100644
index 0000000000..e4a9bb409c
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/parent-task-status-notice.test.ts
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { strict as assert } from 'node:assert';
+import { afterEach, describe, it } from 'node:test';
+import { act, createElement } from 'react';
+import { LocaleProvider } from '@maka/ui';
+import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
+import {
+ ParentTaskStatusNotice,
+} from '../../renderer/features/workbar/testing.js';
+
+describe('ParentTaskStatusNotice', () => {
+ afterEach(() => {
+ cleanupFakeDom();
+ delete (globalThis as { window?: unknown }).window;
+ });
+
+ it('shows the latest-turn failure and the parent conversation action', async () => {
+ const { root, container } = installReactRenderer();
+ let opened = 0;
+ let origin: Element | null | undefined;
+ await act(async () => {
+ root.render(
+ createElement(LocaleProvider, {
+ locale: 'zh-CN',
+ children: createElement(ParentTaskStatusNotice, {
+ status: 'last_turn_failed',
+ onOpenParentConversation: (element) => {
+ opened += 1;
+ origin = element;
+ },
+ }),
+ }),
+ );
+ });
+ const text = container.textContent;
+ assert.match(text, /主任务最近一轮失败/);
+ assert.match(text, /前往主对话/);
+ const notice = findByContract(container, 'side-chat-parent-status');
+ assert.equal(notice?.attributes.get('data-status'), 'last_turn_failed');
+ const button = findButton(container);
+ assert.ok(button, 'parent conversation action is missing');
+ click(button, button);
+ assert.equal(opened, 1);
+ assert.equal(origin, button, 'the action hands its own workspace frame to the focus owner');
+ });
+
+ for (const status of ['waiting_approval', 'running', 'last_turn_failed', 'last_turn_completed'] as const) {
+ it(`renders one polite live region for ${status}`, async () => {
+ const { root, container } = installReactRenderer();
+ await act(async () => {
+ root.render(createElement(LocaleProvider, {
+ locale: 'en',
+ children: createElement(ParentTaskStatusNotice, { status }),
+ }));
+ });
+ const live = findByRole(container, 'status');
+ assert.ok(live, 'a status live region is missing');
+ assert.equal(live.attributes.get('aria-live'), 'polite');
+ assert.match(live.textContent, /parent/i);
+ assert.equal(countByRole(container, 'status') + countByRole(container, 'alert'), 1,
+ 'the Banner owns the only live region, including warning and error states');
+ });
+ }
+
+});
+
+function findByRole(
+ node: { childNodes: readonly unknown[]; attributes?: Map; textContent?: string },
+ role: string,
+): { attributes: Map; textContent: string } | undefined {
+ if (node.attributes?.get('role') === role) {
+ return node as { attributes: Map; textContent: string };
+ }
+ for (const child of node.childNodes) {
+ if (child && typeof child === 'object' && 'childNodes' in child) {
+ const found = findByRole(
+ child as { childNodes: readonly unknown[]; attributes?: Map },
+ role,
+ );
+ if (found) return found;
+ }
+ }
+ return undefined;
+}
+
+function countByRole(
+ node: { childNodes: readonly unknown[]; attributes?: Map },
+ role: string,
+): number {
+ let count = node.attributes?.get('role') === role ? 1 : 0;
+ for (const child of node.childNodes) {
+ if (child && typeof child === 'object' && 'childNodes' in child) {
+ count += countByRole(
+ child as { childNodes: readonly unknown[]; attributes?: Map },
+ role,
+ );
+ }
+ }
+ return count;
+}
+
+function findByContract(node: { childNodes: readonly unknown[]; attributes?: Map }, value: string): { attributes: Map; childNodes: readonly unknown[] } | undefined {
+ if (node.attributes?.get('data-maka-contract') === value) {
+ return node as { attributes: Map; childNodes: readonly unknown[] };
+ }
+ for (const child of node.childNodes) {
+ if (child && typeof child === 'object' && 'childNodes' in child) {
+ const found = findByContract(child as { childNodes: readonly unknown[]; attributes?: Map }, value);
+ if (found) return found;
+ }
+ }
+ return undefined;
+}
+
+function findButton(node: { childNodes?: readonly unknown[]; tagName?: string; onclick?: unknown; props?: unknown }): { click?: () => void; onclick?: () => void } | undefined {
+ if (node.tagName === 'BUTTON') return node as { click?: () => void; onclick?: () => void };
+ for (const child of node.childNodes ?? []) {
+ if (child && typeof child === 'object') {
+ const found = findButton(child as { childNodes?: readonly unknown[]; tagName?: string });
+ if (found) return found;
+ }
+ }
+ return undefined;
+}
+
+function click(node: object, currentTarget?: unknown) {
+ const event = { preventDefault() {}, stopPropagation() {}, currentTarget };
+ const reactProps = Object.entries(node).find(([key]) => key.startsWith('__reactProps$'))?.[1] as
+ | { onClick?: (event: unknown) => void }
+ | undefined;
+ if (typeof reactProps?.onClick === 'function') {
+ reactProps.onClick(event);
+ return;
+ }
+ const host = node as { click?: () => void; onclick?: (event: unknown) => void; onClick?: (event: unknown) => void };
+ if (typeof host.onClick === 'function') {
+ host.onClick(event);
+ return;
+ }
+ if (typeof host.onclick === 'function' && host.onclick.name !== 'noop$1') {
+ host.onclick(event);
+ return;
+ }
+ host.click?.();
+}
diff --git a/apps/desktop/src/main/__tests__/parent-task-status.test.ts b/apps/desktop/src/main/__tests__/parent-task-status.test.ts
new file mode 100644
index 0000000000..99e7a6b247
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/parent-task-status.test.ts
@@ -0,0 +1,356 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { strict as assert } from 'node:assert';
+import { describe, it } from 'node:test';
+import {
+ activateSessionWorkbarTab,
+ classifyPendingInteractionKind,
+ createSessionWorkbarPanelsState,
+ createSessionWorkbarTabsState,
+ hostExecutionProjection,
+ openSessionWorkbarLauncher,
+ parentTaskStatusFromFacts,
+ projectWorkbarPanelsForSession,
+ visibleParentTaskStatus,
+ visibleSideChatParentSessionId,
+ type HostPendingInteractionKind,
+ type SessionWorkbarPanelsState,
+ type SessionWorkbarTab,
+} from '../../renderer/features/workbar/testing.js';
+
+const ALL_KINDS: readonly HostPendingInteractionKind[] = [
+ 'permission',
+ 'question',
+ 'form',
+ 'sandbox_boundary',
+ 'client_capability',
+];
+
+const runningTurn = {
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ runId: 'run-1',
+ status: 'running' as const,
+};
+
+describe('parentTaskStatusFromFacts', () => {
+ it('classifies every canonical pending kind as input or approval', () => {
+ const classified = Object.fromEntries(
+ ALL_KINDS.map((kind) => [kind, classifyPendingInteractionKind(kind)]),
+ );
+ assert.deepEqual(classified, {
+ permission: 'approval',
+ question: 'input',
+ form: 'input',
+ sandbox_boundary: 'approval',
+ client_capability: 'approval',
+ });
+ });
+
+ it('maps pending question and form to waiting input', () => {
+ for (const kind of ['question', 'form'] as const) {
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, runningTurn, [kind]),
+ latestTurnRead: { status: 'pending' },
+ }),
+ 'waiting_input',
+ );
+ }
+ });
+
+ it('maps pending permission, sandbox, and client capability to waiting approval', () => {
+ for (const kind of ['permission', 'sandbox_boundary', 'client_capability'] as const) {
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, runningTurn, [kind]),
+ latestTurnRead: { status: 'pending' },
+ }),
+ 'waiting_approval',
+ );
+ }
+ });
+
+ it('maps mixed input and approval pending kinds together', () => {
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, runningTurn, ['form', 'permission']),
+ latestTurnRead: { status: 'pending' },
+ }),
+ 'waiting_input_and_approval',
+ );
+ });
+
+ it('maps a live root turn without pending interactions to running', () => {
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, {
+ ...runningTurn,
+ status: 'waiting_for_user',
+ }),
+ latestTurnRead: { status: 'pending' },
+ }),
+ 'running',
+ );
+ });
+
+ it('labels terminal root turns as the latest parent turn', () => {
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, {
+ ...runningTurn,
+ status: 'completed',
+ terminalEventId: 'done',
+ }),
+ latestTurnRead: { status: 'pending' },
+ }),
+ 'last_turn_completed',
+ );
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, {
+ ...runningTurn,
+ status: 'failed',
+ terminalEventId: 'fail',
+ failureClass: 'provider',
+ }),
+ latestTurnRead: { status: 'pending' },
+ }),
+ 'last_turn_failed',
+ );
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, {
+ ...runningTurn,
+ status: 'cancelled',
+ terminalEventId: 'stop',
+ abortSource: 'user_stop',
+ }),
+ latestTurnRead: { status: 'pending' },
+ }),
+ 'last_turn_interrupted',
+ );
+ });
+
+ it('does not treat an idle session with no turns as completed', () => {
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, null),
+ latestTurnRead: { status: 'ready', turn: null },
+ }),
+ 'idle',
+ );
+ assert.equal(
+ visibleParentTaskStatus('idle'),
+ null,
+ );
+ });
+
+ it('uses the latest TurnRecord when rootTurn is empty', () => {
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, null),
+ latestTurnRead: { status: 'ready', turn: { status: 'completed' } },
+ }),
+ 'last_turn_completed',
+ );
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, null),
+ latestTurnRead: { status: 'ready', turn: { status: 'failed' } },
+ }),
+ 'last_turn_failed',
+ );
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, null),
+ latestTurnRead: { status: 'ready', turn: { status: 'aborted' } },
+ }),
+ 'last_turn_interrupted',
+ );
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, null),
+ latestTurnRead: { status: 'ready', turn: { status: 'running' } },
+ }),
+ 'unavailable',
+ );
+ });
+
+
+ it('reports nothing until the projection or a needed history read answers', () => {
+ // No projection yet: an initial load, not a failure.
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: undefined,
+ latestTurnRead: { status: 'ready', turn: { status: 'completed' } },
+ }),
+ null,
+ );
+ // A history read still in flight is equally not a failure.
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, null),
+ latestTurnRead: { status: 'pending' },
+ }),
+ null,
+ );
+ assert.equal(visibleParentTaskStatus(null), null);
+ });
+
+ it('stays silent while a known execution projection is being re-observed', () => {
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: {
+ ...hostExecutionProjection(false, runningTurn),
+ observationPending: true,
+ },
+ latestTurnRead: { status: 'pending' },
+ }),
+ null,
+ );
+ });
+
+ it('does not keep a previous success when observation is unavailable', () => {
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(false, {
+ ...runningTurn,
+ status: 'completed',
+ terminalEventId: 'done',
+ }),
+ latestTurnRead: { status: 'ready', turn: { status: 'completed' } },
+ }),
+ 'unavailable',
+ );
+ assert.equal(
+ parentTaskStatusFromFacts({
+ execution: hostExecutionProjection(true, null),
+ latestTurnRead: { status: 'failed' },
+ }),
+ 'unavailable',
+ );
+ });
+});
+
+function sideChatPanels(
+ tabs: readonly SessionWorkbarTab[],
+ activeTabId?: string,
+): SessionWorkbarPanelsState {
+ return createSessionWorkbarPanelsState(
+ createSessionWorkbarTabsState(
+ [...tabs],
+ activeTabId ?? tabs[0]?.id ?? null,
+ ),
+ );
+}
+
+const sideChatTab: SessionWorkbarTab = { id: 'side-chat:mine', kind: 'side-chat' };
+const reviewTab: SessionWorkbarTab = { id: 'review', kind: 'review' };
+const visible = { hidden: false, rightCollapsed: false, bottomOpen: false };
+
+/** The projection the Surface and this gate share; nothing else may widen it. */
+function projectedFor(
+ panels: SessionWorkbarPanelsState,
+ quoteIds: readonly string[],
+): SessionWorkbarPanelsState {
+ return projectWorkbarPanelsForSession(
+ panels,
+ 'parent-1',
+ new Set(quoteIds.map((id) => `side-chat:${id}`)),
+ );
+}
+
+describe('visibleSideChatParentSessionId', () => {
+ it('names the owning Session only for a Side Conversation the reader can see', () => {
+ const panels = projectedFor(sideChatPanels([sideChatTab]), ['mine']);
+ assert.equal(visibleSideChatParentSessionId(panels, visible, 'parent-1'), 'parent-1');
+ });
+
+ it('stays silent when the Workbar is hidden or has no owning Session', () => {
+ const panels = projectedFor(sideChatPanels([sideChatTab]), ['mine']);
+ assert.equal(
+ visibleSideChatParentSessionId(panels, { ...visible, hidden: true }, 'parent-1'),
+ undefined,
+ );
+ assert.equal(visibleSideChatParentSessionId(panels, visible, undefined), undefined);
+ });
+
+ it('stays silent for a collapsed placement', () => {
+ const panels = projectedFor(sideChatPanels([sideChatTab]), ['mine']);
+ assert.equal(
+ visibleSideChatParentSessionId(panels, { ...visible, rightCollapsed: true }, 'parent-1'),
+ undefined,
+ );
+ });
+
+ it('stays silent while the launcher is open or another tab is active', () => {
+ const panels = projectedFor(sideChatPanels([sideChatTab, reviewTab]), ['mine']);
+ assert.equal(
+ visibleSideChatParentSessionId(
+ { ...panels, right: openSessionWorkbarLauncher(panels.right) },
+ visible,
+ 'parent-1',
+ ),
+ undefined,
+ );
+ assert.equal(
+ visibleSideChatParentSessionId(
+ { ...panels, right: activateSessionWorkbarTab(panels.right, 'review') },
+ visible,
+ 'parent-1',
+ ),
+ undefined,
+ );
+ assert.equal(
+ visibleSideChatParentSessionId(
+ { ...panels, right: activateSessionWorkbarTab(panels.right, 'side-chat:mine') },
+ visible,
+ 'parent-1',
+ ),
+ 'parent-1',
+ );
+ });
+
+ it('stays silent for another Session’s quote or a tab the projection drops', () => {
+ const otherSession = projectedFor(sideChatPanels([sideChatTab]), ['other']);
+ assert.deepEqual(otherSession.right.tabs, [], 'another Session’s conversation is not open');
+ assert.equal(visibleSideChatParentSessionId(otherSession, visible, 'parent-1'), undefined);
+ const dropped = projectedFor(sideChatPanels([sideChatTab]), []);
+ assert.equal(visibleSideChatParentSessionId(dropped, visible, 'parent-1'), undefined);
+ });
+
+ it('reads the bottom placement when that is the visible one', () => {
+ const panels = createSessionWorkbarPanelsState(
+ createSessionWorkbarTabsState(),
+ createSessionWorkbarTabsState([{ id: 'side-chat:mine', kind: 'side-chat' }]),
+ 'bottom',
+ );
+ const stored = projectedFor(panels, ['mine']);
+ assert.equal(
+ visibleSideChatParentSessionId(stored, { hidden: false, rightCollapsed: true, bottomOpen: true }, 'parent-1'),
+ 'parent-1',
+ );
+ assert.equal(
+ visibleSideChatParentSessionId(stored, { hidden: false, rightCollapsed: true, bottomOpen: false }, 'parent-1'),
+ undefined,
+ );
+ });
+});
diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts
index b64750940f..254e3fc2cb 100644
--- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts
+++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts
@@ -284,7 +284,7 @@ async function renderOwnershipProbe(
},
hostTurn(turnId: string | null, status: 'running' | 'completed' = 'running', available = true) {
assert.ok(executionHandler);
- executionHandler({ type: 'host_execution', available,
+ executionHandler({ type: 'host_execution', pendingInteractionKinds: [], available,
rootTurn: turnId ? { sessionId: executionSessionId, turnId, runId: turnId,
...(status === 'completed' ? { status, terminalEventId: 'terminal' } : { status }) } : null });
},
diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
index ff45ee4842..1c678815d7 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
@@ -1497,10 +1497,22 @@ function connectionHarness(
ptyListeners.add(listener);
return () => ptyListeners.delete(listener);
},
- snapshot: options.subscriptionSnapshot ?? {
- projectionRevision: 1,
- session: { sessionId },
- },
+ snapshot: options.subscriptionSnapshot ?? continuitySnapshot({
+ session: {
+ sessionId,
+ metadataRevision: 1,
+ status: 'active',
+ createdAt: 1,
+ isArchived: false,
+ },
+ rootTurn: null,
+ queue: {
+ hostEpoch: `host-${label}`,
+ queueRevision: 0,
+ steering: [],
+ followup: [],
+ },
+ }),
activeAssistantStreams: options.activeAssistantStreams ?? [],
transcriptBootstrap: {
throughSequence: null,
diff --git a/apps/desktop/src/main/__tests__/runtime-host-observation-preload.test.ts b/apps/desktop/src/main/__tests__/runtime-host-observation-preload.test.ts
index 9c107289f8..37d0575bc0 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-observation-preload.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-observation-preload.test.ts
@@ -52,7 +52,7 @@ test('observation readiness includes its active seed even when the invoke reply
events.emit('sessions:event:session-1', {}, owner, {
type: 'host_observation_seed',
observerIds: [observerId],
- execution: { type: 'host_execution', available: true, rootTurn: { turnId: 'turn-1' } },
+ execution: { type: 'host_execution', pendingInteractionKinds: [], available: true, rootTurn: { turnId: 'turn-1' } },
events: [{
type: 'text_delta', id: 'seed-1', turnId: 'turn-1', messageId: 'message-1',
ts: 1, startOffset: 0, text: 'All output accumulated while away',
@@ -77,20 +77,22 @@ test('execution and observation failures stay separate from Runtime events', asy
await invoked.promise;
try {
events.emit('sessions:event:session-1', {}, owner, {
- type: 'host_execution', available: true,
+ type: 'host_execution', pendingInteractionKinds: [], available: true,
rootTurn: { sessionId: 'session-1', turnId: 'new-turn', runId: 'run-1', status: 'running' },
});
assert.equal(projections.at(-1)?.rootTurn?.sessionId, sessionId);
events.emit('sessions:event:session-1', {}, owner, { type: 'host_observation_pending' });
assert.equal(projections.at(-1)?.available, false);
+ assert.equal(projections.at(-1)?.observationPending, true);
assert.equal(projections.at(-1)?.rootTurn?.turnId, 'new-turn');
events.emit('sessions:event:session-1', {}, owner, { type: 'host_observation_error', message: 'connection lost' });
assert.equal(failures.length, 1);
events.emit('sessions:event:session-1', {}, owner, {
- type: 'host_execution', available: true, rootTurn: null,
+ type: 'host_execution', pendingInteractionKinds: [], available: true, rootTurn: null,
});
assert.equal(projections.at(-1)?.rootTurn, null);
assert.equal(projections.at(-1)?.available, true);
+ assert.equal(projections.at(-1)?.observationPending, undefined);
} finally { unsubscribe(); }
});
@@ -107,7 +109,7 @@ test('one ordered stream handles no-content roots, late acknowledgements, new su
const sessionId = JSON.stringify([owner.hostId, 'session-1']);
const emit = (message: unknown) => events.emit('sessions:event:session-1', {}, owner, message);
const execution = (turnId: string) => ({
- type: 'host_execution', available: true, rootTurn: { sessionId: 'session-1', turnId, status: 'running' },
+ type: 'host_execution', pendingInteractionKinds: [], available: true, rootTurn: { sessionId: 'session-1', turnId, status: 'running' },
});
const subscribe = (order: string[]) => bridge.sessions.subscribeEvents(
sessionId, () => assert.fail('No Runtime events are needed for a running root'),
@@ -169,7 +171,7 @@ test('cancelled Session observation removes preload listeners without publishing
ts: 1, startOffset: 0, text: 'Late output',
});
events.emit('sessions:event:session-1', {}, owner, {
- type: 'host_observation_seed', execution: { type: 'host_execution', available: true, rootTurn: null }, events: [],
+ type: 'host_observation_seed', execution: { type: 'host_execution', pendingInteractionKinds: [], available: true, rootTurn: null }, events: [],
});
assert.deepEqual(callbacks, []);
} finally {
@@ -193,7 +195,7 @@ test('unsubscribing while consuming a seed prevents remaining content and readin
const observerId = await invoked.promise;
events.emit('sessions:event:session-1', {}, owner, {
type: 'host_observation_seed', observerIds: [observerId],
- execution: { type: 'host_execution', available: true, rootTurn: null },
+ execution: { type: 'host_execution', pendingInteractionKinds: [], available: true, rootTurn: null },
events: ['first', 'second'].map((value, index) => ({
type: 'text_delta', id: `seed-${index}`, turnId: 'turn-1', messageId: `message-${index}`,
ts: 1, startOffset: 0, text: value,
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
index 73f9582c4e..781baad768 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
@@ -97,10 +97,78 @@ test('projects root lifecycle without fabricating content events', async (t) =>
assert.equal(started.rootTurn?.turnId, 'turn-1');
assert.equal(started.rootTurn?.status, 'running');
assert.equal(started.available, true);
+ assert.deepEqual(started.pendingInteractionKinds, []);
}
await observer.close();
});
+test("projects pending Interaction kinds without request bodies", async () => {
+ const events = new AsyncFrameQueue();
+ const pending = [
+ pendingKind("permission"),
+ pendingKind("question"),
+ pendingKind("form"),
+ pendingKind("sandbox_boundary"),
+ pendingKind("client_capability"),
+ ] as SessionContinuitySnapshot["interactions"]["pending"];
+ const observer = new RuntimeHostSessionObserver({
+ client: { openSession: async () => runtimeHostSessionFixture({
+ snapshot: continuitySnapshot({
+ rootTurn: {
+ sessionId: "session-1",
+ turnId: "turn-1",
+ runId: "run-1",
+ status: "waiting_for_user",
+ },
+ interactions: { pending },
+ }),
+ activeAssistantStreams: [],
+ transcript: Promise.resolve([]),
+ events,
+ async close() { events.end(); },
+ }) },
+ emitSessionsChanged() {},
+ });
+ const messages: Parameters[1][] = [];
+ try {
+ await observer.observe('session-1', 'parent-status', {
+ id: 101, send(_channel, message) { messages.push(message); }, once() {}, off() {},
+ });
+ const seed = messages[0];
+ assert.equal(seed?.type, 'host_observation_seed');
+ if (seed?.type === 'host_observation_seed') {
+ assert.deepEqual(seed.execution.pendingInteractionKinds, [
+ 'permission', 'question', 'form', 'sandbox_boundary', 'client_capability',
+ ]);
+ assert.equal('prompt' in (pending[0]?.request ?? {}), true);
+ }
+ const seededCount = messages.length;
+ events.push({
+ kind: 'subscription.session_projection', hostEpoch: 'host-1', subscriptionId: 'subscription-1', sequence: 1,
+ snapshot: continuitySnapshot({
+ projectionRevision: 2,
+ rootTurn: {
+ sessionId: "session-1",
+ turnId: "turn-1",
+ runId: "run-1",
+ status: "waiting_for_user",
+ },
+ interactions: { pending: [pendingKind("form")] as SessionContinuitySnapshot["interactions"]["pending"] },
+ }),
+ });
+ await waitFor(() => messages.length > seededCount);
+ const live = messages.at(-1);
+ assert.equal(live?.type, 'host_execution');
+ if (live?.type === 'host_execution') {
+ assert.deepEqual(live.pendingInteractionKinds, ['form']);
+ assert.equal(JSON.stringify(live).includes('"prompt"'), false);
+ assert.equal(JSON.stringify(live).includes('Proceed?'), false);
+ }
+ } finally {
+ await observer.close();
+ }
+});
+
test("joins an active Turn without losing or replaying assistant text", async () => {
const transcript = deferred();
const events = new AsyncFrameQueue();
@@ -3717,6 +3785,69 @@ function deltaFrame(
};
}
+function pendingKind(kind: 'permission' | 'question' | 'form' | 'sandbox_boundary' | 'client_capability') {
+ const base = {
+ schemaVersion: 1 as const,
+ interactionId: `interaction-${kind}`,
+ sessionId: "session-1",
+ turnId: "turn-1",
+ runId: "run-1",
+ revision: 1 as const,
+ status: "pending" as const,
+ outcome: null,
+ };
+ if (kind === 'permission') {
+ return {
+ ...base,
+ request: {
+ kind,
+ toolUseId: `tool-${kind}`,
+ prompt: {
+ kind: 'tool',
+ toolName: 'secret-tool',
+ category: 'write',
+ reason: 'needs approval',
+ rememberForTurnAllowed: false,
+ review: { summary: 'do not leak', risk: 'high' as const },
+ },
+ },
+ };
+ }
+ if (kind === 'question') {
+ return pendingQuestion(base.interactionId, base.turnId, base.runId);
+ }
+ if (kind === 'form') {
+ return {
+ ...base,
+ request: {
+ kind,
+ toolUseId: `tool-${kind}`,
+ message: 'secret form',
+ requester: { name: 'deploy' },
+ fields: [{ kind: 'boolean' as const, name: 'ok', label: 'OK', required: true }],
+ },
+ };
+ }
+ if (kind === 'sandbox_boundary') {
+ return {
+ ...base,
+ request: {
+ kind,
+ expansion: { paths: ['/secret'] },
+ justification: 'secret reason',
+ },
+ };
+ }
+ return {
+ ...base,
+ request: {
+ kind,
+ toolUseId: `tool-${kind}`,
+ target: { kind: 'browser' as const },
+ },
+ };
+}
+
function pendingQuestion(interactionId: string, turnId: string, runId: string) {
return {
schemaVersion: 1 as const,
diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts
index ca1f2aad46..1c2051bb2c 100644
--- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts
+++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts
@@ -33,6 +33,7 @@ import {
createFakeWorkbarServices,
projectWorkbarPanelsForSession,
useWorkbarController,
+ visibleSideChatParentSessionId,
WorkbarServicesProvider,
type UseWorkbarControllerInput,
type WorkbarController,
@@ -1138,6 +1139,36 @@ describe('useWorkbarController', () => {
assert.deepEqual(activeSessions, ['a', 'b']);
});
+ it('gates the parent status read on the projected Side Conversation the render shows', async () => {
+ const { root } = installReactRenderer();
+ const services = createFakeWorkbarServices();
+ await act(async () => renderController(root, services, input(session('a'))));
+ await act(async () => controller().commands.openTool('side-chat'));
+ const host = controller().host;
+ const gate = (visibility: typeof host = host) =>
+ visibleSideChatParentSessionId(
+ projectWorkbarPanelsForSession(
+ host.panelsState,
+ host.activeId,
+ new Set(host.quotes?.map((quote) => `side-chat:${quote.id}`)),
+ ),
+ visibility,
+ host.sourceSession?.id,
+ );
+ assert.equal(gate(), 'a', 'an open, active Side Conversation is worth the read');
+ assert.equal(gate({ ...host, hidden: true }), undefined);
+ assert.equal(gate({ ...host, rightCollapsed: true }), undefined);
+ });
+
+ it('exposes the parent conversation callback from the shell', async () => {
+ const { root } = installReactRenderer();
+ const services = createFakeWorkbarServices();
+ await act(async () => renderController(root, services, input(session('a'))));
+ // The action itself is covered against a real DOM in
+ // parent-interaction-focus.test.ts; here the shell only has to expose it.
+ assert.equal(typeof controller().host.onOpenParentConversation, 'function');
+ });
+
it('opens Browser once when the active Session gains a live browser view', async () => {
const { root } = installReactRenderer();
let publishState:
diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
index 8225b7bab9..ceb2d32d53 100644
--- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
@@ -84,7 +84,7 @@ async function mountController(failFirstRead = false, overrides: Partial interaction.request.kind,
+ ),
+ };
+}
+
function samePendingInteractions(
previous: SessionContinuitySnapshot,
next: SessionContinuitySnapshot,
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 1048a84831..2bafcc6a11 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -2509,7 +2509,9 @@ const makaBridge = {
return;
}
if (event.type === 'host_observation_pending') {
- if (lastExecution) lastExecution = { ...lastExecution, available: false };
+ if (lastExecution) {
+ lastExecution = { ...lastExecution, available: false, observationPending: true };
+ }
onExecution?.(lastExecution);
onObservationSeed?.('pending');
return;
diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts
index aa75602df4..c0d3cb3dd8 100644
--- a/apps/desktop/src/renderer/app-shell-effects.ts
+++ b/apps/desktop/src/renderer/app-shell-effects.ts
@@ -422,7 +422,7 @@ export function useActiveSessionEvents(options: {
() => {
if (attempt !== observationAttempt) return;
controller.observationChanged('pending');
- options.setExecution(activeId, undefined);
+ options.setExecution(activeId, null);
unsubscribeCurrent();
observationFailures += 1;
const retryDelayMs = Math.min(100 * (2 ** (observationFailures - 1)), 2_000);
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index aa3844ba92..508baa44a4 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -469,6 +469,7 @@ function AppShellContent({
streamingSessionIds,
activeLiveTurnSnapshot,
activeExecution,
+ parentExecutionHistoryEpoch,
} = useAppShellSessionUiReads(sessionUiController, activeId);
// The chat surface follows the active Session's Host. Settings and global
// commands remain owned by the default Host.
@@ -1172,7 +1173,7 @@ function AppShellContent({
);
const workbar = useWorkbarController({
workHub: { enabled: workHubEnabled, active: workHubActive },
- available: sessionsSelected && (workHubActive || Boolean(activeHostSession)),
+ available: sessionsSelected,
layoutSessionId: activeId,
activeSession: activeHostSession,
projectId: currentProjectId,
@@ -1186,6 +1187,8 @@ function AppShellContent({
openSessionInChat,
resolveWorkBoardTarget,
prepareWorkBoardDraft,
+ parentExecution: activeExecution,
+ parentExecutionHistoryEpoch,
});
const { commands, selectors, LiveContextUsageProbe } = workbar;
diff --git a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts
index 6456a222aa..8b097e72ec 100644
--- a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts
+++ b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts
@@ -213,6 +213,17 @@ export interface DesktopConversationCopy {
/** Responding to a permission / question prompt failed. */
respondFailed: string;
};
+ parentStatus: {
+ waitingInput: string;
+ waitingApproval: string;
+ waitingInputAndApproval: string;
+ running: string;
+ lastTurnCompleted: string;
+ lastTurnFailed: string;
+ lastTurnInterrupted: string;
+ unavailable: string;
+ openParent: string;
+ };
};
health: {
blocked: Record<
@@ -548,6 +559,17 @@ const COPY = {
settlementFailed: '运行已结束,但消息加载失败。请重试或重新打开侧边对话。',
respondFailed: '响应失败,请稍后重试。',
},
+ parentStatus: {
+ waitingInput: '主任务等待输入',
+ waitingApproval: '主任务等待审批,需在主对话处理',
+ waitingInputAndApproval: '主任务等待输入;审批需在主对话处理',
+ running: '主任务正在运行',
+ lastTurnCompleted: '主任务最近一轮已完成',
+ lastTurnFailed: '主任务最近一轮失败',
+ lastTurnInterrupted: '主任务最近一轮已中断',
+ unavailable: '主任务状态不可用',
+ openParent: '前往主对话',
+ },
},
health: {
blocked: {
@@ -782,6 +804,17 @@ const COPY = {
settlementFailed: '執行已結束,但訊息載入失敗。請重試或重新開啟側邊對話。',
respondFailed: '回應失敗,請稍後重試。',
},
+ parentStatus: {
+ waitingInput: '主任務等待輸入',
+ waitingApproval: '主任務等待核准,需在主對話處理',
+ waitingInputAndApproval: '主任務等待輸入;核准需在主對話處理',
+ running: '主任務正在執行',
+ lastTurnCompleted: '主任務最近一輪已完成',
+ lastTurnFailed: '主任務最近一輪失敗',
+ lastTurnInterrupted: '主任務最近一輪已中斷',
+ unavailable: '主任務狀態無法使用',
+ openParent: '前往主對話',
+ },
},
health: {
blocked: {
@@ -1032,6 +1065,17 @@ const COPY = {
settlementFailed: 'The run ended, but its messages could not be loaded. Retry or reopen the side chat.',
respondFailed: 'The response failed. Please try again.',
},
+ parentStatus: {
+ waitingInput: 'Parent task is waiting for input',
+ waitingApproval: 'Parent task is waiting for approval; approve it in the parent conversation',
+ waitingInputAndApproval: 'Parent task is waiting for input; approval is handled in the parent conversation',
+ running: 'Parent task is running',
+ lastTurnCompleted: 'Latest parent turn completed',
+ lastTurnFailed: 'Latest parent turn failed',
+ lastTurnInterrupted: 'Latest parent turn was interrupted',
+ unavailable: 'Parent task status is unavailable',
+ openParent: 'Open parent conversation',
+ },
},
health: {
blocked: {
diff --git a/apps/desktop/src/renderer/application/contracts/session-execution.ts b/apps/desktop/src/renderer/application/contracts/session-execution.ts
index 98c92e32a4..281fc95f2b 100644
--- a/apps/desktop/src/renderer/application/contracts/session-execution.ts
+++ b/apps/desktop/src/renderer/application/contracts/session-execution.ts
@@ -18,7 +18,54 @@
*/
import type { SessionExecutionProjection } from '../../../shared/session-execution-projection.js';
-export type { SessionExecutionProjection } from '../../../shared/session-execution-projection.js';
+export type {
+ HostPendingInteractionKind,
+ SessionExecutionProjection,
+} from '../../../shared/session-execution-projection.js';
+
+/**
+ * Whether a projection write invalidates a settled-history read.
+ *
+ * Producers call this on every write, where both frames of a batched
+ * transition are still visible. Comparing rendered snapshots instead would
+ * miss an `available → unavailable → available` sequence React batches into
+ * one commit, and the settled turn would never be reread.
+ */
+export function executionInvalidatesHistory(
+ previous: SessionExecutionProjection | undefined,
+ next: SessionExecutionProjection | undefined,
+): boolean {
+ if (!next?.available || next.rootTurn) return false;
+ if (!previous?.available) return true;
+ return previous.rootTurn !== null;
+}
+
+/** A projection plus the producer's invalidation counter for it. */
+export interface ExecutionHistoryState {
+ readonly sessionId: string;
+ readonly projection: SessionExecutionProjection | undefined;
+ readonly historyEpoch: number;
+}
+
+/**
+ * The one place the producer-side epoch advances. The Session identity travels
+ * with the projection so a navigation render can never pair the new id with the
+ * previous Session's execution.
+ */
+export function advanceExecutionHistory(
+ current: ExecutionHistoryState,
+ sessionId: string,
+ projection: SessionExecutionProjection | undefined,
+): ExecutionHistoryState {
+ if (current.sessionId === sessionId && current.projection === projection) return current;
+ const sameSession = current.sessionId === sessionId;
+ return {
+ sessionId,
+ projection,
+ historyEpoch: (sameSession ? current.historyEpoch : 0)
+ + (sameSession && executionInvalidatesHistory(current.projection, projection) ? 1 : 0),
+ };
+}
/** Retain the last nonterminal identity for Stop and conservative controls even when observation is unavailable. */
export function activeHostTurn(projection: SessionExecutionProjection | undefined) {
@@ -27,6 +74,27 @@ export function activeHostTurn(projection: SessionExecutionProjection | undefine
? turn : undefined;
}
+/**
+ * A failed observation is a different fact from an unread projection, but it is
+ * not a reason to forget the turn identity Stop and other conservative controls
+ * rely on: mark the known projection unavailable and keep its root. Before the
+ * first seed there is nothing to keep, so report an empty unavailable one.
+ */
+export function unavailableExecutionProjection(
+ previous: SessionExecutionProjection | undefined,
+): SessionExecutionProjection {
+ if (previous?.available || previous?.observationPending) {
+ const { observationPending: _pending, ...known } = previous;
+ return { ...known, available: false };
+ }
+ return previous ?? {
+ type: 'host_execution',
+ available: false,
+ rootTurn: null,
+ pendingInteractionKinds: [],
+ };
+}
+
/** Presentation fields only; the Host retains ownership of the lifecycle. */
export function chatTurnActivity(projection: SessionExecutionProjection | undefined) {
if (!projection?.available) return undefined;
diff --git a/apps/desktop/src/renderer/features/conversation/model/session-ui-selectors.ts b/apps/desktop/src/renderer/features/conversation/model/session-ui-selectors.ts
index 657ca52f03..eed4f4d591 100644
--- a/apps/desktop/src/renderer/features/conversation/model/session-ui-selectors.ts
+++ b/apps/desktop/src/renderer/features/conversation/model/session-ui-selectors.ts
@@ -34,6 +34,7 @@ const selectStopPending = (state: AppShellSessionUiState) => state.stopPendingBy
const selectInteraction = (state: AppShellSessionUiState) => state.interactionBySession;
const selectMessageQueue = (state: AppShellSessionUiState) => state.messageQueueBySession;
const selectExecution = (state: AppShellSessionUiState, id: string | undefined) => id ? state.executionBySession[id] : undefined;
+const selectExecutionHistoryEpoch = (state: AppShellSessionUiState, id: string | undefined) => id ? state.executionHistoryEpochBySession[id] : undefined;
const selectPulseSet = (state: AppShellSessionUiState) => selectStreamingSessionIds(state.liveTurnBySession, state.executionBySession);
/**
@@ -55,8 +56,13 @@ export const sessionUiSelectors = {
retry: selectMessageRetryPending, stop: selectStopPending, interaction: selectInteraction,
queue: selectMessageQueue, pulse: selectPulseSet, pulseEqual: sessionIdSetsEqual,
active: (state: AppShellSessionUiState, id: string | undefined) => ({
- activeExecution: selectExecution(state, id), activeLiveTurnSnapshot: selectActiveSnapshot(state, id),
+ activeExecution: selectExecution(state, id),
+ /** Carried so a batched invalidation still re-renders its readers. */
+ parentExecutionHistoryEpoch: selectExecutionHistoryEpoch(state, id),
+ activeLiveTurnSnapshot: selectActiveSnapshot(state, id),
}),
- activeEqual: (a: { activeExecution: unknown; activeLiveTurnSnapshot: import('./live-turn-snapshot.js').LiveTurnSnapshot }, b: typeof a) =>
- a.activeExecution === b.activeExecution && liveTurnSnapshotsEqual(a.activeLiveTurnSnapshot, b.activeLiveTurnSnapshot),
+ activeEqual: (a: { activeExecution: unknown; parentExecutionHistoryEpoch: number | undefined; activeLiveTurnSnapshot: import('./live-turn-snapshot.js').LiveTurnSnapshot }, b: typeof a) =>
+ a.activeExecution === b.activeExecution
+ && a.parentExecutionHistoryEpoch === b.parentExecutionHistoryEpoch
+ && liveTurnSnapshotsEqual(a.activeLiveTurnSnapshot, b.activeLiveTurnSnapshot),
};
diff --git a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts
index 5637d665a1..19ebca4e09 100644
--- a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts
+++ b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts
@@ -26,7 +26,11 @@ import {
type LiveTurnBuffer,
} from '@maka/ui';
import { createObservableState } from './observable-state.js';
-import type { SessionExecutionProjection } from '../../../../shared/session-execution-projection.js';
+import {
+ advanceExecutionHistory,
+ unavailableExecutionProjection,
+ type SessionExecutionProjection,
+} from '../../../application/contracts/session-execution.js';
type StateUpdater = (updater: (current: T) => T) => void;
type ShellRunUpdatesBySession = Record>;
@@ -37,6 +41,13 @@ export interface AppShellSessionUiState {
stopPendingBySession: Record;
liveTurnBySession: Record;
executionBySession: Record;
+ /**
+ * Bumped by the projection producer whenever a write invalidates a settled
+ * history read. A consumer cannot recover that from the rendered projection
+ * alone: React can batch `available → unavailable → available` into one
+ * commit whose snapshot equals the one before it.
+ */
+ executionHistoryEpochBySession: Record;
shellRunUpdatesBySession: ShellRunUpdatesBySession;
interactionBySession: InteractionQueues;
messageQueueBySession: Record;
@@ -74,6 +85,7 @@ const SESSION_UI_MAP_KEYS = [
'stopPendingBySession',
'liveTurnBySession',
'executionBySession',
+ 'executionHistoryEpochBySession',
'shellRunUpdatesBySession',
'interactionBySession',
'messageQueueBySession',
@@ -200,15 +212,26 @@ export function createAppShellSessionUiStateController(
messageRetryPending: createPendingClaim('messageRetryPendingBySession'),
stopPending: createPendingClaim('stopPendingBySession'),
setLiveTurnBySession: createMapSetter('liveTurnBySession'),
- setExecution: (sessionId: string, projection: SessionExecutionProjection | undefined) => {
- updateMap('executionBySession', (current) => {
- const previous = current[sessionId];
- if (!projection) return previous?.available
- ? { ...current, [sessionId]: { ...previous, available: false } } : current;
- // The observation channel re-publishes the projection on every frame —
- // catalog metadata writes included — with a fresh object each time.
- if (previous !== undefined && valuesEqual(previous, projection)) return current;
- return { ...current, [sessionId]: projection };
+ setExecution: (sessionId: string, projection: SessionExecutionProjection | null | undefined) => {
+ const latest = state.getState();
+ const previous = latest.executionBySession[sessionId];
+ // null is an observed failure; undefined is ordinary subscription cleanup.
+ const next = projection === null ? unavailableExecutionProjection(previous)
+ : projection ?? (previous ? { ...previous, available: false, observationPending: true } : undefined);
+ if (next === previous || (previous !== undefined && valuesEqual(previous, next))) return;
+ const advanced = advanceExecutionHistory(
+ { sessionId, projection: previous, historyEpoch: latest.executionHistoryEpochBySession[sessionId] ?? 0 },
+ sessionId,
+ next,
+ );
+ replaceState({
+ ...latest,
+ executionBySession: next === undefined
+ ? omitSessionKey(latest.executionBySession, sessionId)
+ : { ...latest.executionBySession, [sessionId]: next },
+ executionHistoryEpochBySession: advanced.historyEpoch === (latest.executionHistoryEpochBySession[sessionId] ?? 0)
+ ? latest.executionHistoryEpochBySession
+ : { ...latest.executionHistoryEpochBySession, [sessionId]: advanced.historyEpoch },
});
},
setShellRunUpdatesBySession: createMapSetter('shellRunUpdatesBySession'),
diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-parent-task-status.ts b/apps/desktop/src/renderer/features/workbar/controller/use-parent-task-status.ts
new file mode 100644
index 0000000000..6b34fc2f64
--- /dev/null
+++ b/apps/desktop/src/renderer/features/workbar/controller/use-parent-task-status.ts
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useEffect, useState } from 'react';
+import { useMountedRef } from '@maka/ui';
+import type { TurnRecord } from '@maka/core/session';
+import type { SessionExecutionProjection } from '../../../application/contracts/session-execution.js';
+import { useWorkbarServices } from '../services-context.js';
+import {
+ parentTaskStatusFromFacts,
+ visibleParentTaskStatus,
+ type ParentTaskLatestTurnRead,
+ type VisibleParentTaskStatus,
+} from '../model/parent-task-status.js';
+
+/** First retry delay; doubles per attempt and never exceeds the cap. */
+const HISTORY_READ_BACKOFF_MS = 100;
+const HISTORY_READ_MAX_DELAY_MS = 2_000;
+/** Bounded auto recovery: the initial read plus two retries. */
+const HISTORY_READ_MAX_ATTEMPTS = 3;
+
+type HistoryRead = {
+ readonly sessionId: string;
+ readonly epoch: number;
+ readonly read: ParentTaskLatestTurnRead;
+};
+
+export interface ParentTaskStatusInput {
+ /** The Session whose Side Conversations are reading their parent. */
+ readonly sessionId: string | undefined;
+ /** The owning conversation's canonical projection; `undefined` until it arrives. */
+ readonly execution: SessionExecutionProjection | undefined;
+ /** Producer-side counter that changes whenever the projection invalidates history. */
+ readonly historyEpoch: number | undefined;
+}
+
+const pendingRead: ParentTaskLatestTurnRead = { status: 'pending' };
+
+/**
+ * Parent-task facts for the Side Conversations of one Session.
+ *
+ * The execution projection comes from the conversation that owns the Session;
+ * this hook only adds the one fact the projection cannot carry cheaply — the
+ * latest settled turn — and only while the projection is available without a
+ * live root. A failed read retries on a bounded backoff and then reports real
+ * unavailability, but an unseeded or still-loading read reports nothing.
+ */
+export function useParentTaskStatus(
+ input: ParentTaskStatusInput,
+): VisibleParentTaskStatus | null {
+ const { sideChat } = useWorkbarServices();
+ const mountedRef = useMountedRef();
+ const sessionId = input.sessionId;
+ const epoch = input.historyEpoch ?? 0;
+ const execution = input.execution;
+ const needsHistoryRead = Boolean(sessionId && execution?.available && !execution.rootTurn);
+ const [historyRead, setHistoryRead] = useState(null);
+
+ useEffect(() => {
+ if (!sessionId || !needsHistoryRead) return;
+ let cancelled = false;
+ let attempt = 0;
+ let retryTimer: ReturnType | undefined;
+ const publish = (read: ParentTaskLatestTurnRead) => {
+ setHistoryRead({ sessionId, epoch, read });
+ };
+ const readLatestTurn = () => {
+ attempt += 1;
+ void sideChat.listTurns(sessionId).then(
+ (turns: TurnRecord[]) => {
+ if (cancelled || !mountedRef.current) return;
+ publish({ status: 'ready', turn: turns.at(-1) ?? null });
+ },
+ () => {
+ if (cancelled || !mountedRef.current) return;
+ if (attempt >= HISTORY_READ_MAX_ATTEMPTS) {
+ publish({ status: 'failed' });
+ return;
+ }
+ retryTimer = globalThis.setTimeout(
+ readLatestTurn,
+ Math.min(HISTORY_READ_BACKOFF_MS * (2 ** (attempt - 1)), HISTORY_READ_MAX_DELAY_MS),
+ );
+ },
+ );
+ };
+ readLatestTurn();
+ return () => {
+ cancelled = true;
+ if (retryTimer !== undefined) globalThis.clearTimeout(retryTimer);
+ };
+ // Navigating away, a new history epoch, or a live root turn all cancel the
+ // read: the fence below drops any answer that still arrives.
+ }, [epoch, mountedRef, needsHistoryRead, sessionId, sideChat]);
+
+ if (!sessionId) return null;
+ const current = historyRead
+ && historyRead.sessionId === sessionId
+ && historyRead.epoch === epoch
+ ? historyRead.read
+ : pendingRead;
+ return visibleParentTaskStatus(
+ parentTaskStatusFromFacts({
+ execution,
+ latestTurnRead: execution?.rootTurn ? { status: 'ready', turn: null } : current,
+ }),
+ );
+}
diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts
index baa6808265..39c2e9a0ce 100644
--- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts
+++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts
@@ -41,6 +41,10 @@ import { sideChatTitleFromPrompt } from '../../../side-chat-command.js';
import { desktopSessionKey, parseDesktopSessionKey } from '../../../../shared/runtime-host-identity.js';
import { useWorkHubWorkspace } from '../../../application/contracts/workhub-workspace/use-workhub-workspace.js';
import { useWorkbarServices } from '../services-context.js';
+import { focusParentConversation } from '../model/parent-interaction-focus.js';
+import type { SessionExecutionProjection } from '../../../../shared/session-execution-projection.js';
+
+export { focusParentConversation };
import type { WorkbarHostModel } from '../ui/workbar-host.js';
import { SKIP_SIDE_CHAT_CLOSE_CONFIRMATION_KEY } from '../ui/side-chat-close-confirmation.js';
import {
@@ -116,6 +120,10 @@ export interface UseWorkbarControllerInput {
/** Toast surface owned by the shell composition zone. */
toastApi: ToastApi;
composerRef?: { current: Pick | null };
+ /** The owning conversation's canonical Host execution projection. */
+ parentExecution?: SessionExecutionProjection;
+ /** Producer-side history invalidation counter for `parentExecution`. */
+ parentExecutionHistoryEpoch?: number;
openNewTaskSurface?(): number;
openSessionInChat?(sessionId: string, turnId?: string): void;
resolveWorkBoardTarget?(item: WorkBoardItem):
@@ -171,7 +179,7 @@ export function useWorkbarController(
projectId: null,
projectAliases: [],
authoritativeSessionIds: coordination.authoritativeSessionIds,
- } : { ...requested, authoritativeSessionIds: coordination.authoritativeSessionIds };
+ } : { ...requested, available: requested.available && Boolean(activeSessionId), authoritativeSessionIds: coordination.authoritativeSessionIds };
const locale = useUiLocale();
// Enforce development-only: the experimental Start-task path must never be
// reachable in a production build even if the flag is set, so the gate
@@ -984,6 +992,9 @@ export function useWorkbarController(
sourceSession: input.activeSession,
onOpenConversation: input.openSessionInChat,
modelChoices: input.modelChoices,
+ onOpenParentConversation: focusParentConversation,
+ parentExecution: input.parentExecution,
+ parentExecutionHistoryEpoch: input.parentExecutionHistoryEpoch,
onStartWorkBoardTask: startWorkBoardTask,
resolveWorkBoardStartTask: input.resolveWorkBoardTarget,
onOpenWorkBoardSession: openWorkBoardSession,
diff --git a/apps/desktop/src/renderer/features/workbar/model/parent-interaction-focus.ts b/apps/desktop/src/renderer/features/workbar/model/parent-interaction-focus.ts
new file mode 100644
index 0000000000..8fbb0d1cb7
--- /dev/null
+++ b/apps/desktop/src/renderer/features/workbar/model/parent-interaction-focus.ts
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * The one attribute that names a Session workspace's interaction container.
+ *
+ * AppShell owns a `.maka-detail-with-artifacts` frame. A global lookup
+ * can land in a workspace the user is not looking at. The semantic
+ * attribute lets the parent-focus action resolve the workspace that
+ * owns the Side Conversation it was invoked from.
+ */
+export const SESSION_INTERACTION_CONTAINER_ATTRIBUTE = 'data-maka-interaction-container';
+export const SESSION_INTERACTION_CONTAINER_SELECTOR =
+ `.maka-detail-with-artifacts, [${SESSION_INTERACTION_CONTAINER_ATTRIBUTE}]`;
+
+/**
+ * The parent conversation region inside that frame. The Workbar column is a
+ * sibling of this region, never a descendant, which is what keeps the Side
+ * Conversation's own composer out of reach.
+ */
+export const SESSION_PARENT_INTERACTION_ATTRIBUTE = 'data-maka-parent-interaction';
+export const SESSION_PARENT_INTERACTION_SELECTOR =
+ `:scope > .mainColumn, [${SESSION_PARENT_INTERACTION_ATTRIBUTE}]`;
+
+// Question and form controls first, the parent composer second: both sit in the
+// parent region, and only the first tier can answer a pending interaction.
+const PARENT_INTERACTION_FOCUSABLE =
+ '.maka-composer-interaction button, .maka-composer-interaction [href], .maka-composer-interaction input, .maka-composer-interaction textarea, .maka-composer-interaction [contenteditable="true"]';
+const PARENT_COMPOSER_FOCUSABLE = '.maka-composer [contenteditable="true"]';
+
+const HIDDEN_ANCESTOR = '[hidden], [aria-hidden="true"], [inert]';
+const DISABLEABLE_CONTROL = 'button, input, select, textarea, optgroup, option';
+
+/**
+ * A disabled fieldset disables its controls, except those inside its first
+ * ``. `:disabled` states the same rule, but this module also runs where
+ * no selector engine implements it.
+ */
+function isDisabledControl(node: Element): boolean {
+ if (!node.matches(DISABLEABLE_CONTROL)) return false;
+ if (node.hasAttribute('disabled')) return true;
+ for (let ancestor = node.parentElement; ancestor; ancestor = ancestor.parentElement) {
+ if (ancestor.localName !== 'fieldset' || !ancestor.hasAttribute('disabled')) continue;
+ const legend = [...ancestor.children].find((child) => child.localName === 'legend');
+ if (legend?.contains(node)) continue;
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Rendered means laid out, painted, and not behind a marker that takes it out of
+ * the accessibility tree or the tab order.
+ */
+function isRendered(node: Element | null): node is HTMLElement {
+ if (!node) return false;
+ if (node.closest(HIDDEN_ANCESTOR)) return false;
+ const view = node.ownerDocument?.defaultView;
+ if (view?.getComputedStyle) {
+ // `visibility` resolves through inheritance, so the node's own computed
+ // value already accounts for its ancestors; `display: none` on an ancestor
+ // never shows up in the node's own value.
+ const { visibility } = view.getComputedStyle(node);
+ if (visibility === 'hidden' || visibility === 'collapse') return false;
+ for (let ancestor: Element | null = node; ancestor; ancestor = ancestor.parentElement) {
+ if (view.getComputedStyle(ancestor).display === 'none') return false;
+ }
+ }
+ return node.getClientRects().length > 0;
+}
+
+function isUsableFocusTarget(node: Element): node is HTMLElement {
+ if (typeof (node as HTMLElement).focus !== 'function') return false;
+ if (isDisabledControl(node)) return false;
+ return isRendered(node);
+}
+
+function firstUsableFocusTarget(root: ParentNode | null, selector: string): HTMLElement | null {
+ if (!root) return null;
+ for (const node of root.querySelectorAll(selector)) {
+ if (isUsableFocusTarget(node)) return node;
+ }
+ return null;
+}
+
+/**
+ * Focus the parent task's pending question or form control, then its composer.
+ *
+ * `origin` is the element the user acted on; its own workspace wins, so a
+ * hidden or adjacent workspace can never receive the focus. An origin outside a
+ * marked workspace resolves to nothing rather than to some other workspace.
+ */
+export function focusParentConversation(origin?: Element | null): void {
+ const container = origin?.closest(SESSION_INTERACTION_CONTAINER_SELECTOR) ?? null;
+ if (!isRendered(container)) return;
+ const parentRegion = container.querySelector(SESSION_PARENT_INTERACTION_SELECTOR);
+ if (!isRendered(parentRegion)) return;
+ const target =
+ firstUsableFocusTarget(parentRegion, PARENT_INTERACTION_FOCUSABLE) ??
+ firstUsableFocusTarget(parentRegion, PARENT_COMPOSER_FOCUSABLE);
+ target?.focus();
+}
diff --git a/apps/desktop/src/renderer/features/workbar/model/parent-task-status.ts b/apps/desktop/src/renderer/features/workbar/model/parent-task-status.ts
new file mode 100644
index 0000000000..19e4c6f1c7
--- /dev/null
+++ b/apps/desktop/src/renderer/features/workbar/model/parent-task-status.ts
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { TurnStatus } from '@maka/core/session';
+import type {
+ HostPendingInteractionKind,
+ SessionExecutionProjection,
+} from '../../../application/contracts/session-execution.js';
+import type { SessionWorkbarPanelsState } from './workbar-tabs.js';
+
+/**
+ * The only Session whose parent status is worth a Host read: the Side
+ * Conversation the reader can actually see. It mirrors the Surface's own
+ * projection, active tab and collapsed state, so a hidden Workbar, a collapsed
+ * placement, an open launcher, an inactive tab, a Side Conversation of another
+ * Session, or a tab the projection dropped never triggers a read.
+ */
+export function visibleSideChatParentSessionId(
+ visiblePanels: SessionWorkbarPanelsState,
+ visibility: { hidden: boolean; rightCollapsed: boolean; bottomOpen: boolean },
+ sourceSessionId: string | undefined,
+): string | undefined {
+ if (visibility.hidden || !sourceSessionId) return undefined;
+ const showing = (['right', 'bottom'] as const).some((placement) => {
+ const panel = visiblePanels[placement];
+ const panelVisible =
+ placement === 'right' ? !visibility.rightCollapsed : visibility.bottomOpen;
+ if (!panelVisible || panel.launcherOpen) return false;
+ return panel.tabs.find((tab) => tab.id === panel.activeTabId)?.kind === 'side-chat';
+ });
+ return showing ? sourceSessionId : undefined;
+}
+
+export type {
+ HostPendingInteractionKind,
+ SessionExecutionProjection,
+} from '../../../application/contracts/session-execution.js';
+
+export type ParentTaskStatusKind =
+ | 'unavailable'
+ | 'waiting_input'
+ | 'waiting_approval'
+ | 'waiting_input_and_approval'
+ | 'running'
+ | 'last_turn_completed'
+ | 'last_turn_failed'
+ | 'last_turn_interrupted'
+ | 'idle';
+
+export type ParentTaskLatestTurnRead =
+ | { readonly status: 'pending' }
+ | { readonly status: 'failed' }
+ | { readonly status: 'ready'; readonly turn: { readonly status: TurnStatus } | null };
+
+export function classifyPendingInteractionKind(
+ kind: HostPendingInteractionKind,
+): 'input' | 'approval' {
+ switch (kind) {
+ case 'question':
+ case 'form':
+ return 'input';
+ case 'permission':
+ case 'sandbox_boundary':
+ case 'client_capability':
+ return 'approval';
+ default: {
+ const exhaustive: never = kind;
+ return exhaustive;
+ }
+ }
+}
+
+export function hostExecutionProjection(
+ available: boolean,
+ rootTurn: SessionExecutionProjection['rootTurn'],
+ pendingInteractionKinds: readonly HostPendingInteractionKind[] = [],
+): SessionExecutionProjection {
+ return {
+ type: 'host_execution',
+ available,
+ rootTurn,
+ pendingInteractionKinds,
+ };
+}
+
+/**
+ * `null` means there is nothing honest to say yet: no projection has arrived,
+ * or the projection needs a history read that has not answered. A projection
+ * that reports itself unavailable is the only "unavailable" case, so an
+ * initial load can never flash a warning.
+ */
+export function parentTaskStatusFromFacts(input: {
+ readonly execution: SessionExecutionProjection | undefined;
+ readonly latestTurnRead: ParentTaskLatestTurnRead;
+}): ParentTaskStatusKind | null {
+ const execution = input.execution;
+ if (!execution || execution.observationPending) return null;
+ if (!execution.available) return 'unavailable';
+
+ let hasInput = false;
+ let hasApproval = false;
+ for (const kind of execution.pendingInteractionKinds) {
+ if (classifyPendingInteractionKind(kind) === 'input') hasInput = true;
+ else hasApproval = true;
+ }
+ if (hasInput && hasApproval) return 'waiting_input_and_approval';
+ if (hasInput) return 'waiting_input';
+ if (hasApproval) return 'waiting_approval';
+
+ const root = execution.rootTurn;
+ if (root) {
+ if (root.status === 'completed') return 'last_turn_completed';
+ if (root.status === 'failed') return 'last_turn_failed';
+ if (root.status === 'cancelled') return 'last_turn_interrupted';
+ return 'running';
+ }
+
+ const latest = input.latestTurnRead;
+ if (latest.status === 'pending') return null;
+ if (latest.status === 'failed') return 'unavailable';
+ if (!latest.turn) return 'idle';
+ if (latest.turn.status === 'completed') return 'last_turn_completed';
+ if (latest.turn.status === 'failed') return 'last_turn_failed';
+ if (latest.turn.status === 'aborted') return 'last_turn_interrupted';
+ if (latest.turn.status === 'running') return 'unavailable';
+ const exhaustive: never = latest.turn.status;
+ return exhaustive;
+}
+
+export type VisibleParentTaskStatus = Exclude;
+
+export function visibleParentTaskStatus(
+ kind: ParentTaskStatusKind | null,
+): VisibleParentTaskStatus | null {
+ return kind === 'idle' ? null : kind;
+}
diff --git a/apps/desktop/src/renderer/features/workbar/stories.ts b/apps/desktop/src/renderer/features/workbar/stories.ts
index 3945830ccc..d7cf63c80f 100644
--- a/apps/desktop/src/renderer/features/workbar/stories.ts
+++ b/apps/desktop/src/renderer/features/workbar/stories.ts
@@ -31,5 +31,7 @@
*/
export { WorkbarSurface } from './ui/workbar-surface.js';
+
+export { SESSION_INTERACTION_CONTAINER_ATTRIBUTE, SESSION_PARENT_INTERACTION_ATTRIBUTE } from './model/parent-interaction-focus.js';
export type { WorkbarHostModel } from './ui/workbar-host.js';
export { useWorkbarLayoutState } from './controller/use-workbar-layout-state.js';
diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts
index 114fd9513b..59060360f9 100644
--- a/apps/desktop/src/renderer/features/workbar/testing.ts
+++ b/apps/desktop/src/renderer/features/workbar/testing.ts
@@ -57,6 +57,9 @@ export * from './tools/terminal/session-terminal-query.js';
export * from './tools/terminal/session-terminal-frame.js';
export * from '../../application/contracts/session-inspector/use-session-trace.js';
export * from './controller/use-workbar-controller.js';
+export * from './model/parent-task-status.js';
+export { useParentTaskStatus } from './controller/use-parent-task-status.js';
+export { ParentTaskStatusNotice } from './tools/side-chat/parent-task-status-notice.js';
export { SideChatCloseConfirmation } from './ui/side-chat-close-confirmation.js';
const noopSubscription = (): (() => void) => () => undefined;
@@ -170,7 +173,13 @@ export function createFakeWorkbarServices(
respondToClientCapability: async () => undefined,
respondToUserQuestion: async () => undefined,
respondToUserForm: async () => undefined,
- subscribeEvents: (_sessionId, _handler, onSeeded) => {
+ subscribeEvents: (_sessionId, _handler, onSeeded, _onSeedError, onExecution) => {
+ onExecution?.({
+ type: 'host_execution',
+ available: true,
+ rootTurn: null,
+ pendingInteractionKinds: [],
+ });
onSeeded?.();
return noopSubscription();
},
@@ -179,3 +188,5 @@ export function createFakeWorkbarServices(
...overrides,
};
}
+
+export { focusParentConversation } from './model/parent-interaction-focus.js';
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/parent-task-status-notice.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/parent-task-status-notice.tsx
new file mode 100644
index 0000000000..bf91918921
--- /dev/null
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/parent-task-status-notice.tsx
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Banner } from '@astryxdesign/core/Banner';
+import { Button } from '@astryxdesign/core/Button';
+import { useUiLocale } from '@maka/ui';
+import { getDesktopConversationCopy } from '../../../../application/contracts/conversation-copy.js';
+import type { VisibleParentTaskStatus } from '../../model/parent-task-status.js';
+
+const BANNER_STATUS = {
+ waiting_input: 'warning',
+ waiting_approval: 'warning',
+ waiting_input_and_approval: 'warning',
+ running: 'info',
+ last_turn_completed: 'success',
+ last_turn_failed: 'error',
+ last_turn_interrupted: 'warning',
+ unavailable: 'warning',
+} as const;
+
+export function ParentTaskStatusNotice(props: {
+ status: VisibleParentTaskStatus;
+ onOpenParentConversation?: (origin?: Element | null) => void;
+}) {
+ const locale = useUiLocale();
+ const copy = getDesktopConversationCopy(locale).quoteCompanion.parentStatus;
+ const title = {
+ waiting_input: copy.waitingInput,
+ waiting_approval: copy.waitingApproval,
+ waiting_input_and_approval: copy.waitingInputAndApproval,
+ running: copy.running,
+ last_turn_completed: copy.lastTurnCompleted,
+ last_turn_failed: copy.lastTurnFailed,
+ last_turn_interrupted: copy.lastTurnInterrupted,
+ unavailable: copy.unavailable,
+ }[props.status];
+ return (
+
+ props.onOpenParentConversation?.(event.currentTarget)}
+ />
+ ) : undefined
+ }
+ />
+
+ );
+}
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
index e6fcfbeb53..cf38f743ff 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
@@ -35,6 +35,8 @@ import {
import type { SessionSummary } from '@maka/core/session';
import { generalizedErrorMessageForLocale } from '@maka/core/redaction';
import { useQuoteCompanion } from './use-quote-companion';
+import { ParentTaskStatusNotice } from './parent-task-status-notice';
+import type { VisibleParentTaskStatus } from '../../model/parent-task-status';
import { useComposerAttachments } from '@maka/ui/use-composer-attachments';
import { localizedShellErrorMessage } from '../../../../locales/shell-copy.js';
import { useComposerMentionsContext } from '../../../../composer-mentions.js';
@@ -83,6 +85,8 @@ export function QuoteCompanionPanel(props: {
onInitialPromptStarted?: (panelId: string) => void;
onPromptAccepted?: (panelId: string, prompt: string) => void;
onActivityStateChange?: (panelId: string, active: boolean) => void;
+ parentTaskStatus?: VisibleParentTaskStatus | null;
+ onOpenParentConversation?: (origin?: Element | null) => void;
}) {
const { attachments } = useWorkbarServices();
const mentions = useComposerMentionsContext();
@@ -235,6 +239,12 @@ export function QuoteCompanionPanel(props: {
const deriveTurnPresentation = useAppShellTurnPresentation({ uiLocale: locale, allowBranch: false });
return (
+ {props.parentTaskStatus ? (
+
+ ) : null}
@@ -121,6 +122,11 @@ export interface WorkbarHostModel {
sourceSession?: SessionSummary;
onOpenConversation?(sessionId: string, turnId?: string): void;
modelChoices?: readonly ChatModelChoice[];
+ /** The owning conversation's canonical execution projection, for the parent
+ * status of Side Conversations. */
+ parentExecution?: SessionExecutionProjection;
+ parentExecutionHistoryEpoch?: number;
+ onOpenParentConversation?: (origin?: Element | null) => void;
onStartWorkBoardTask?: (item: WorkBoardItem) => void;
resolveWorkBoardStartTask?: (item: WorkBoardItem) => { ok: boolean; message?: string };
onOpenWorkBoardSession?: (link: WorkBoardLinkedSession) => void;
@@ -245,6 +251,9 @@ export function WorkbarHost({ model: props, togglePosition = 'edge' }: { model:
onOpenWorkBoardSession={props.onOpenWorkBoardSession}
workBoardStartTaskEnabled={props.workBoardStartTaskEnabled}
confirmBypass={() => confirmBypassPermission(toast, locale)}
+ parentExecution={props.parentExecution}
+ parentExecutionHistoryEpoch={props.parentExecutionHistoryEpoch}
+ onOpenParentConversation={props.onOpenParentConversation}
/>
diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
index 80782d998b..1233c037ae 100644
--- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
+++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
@@ -44,8 +44,10 @@ import { Section } from '@astryxdesign/core/Section';
import { Spinner } from '@astryxdesign/core/Spinner';
import { Tab, TabList } from '@astryxdesign/core/TabList';
import type { SessionSummary } from '@maka/core/session';
+import type { SessionExecutionProjection } from '../../../../shared/session-execution-projection.js';
import type { WorkBoardItem, WorkBoardLinkedSession } from '@maka/core/work-board';
import { QuoteCompanionPanel } from '../tools/side-chat/quote-companion-panel';
+import { useParentTaskStatus } from '../controller/use-parent-task-status';
import {
type SessionWorkbarTab,
type SessionWorkbarTabKind,
@@ -54,7 +56,8 @@ import {
terminalRefFromWorkbarTab,
reduceWorkbarPanels,
projectWorkbarPanelsForSession,
-} from '../model/workbar-tabs';
+} from '../model/workbar-tabs.js';
+import { visibleSideChatParentSessionId } from '../model/parent-task-status.js';
import {
workbarToolsForWorkspace,
workbarToolDefinition,
@@ -415,11 +418,16 @@ export function WorkbarSurface(props: {
activeSideChatPanelIds?: ReadonlySet;
sourceSession?: SessionSummary;
modelChoices?: readonly ChatModelChoice[];
+ /** The owning conversation's canonical execution projection and its
+ * producer-side invalidation counter. */
+ parentExecution?: SessionExecutionProjection;
+ parentExecutionHistoryEpoch?: number;
onStartWorkBoardTask?: (item: WorkBoardItem) => void;
resolveWorkBoardStartTask?: (item: WorkBoardItem) => { ok: boolean; message?: string };
onOpenWorkBoardSession?: (link: WorkBoardLinkedSession) => void;
workBoardStartTaskEnabled?: boolean;
confirmBypass: () => Promise;
+ onOpenParentConversation?: (origin?: Element | null) => void;
}) {
const { inspector } = useWorkbarServices();
const locale = useUiLocale();
@@ -459,6 +467,16 @@ export function WorkbarSurface(props: {
const positionedTabs = placements.flatMap((placement) =>
allowedPanels[placement].tabs.map((tab) => ({ placement, tab })),
);
+ // Only a Side Conversation the reader can actually see and use is worth a
+ // Host read: the same projection, active tab and collapsed state the render
+ // below uses decide it.
+ const parentSessionId = props.workspace === 'workhub' ? undefined
+ : visibleSideChatParentSessionId(visiblePanels, props, props.sessionId);
+ const parentTaskStatus = useParentTaskStatus({
+ sessionId: parentSessionId,
+ execution: props.parentExecution,
+ historyEpoch: props.parentExecutionHistoryEpoch,
+ });
return (
@@ -619,6 +637,8 @@ export function WorkbarSurface(props: {
onInitialPromptStarted={props.onInitialPromptStarted}
onPromptAccepted={props.onPromptAccepted}
onActivityStateChange={props.onActivityStateChange}
+ parentTaskStatus={parentTaskStatus}
+ onOpenParentConversation={props.onOpenParentConversation}
/>
);
}
diff --git a/apps/desktop/src/renderer/styles/workbar/side-chat.css b/apps/desktop/src/renderer/styles/workbar/side-chat.css
index 8d73e8754a..8ce8302ce6 100644
--- a/apps/desktop/src/renderer/styles/workbar/side-chat.css
+++ b/apps/desktop/src/renderer/styles/workbar/side-chat.css
@@ -103,3 +103,9 @@
max-width: 200px;
padding-inline: var(--space-1-5);
}
+
+.maka-quote-companion-parent-status {
+ flex: 0 0 auto;
+ padding: var(--space-2);
+ padding-bottom: 0;
+}
diff --git a/apps/desktop/src/shared/session-execution-projection.d.ts b/apps/desktop/src/shared/session-execution-projection.d.ts
index 29629853c5..05979283cf 100644
--- a/apps/desktop/src/shared/session-execution-projection.d.ts
+++ b/apps/desktop/src/shared/session-execution-projection.d.ts
@@ -17,14 +17,22 @@
* under the License.
*/
+import type { InteractionRequest } from '@maka/core/interaction';
import type { TurnSnapshot } from '@maka/runtime-host/protocol';
import type { SessionEvent } from '@maka/core/events';
+/** Canonical pending Interaction kinds only. Never request bodies or secrets. */
+export type HostPendingInteractionKind = InteractionRequest['kind'];
+
/** Desktop observation data, never a Runtime event or a client execution claim. */
export interface SessionExecutionProjection {
readonly type: 'host_execution';
readonly available: boolean;
+ /** Renderer cleanup retains root identity while awaiting a new observation. */
+ readonly observationPending?: boolean;
readonly rootTurn: TurnSnapshot | null;
+ /** Type-only pending Interaction summary. Empty when none are pending. */
+ readonly pendingInteractionKinds: readonly HostPendingInteractionKind[];
}
export type SessionObservationMessage = SessionExecutionProjection | {
diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx
index 6cf97f8e4c..aab7f7bf93 100644
--- a/apps/desktop/stories/session-workbar.stories.tsx
+++ b/apps/desktop/stories/session-workbar.stories.tsx
@@ -27,9 +27,14 @@ import type { GitReviewReadResult, GitReviewSnapshot } from '@maka/core/git-revi
import type { SessionSummary, StoredMessage } from '@maka/core/session';
import type { SessionTrace } from '@maka/core/session-trace';
import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol';
-import { ChatSurfaceLayout, Composer, ToastProvider } from '@maka/ui';
+import type { SandboxBoundaryRequestEvent } from '@maka/core/events';
+import { ChatSurfaceLayout, Composer, SandboxBoundaryPrompt, ToastProvider } from '@maka/ui';
import { WorkbarHost, WorkbarServicesProvider } from '../src/renderer/features/workbar';
import { WorkbarSurface, useWorkbarLayoutState, type WorkbarHostModel } from '../src/renderer/features/workbar/stories';
+import {
+ SESSION_INTERACTION_CONTAINER_ATTRIBUTE,
+ SESSION_PARENT_INTERACTION_ATTRIBUTE,
+} from '../src/renderer/features/workbar/stories';
import {
createFakeWorkbarServices,
createSessionWorkbarPanelsState,
@@ -38,9 +43,11 @@ import {
createSessionWorkbarTabsState,
openStaticSessionWorkbarTab,
terminalSessionWorkbarTabId,
+ focusParentConversation,
type QuoteCompanionPanelState,
type SessionWorkbarTab,
type SessionWorkbarTabKind,
+ type SessionExecutionProjection,
type SessionUsageSummary,
type WorkbarServices,
} from '../src/renderer/features/workbar/testing';
@@ -122,6 +129,26 @@ const SIDE_CHAT_SESSION: SessionSummary = {
const TERMINAL_REF = 'shell-run:storybook-terminal';
const SIDE_CHAT_PANEL_ID = 'storybook-side-chat';
+const PARENT_SANDBOX_BOUNDARY_REQUEST = {
+ id: 'parent-boundary-event',
+ turnId: 'parent-turn',
+ ts: NOW,
+ type: 'sandbox_boundary_request',
+ requestId: 'parent-boundary-request',
+ toolUseId: 'parent-tool',
+ justification: '下载构建依赖,并把产物写入工作区外的发布目录。',
+ expansion: {
+ filesystem: {
+ entries: [
+ { path: '/Users/maka/release', access: 'write', scope: 'subtree' },
+ { path: '/Users/maka/.config/signing.json', access: 'read', scope: 'exact' },
+ ],
+ },
+ network: { enabled: true },
+ },
+} satisfies SandboxBoundaryRequestEvent;
+
+
// A terminal whose scrollback runs long — the "very many rows" state a fresh
// shell never shows.
const LONG_TERMINAL_BUFFER = [
@@ -898,6 +925,8 @@ function bridge(options: {
terminalBuffer?: string;
/** Make `terminal.write` reject, so typing into the terminal shows the write-failed Banner. */
terminalWriteFails?: boolean;
+ /** Host execution facts for the parent Session the side chat observes. */
+ parentExecution?: SessionExecutionProjection;
} = {}): Decorator {
const browserState = options.browserState ?? EMPTY_BROWSER_STATE;
let recentPendingReads = options.recentPendingReads ?? 0;
@@ -1037,7 +1066,8 @@ function bridge(options: {
respondToUserQuestion: async () => undefined,
respondToUserForm: async () => undefined,
subscribeEvents: (_sessionId, handler, onSeeded, _onSeedError, onExecution) => {
- if (options.recentRunning !== undefined) onExecution?.({ type: 'host_execution', available: true,
+ if (options.parentExecution) onExecution?.(options.parentExecution);
+ else if (options.recentRunning !== undefined) onExecution?.({ type: 'host_execution', available: true, pendingInteractionKinds: [],
rootTurn: options.recentRunning
? { sessionId: SESSION_ID, turnId: 'source-turn', runId: 'story-run', status: 'running' }
: null });
@@ -1047,7 +1077,7 @@ function bridge(options: {
const timer = options.recentCompletesAfterMs === undefined ? undefined : window.setTimeout(() => {
if (options.recentStopReason === 'user_stop') handler({ type: 'abort', id: 'recent-abort', turnId: 'source-turn', ts: NOW + 2_000, reason: 'user_stop' });
handler({ type: 'complete', id: 'recent-complete', turnId: 'source-turn', ts: NOW + 2_000, stopReason: options.recentStopReason ?? 'end_turn' });
- onExecution?.({ type: 'host_execution', available: true, rootTurn: {
+ onExecution?.({ type: 'host_execution', available: true, pendingInteractionKinds: [], rootTurn: {
sessionId: SESSION_ID, turnId: 'source-turn', runId: 'story-run', status: 'completed',
terminalEventId: 'recent-complete',
} });
@@ -1081,6 +1111,7 @@ function Workbar(props: {
* affordance drive `rightCollapsed`, the way the app's reducer does.
*/
collapsible?: boolean;
+ parentFrame?: 'waiting-approval';
focusable?: boolean;
}) {
const [collapsed, setCollapsed] = useState(false);
@@ -1131,6 +1162,7 @@ function Workbar(props: {
-
+
+ {props.parentFrame === 'waiting-approval' ? (
+ // ChatComposerRegion owns `.maka-composer-interaction-slot` around
+ // parent prompts; that wrapper is not exported on its own.
+
+ undefined}
+ />
+
+ ) : null}
{props.focusable && (
@@ -1183,6 +1225,12 @@ function Workbar(props: {
props.sourceSession ??
(props.tab === 'side-chat' ? TOOL_PICKER_SOURCE_SESSION : undefined)
}
+ parentExecution={props.parentFrame === 'waiting-approval' ? {
+ type: 'host_execution', available: true,
+ rootTurn: { sessionId: SESSION_ID, turnId: 'parent-turn', runId: 'parent-run', status: 'waiting_for_user' },
+ pendingInteractionKinds: ['sandbox_boundary'],
+ } : undefined}
+ onOpenParentConversation={focusParentConversation}
/>
@@ -2325,6 +2373,85 @@ export const SideChat: Story = {
render: () =>
,
};
+// Real path: 任务工作栏 → 侧边对话 while the parent Session is waiting on a
+// sandbox_boundary approval. The notice is the production companion panel;
+// the parent column mounts the production SandboxBoundaryPrompt, which is
+// what ChatComposerRegion places in its interaction slot.
+const parentWaitingApproval = (): Decorator =>
+ bridge({
+ parentExecution: {
+ type: 'host_execution',
+ available: true,
+ rootTurn: {
+ sessionId: SESSION_ID,
+ turnId: 'parent-turn',
+ runId: 'parent-run',
+ status: 'waiting_for_user',
+ },
+ pendingInteractionKinds: ['sandbox_boundary'],
+ } satisfies SessionExecutionProjection,
+ });
+
+export const SideChatParentWaitingApproval: Story = {
+ decorators: [parentWaitingApproval()],
+ render: () =>
,
+ play: async ({ canvasElement }) => {
+ const notice = await waitFor(() => {
+ const found = canvasElement.querySelector
(
+ '[data-maka-contract="side-chat-parent-status"]',
+ );
+ if (!found) throw new Error('parent status notice is missing');
+ return found;
+ });
+ expect(notice.dataset.status).toBe('waiting_approval');
+ expect(notice.textContent).toContain('主任务等待审批');
+ const slot = canvasElement.querySelector(
+ '.mainColumn .maka-composer-interaction-slot',
+ );
+ if (!slot) throw new Error('parent interaction slot is missing');
+ const reject = within(slot).getByRole('button', { name: '拒绝' });
+ const allow = within(slot).getByRole('button', { name: '本任务允许' });
+ const action = within(notice).getByRole('button', { name: '前往主对话' });
+ await userEvent.click(action);
+ expect(document.activeElement === reject || document.activeElement === allow).toBe(true);
+ },
+};
+
+// Real path: 侧边对话 at the column's 320px floor while the parent is waiting on
+// a sandbox_boundary approval. The notice has to survive the narrowest column
+// the resize handle allows, and its action still has to land on the parent's
+// own approval controls rather than on this panel's composer.
+export const SideChatParentWaitingApprovalAtColumnFloor: Story = {
+ decorators: [parentWaitingApproval()],
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const notice = await waitFor(() => {
+ const found = canvasElement.querySelector(
+ '[data-maka-contract="side-chat-parent-status"]',
+ );
+ if (!found) throw new Error('parent status notice is missing');
+ return found;
+ });
+ const column = notice.closest('.maka-session-workbar-panel');
+ if (!column) throw new Error('workbar column is missing');
+ const columnBox = column.getBoundingClientRect();
+ const noticeBox = notice.getBoundingClientRect();
+ expect(noticeBox.width).toBeLessThanOrEqual(columnBox.width + 1);
+ expect(noticeBox.left).toBeGreaterThanOrEqual(columnBox.left - 1);
+ expect(noticeBox.right).toBeLessThanOrEqual(columnBox.right + 1);
+
+ const slot = canvasElement.querySelector(
+ '[data-maka-parent-interaction] .maka-composer-interaction-slot',
+ );
+ if (!slot) throw new Error('parent interaction slot is missing');
+ const reject = within(slot).getByRole('button', { name: '拒绝' });
+ const allow = within(slot).getByRole('button', { name: '本任务允许' });
+ const action = within(notice).getByRole('button', { name: '前往主对话' });
+ await userEvent.click(action);
+ expect(document.activeElement === reject || document.activeElement === allow).toBe(true);
+ },
+};
+
// Real path: 侧边对话 at the column's 320px floor, under a long model label.
export const SideChatAtColumnFloor: Story = {
decorators: [bridge()],
diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx
index 99a2cade06..15f600860e 100644
--- a/apps/desktop/stories/workhub.stories.tsx
+++ b/apps/desktop/stories/workhub.stories.tsx
@@ -86,7 +86,7 @@ function makeServices(failFirst: boolean, withHistory: boolean | 'usage', colore
let questionPending = question;
let pendingForm: import('@maka/core/events').FormRequestEvent | undefined;
let newWorkDefaults: Omit = {};
- const publishExecution = () => updateExecution?.({ type: 'host_execution', available: true, rootTurn: pendingForm ? { sessionId, turnId: pendingForm.turnId, runId: 'selection-run', status: 'waiting_for_user' } : questionPending ? { sessionId, turnId: 'question-turn', runId: 'question-run', status: 'waiting_for_user' } : null });
+ const publishExecution = () => updateExecution?.({ type: 'host_execution', available: true, pendingInteractionKinds: [], rootTurn: pendingForm ? { sessionId, turnId: pendingForm.turnId, runId: 'selection-run', status: 'waiting_for_user' } : questionPending ? { sessionId, turnId: 'question-turn', runId: 'question-run', status: 'waiting_for_user' } : null });
const publish = () => { publishExecution(); updateTranscript?.({ messages, hasOlder: false, ready: true }); };
return {
inspector: {
@@ -538,7 +538,8 @@ export const QuestionLifecycle: Story = {
await waitFor(() => expect(within(prompt).getAllByRole('option')[1]).toHaveAttribute('aria-selected', 'true'));
await userEvent.keyboard('{Enter}');
await waitFor(() => expect(canvasElement.querySelector('.maka-user-question-prompt')).toBeNull());
- expect(canvas.getByText('按公开测试安排发布。')).toBeInTheDocument();
+ // The transcript may remount after the question prompt disappears.
+ await waitFor(() => expect(canvas.getByText('按公开测试安排发布。')).toBeInTheDocument());
expect(writes.question).toHaveBeenLastCalledWith(sessionId, expect.objectContaining({ requestId: 'question-request' }));
},
};
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index d40d926b43..c0d6f5d5ae 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.2` (195 component exports).
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 300 files — blocker 0, reimplementation 0, polish 4, aligned 296.
+**Totals:** 301 files — blocker 0, reimplementation 0, polish 4, aligned 297.
## Exclusions (explicit)
@@ -122,6 +122,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/review/session-review-base-branch-picker.tsx` | shell-chrome-or-panel | Selector, SelectorOption, Text | aligned — uses Astryx (Selector, SelectorOption, Text) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx` | shell-chrome-or-panel | Banner, Button, Collapsible, CollapsibleGroup, EmptyState, HStack, Section, Skeleton, Text, VStack | aligned — uses Astryx (Banner, Button, Collapsible, CollapsibleGroup, EmptyState, HStack, Section, Skeleton) | aligned |
+| `apps/desktop/src/renderer/features/workbar/tools/side-chat/parent-task-status-notice.tsx` | shell-chrome-or-panel | Banner, Button | aligned — uses Astryx (Banner, Button) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx` | shell-chrome-or-panel | Banner | aligned — uses Astryx (Banner) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx` | shell-chrome-or-panel | Banner, EmptyState | aligned — uses Astryx (Banner, EmptyState) | aligned |
| `apps/desktop/src/renderer/features/workbar/ui/recent-turn-overlay.tsx` | shell-chrome-or-panel | Button | aligned — uses Astryx (Button) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 726661172c..d13bdb6b17 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -92,6 +92,7 @@ apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx
apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
apps/desktop/src/renderer/features/workbar/tools/review/session-review-base-branch-picker.tsx
apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx
+apps/desktop/src/renderer/features/workbar/tools/side-chat/parent-task-status-notice.tsx
apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx
apps/desktop/src/renderer/features/workbar/ui/recent-turn-overlay.tsx
diff --git a/scripts/storybook-visual-smoke.mjs b/scripts/storybook-visual-smoke.mjs
index 7dd51657eb..fd2002c62a 100644
--- a/scripts/storybook-visual-smoke.mjs
+++ b/scripts/storybook-visual-smoke.mjs
@@ -73,6 +73,7 @@ const DARK_THEME_SENTINEL_STORY_IDS = new Set([
'product-settings-pages--appearance',
'product-settings-pages--bot-chat-needs-attention',
'product-shell-official-appshell--default-layout',
+ 'product-session-workbar--side-chat-parent-waiting-approval',
'product-workhub--standard-composer',
'product-workhub--progress-model-picker',
]);