Skip to content

Commit b7f2b0c

Browse files
fix(app-shell): a framed console (Live Canvas preview) no longer opens a second assistant dock (#26504)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi94WEaVGC3gCAgvKR4LWw1qE)_ Fixes #26470 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 控制台被嵌在 iframe 里(实时画布的草稿预览)时,助手面板不再读写宿主页的「面板展开」状态:框内面板默认收起,开关只在框内生效。 - **为什么改:** 让助手改应用时自动打开的预览框共享了标签页的 sessionStorage,框里又开了一个助手面板;1024 px 下两个 420 px 面板把列表挤到约 200 px。 - **风险与回滚:** 低。只改 `chatDockState.ts` 的读写两个函数;顶层页面行为不变(对照测试钉住)。回滚即 revert 本 PR。 - **先看:** `ui/packages/app-shell/src/layout/chatDockState.ts` 的 `isFramedConsole` 与读写函数开头的守卫;新测试文件。 - **两分钟验证:** `pnpm --dir ui exec vitest run packages/app-shell/src/layout/__tests__/ConsoleLayout.previewFrameDock-26470.test.tsx`,预期 4 个测试全绿。 - **需要你做的:** 无,席位落地。 ## What changes `ui/packages/app-shell/src/layout/chatDockState.ts`: - New `isFramedConsole()`: `window.self !== window.top`, and `true` when the parent refuses access. - `readStoredDockExpanded` returns its `fallback` inside a frame, so the framed console's dock starts from its own default (collapsed for the console rail) instead of the host page's `ai-chat-dock-expanded` flag. - `writeStoredDockExpanded` is a no-op inside a frame. `useChatDockState` keeps the frame's expanded state in React state only, so opening or closing the dock in the preview never writes the host's flag. `armChatDockExpanded` goes through the same writer. The guard sits in the dock state module rather than in a `ConsoleLayout` special case, as the card asked. Every reader and writer of the flag passes through these two helpers, so `ConsoleLayout` is unchanged. ### Frame signal: `window.self !== window.top`, not `?preview=draft` One signal, chosen for these reasons: 1. The query misses a case the card's defect covers. `LiveCanvas`'s `canvasSrc` drops `?preview=draft` once the app is materialized (the frame then loads the plain `/apps/SEGMENT`). That frame shares sessionStorage just the same and would still open a second dock. 2. The query also matches a page that is not framed. A draft preview opened in its own tab is an ordinary console page; its dock is the only assistant on screen and should keep working as before. 3. The frame test is the thing that actually causes the defect: sharing the tab's sessionStorage with a host page that has its own dock. ## Tests New `ui/packages/app-shell/src/layout/__tests__/ConsoleLayout.previewFrameDock-26470.test.tsx`. It renders the real `ConsoleLayout`, `AppShell`, `useChatDockState` and storage helpers; only the rail body is a stand-in. "Framed" is `window.top` answering a different window. 1. Framed, host flag `'1'`: the dock does not mount; the launcher is there. 2. Framed, host flag `'0'`: opening (launcher click) and closing (the keyboard toggle) the dock in the frame leaves the flag `'0'`. 3. Framed, host flag unset: toggling leaves it unset. 4. Control, top-level: flag `'1'` mounts the dock, and toggling writes `'0'` then `'1'` as before. No existing test was edited. ## Acceptance notes - The card's "Expected" also suggests that a framed console show no assistant launcher and arguably no sidebar or top bar. The seat's delta asked for a dock that starts closed and is scoped to the frame, so the launcher stays in the frame and the chrome is unchanged. Hiding preview chrome is a product call, listed in the report's open questions. - Any console in a frame now keeps its dock state per frame, not only the Live Canvas. A console embedded by some other host starts collapsed and does not carry the open dock across a full reload inside the frame; the dock still works there. - The rail WIDTH still persists through localStorage (`ai-chat-dock-width`) inside a frame, as it does across tabs. The card is about the expanded flag; width is a shared user preference, not a second dock. - `rememberDockReturnLocation` (the maximize-to-`/ai` origin) is not framed-scoped. Maximizing from inside a preview frame is out of this card's scope. ## Verification All runs on head `d368ab53`, worktree based on `origin/main` `00a4593a`. <details><summary>Targeted vitest (new file + the dock siblings)</summary> ``` pnpm --dir ui exec vitest run (under os-verify-lock) \ .../ConsoleLayout.previewFrameDock-26470.test.tsx .../chatDockState.test.ts \ .../ConsoleLayout.fabYieldsToOpenDock-10899.test.tsx .../chatDockReturnLocation.test.tsx \ .../ChatDock.test.tsx .../ConsoleLayout.dockBinding-10926.test.tsx Test Files 6 passed (6) Tests 33 passed (33) os-verify-lock: VERDICT command-exit 0 ``` </details> <details><summary>Ablation: source reverted to origin/main, new test re-run</summary> `chatDockState.ts` restored from `00a4593a` (tree only; `isFramedConsole` count on disk 0), new test file run, file restored with `git checkout HEAD --` (hash equals the HEAD blob, `git diff HEAD` empty). ``` × inside a frame, the host page's EXPANDED flag does not mount a second dock × opening and closing the dock inside a frame leaves the host page's flag as it was × inside a frame, an unset host flag stays unset after the dock is toggled Tests 3 failed | 1 passed (4) os-verify-lock: VERDICT command-exit 1 ``` The three framed cases go red and the top-level control stays green. </details> <details><summary>Type check and gates</summary> `pnpm exec turbo run build --filter='@object-ui/app-shell^...' --concurrency=2 && pnpm --filter @object-ui/app-shell type-check` (tsc --noEmit, then tsc -p tsconfig.test.json, which covers the new test): `os-verify-lock: VERDICT command-exit 0`. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 commands; each ran with exit 0. `--ran` reconciliation: `35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero: all 35 recorded an exit code). </details> The full app-shell suite and the repo-wide farm are left to CI (`Test UI`, `Type Check UI`, `Lint UI`). --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmPEQvCWPCD2sAQWd3D)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14f3099 commit b7f2b0c

3 files changed

Lines changed: 186 additions & 2 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a console rendered inside a frame (the Live Canvas preview) no longer opens a second assistant dock from the host page's state
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The Live Canvas shows the app in an iframe of the full console. That frame shares the tab's sessionStorage, so it read the host page's `ai-chat-dock-expanded` flag and mounted its own assistant dock beside the host's. At 1024 px the two 420 px rails left the app's list about 200 px wide.
11+
12+
What changes:
13+
14+
- A console inside a frame (`window.self !== window.top`) ignores the stored dock flag and starts with its dock collapsed. The assistant launcher stays, so the dock is still one click away inside the preview.
15+
- Opening or closing the dock inside the frame keeps that state in the frame only. It no longer writes the host page's flag, so the host's dock is unchanged when the preview closes.
16+
- A top-level console page, including a preview opened in its own tab, keeps the dock exactly as before.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A console rendered inside a frame keeps its assistant dock to itself
5+
* (objectstack#26470).
6+
*
7+
* Measured on the 2026-10-27 desktop re-test at 1024 px: with the host page's
8+
* dock open, asking the assistant for a change opened the ADR-0037 Live Canvas,
9+
* an iframe of the full console. The frame shares the tab's sessionStorage, so
10+
* it read the host's `ai-chat-dock-expanded` flag and mounted a second dock;
11+
* two 420 px rails left the record list about 200 px.
12+
*
13+
* ## Real subjects
14+
*
15+
* `ConsoleLayout` with its real `AppShell`, the real `useChatDockState` and the
16+
* real storage helpers in `chatDockState`. Only the rail BODY is a stand-in:
17+
* this card is about whether the rail mounts and which flag it writes, not the
18+
* chat inside it. "Framed" is `window.top` answering a different window, which
19+
* is what a browser reports inside an iframe.
20+
*/
21+
22+
import '@testing-library/jest-dom/vitest';
23+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
24+
import React from 'react';
25+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
26+
import { MemoryRouter } from 'react-router-dom';
27+
28+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
29+
...(await importOriginal<Record<string, unknown>>()),
30+
useObjectTranslation: () => ({
31+
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
32+
language: 'en',
33+
}),
34+
}));
35+
36+
// The real dock state hook; only the rail's body and the mobile sheet are stubbed.
37+
vi.mock('../ChatDock', async (importOriginal) => ({
38+
...(await importOriginal<Record<string, unknown>>()),
39+
ChatDockPanel: () => <aside data-testid="chat-dock-panel" />,
40+
ChatDockMobileSheet: () => null,
41+
}));
42+
43+
// The AI surface is ON — the dock's own gate — in every case here.
44+
vi.mock('../../hooks/useAiSurface', () => ({
45+
useAiSurfaceEnabled: () => ({ enabled: true, isLoading: false }),
46+
}));
47+
48+
// Chrome this card does not touch.
49+
vi.mock('../UnifiedSidebar', () => ({ UnifiedSidebar: () => null }));
50+
vi.mock('../AppHeader', () => ({ AppHeader: () => <div data-testid="app-header-stub" /> }));
51+
vi.mock('../ConsoleNotificationBanners', () => ({ ConsoleNotificationBanners: () => null }));
52+
vi.mock('../../preview/DraftPreviewBar', () => ({ DraftPreviewBar: () => null }));
53+
vi.mock('../../preview/UnpublishedAppBar', () => ({ UnpublishedAppBar: () => null }));
54+
55+
import { ConsoleLayout } from '../ConsoleLayout';
56+
import { NavigationProvider } from '../../context/NavigationContext';
57+
import { DOCK_EXPANDED_STORAGE_KEY } from '../chatDockState';
58+
59+
const APP = { name: 'crm', label: 'CRM', active: true, navigation: [] };
60+
61+
function mountConsole() {
62+
return render(
63+
<MemoryRouter initialEntries={['/apps/crm?preview=draft']}>
64+
<NavigationProvider>
65+
<ConsoleLayout activeAppName="crm" activeApp={APP} onAppChange={vi.fn()} objects={[]}>
66+
<div data-testid="route-content">page</div>
67+
</ConsoleLayout>
68+
</NavigationProvider>
69+
</MemoryRouter>,
70+
);
71+
}
72+
73+
/** ⌘/Ctrl+Shift+I — the dock's own toggle, wired by ConsoleLayout. */
74+
function pressDockToggle() {
75+
fireEvent.keyDown(document, { key: 'I', ctrlKey: true, shiftKey: true });
76+
}
77+
78+
const ownTop = Object.getOwnPropertyDescriptor(window, 'top');
79+
80+
/** Make `window.top` answer a different window, as it does inside an iframe. */
81+
function renderInsideFrame() {
82+
Object.defineProperty(window, 'top', { configurable: true, get: () => ({}) });
83+
}
84+
85+
beforeEach(() => window.sessionStorage.clear());
86+
afterEach(() => {
87+
cleanup();
88+
if (ownTop) Object.defineProperty(window, 'top', ownTop);
89+
window.sessionStorage.clear();
90+
});
91+
92+
describe('a framed console keeps its assistant dock to itself (objectstack#26470)', () => {
93+
it('inside a frame, the host page’s EXPANDED flag does not mount a second dock', () => {
94+
window.sessionStorage.setItem(DOCK_EXPANDED_STORAGE_KEY, '1');
95+
renderInsideFrame();
96+
expect(window.self).not.toBe(window.top);
97+
mountConsole();
98+
expect(screen.getByTestId('route-content')).toBeInTheDocument();
99+
expect(screen.queryByTestId('chat-dock-panel')).not.toBeInTheDocument();
100+
// Collapsed, so its launcher is there: the dock is still one click away.
101+
expect(screen.getByTestId('console-chatbot-fab')).toBeInTheDocument();
102+
});
103+
104+
it('opening and closing the dock inside a frame leaves the host page’s flag as it was', () => {
105+
window.sessionStorage.setItem(DOCK_EXPANDED_STORAGE_KEY, '0');
106+
renderInsideFrame();
107+
mountConsole();
108+
109+
fireEvent.click(screen.getByTestId('console-chatbot-fab'));
110+
expect(screen.getByTestId('chat-dock-panel')).toBeInTheDocument();
111+
expect(window.sessionStorage.getItem(DOCK_EXPANDED_STORAGE_KEY)).toBe('0');
112+
113+
pressDockToggle();
114+
expect(screen.queryByTestId('chat-dock-panel')).not.toBeInTheDocument();
115+
expect(window.sessionStorage.getItem(DOCK_EXPANDED_STORAGE_KEY)).toBe('0');
116+
});
117+
118+
it('inside a frame, an unset host flag stays unset after the dock is toggled', () => {
119+
renderInsideFrame();
120+
mountConsole();
121+
pressDockToggle();
122+
expect(screen.getByTestId('chat-dock-panel')).toBeInTheDocument();
123+
expect(window.sessionStorage.getItem(DOCK_EXPANDED_STORAGE_KEY)).toBeNull();
124+
});
125+
126+
it('CONTROL — a top-level console still mounts the stored dock and round-trips the flag', () => {
127+
window.sessionStorage.setItem(DOCK_EXPANDED_STORAGE_KEY, '1');
128+
expect(window.self).toBe(window.top);
129+
mountConsole();
130+
expect(screen.getByTestId('chat-dock-panel')).toBeInTheDocument();
131+
132+
pressDockToggle();
133+
expect(screen.queryByTestId('chat-dock-panel')).not.toBeInTheDocument();
134+
expect(window.sessionStorage.getItem(DOCK_EXPANDED_STORAGE_KEY)).toBe('0');
135+
136+
pressDockToggle();
137+
expect(screen.getByTestId('chat-dock-panel')).toBeInTheDocument();
138+
expect(window.sessionStorage.getItem(DOCK_EXPANDED_STORAGE_KEY)).toBe('1');
139+
});
140+
});

‎ui/packages/app-shell/src/layout/chatDockState.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,33 @@ export function parseStoredDockExpanded(raw: string | null): boolean | null {
7676
return null;
7777
}
7878

79-
/** Read a stored expanded flag; unset OR storage failure (private mode) → `fallback`. */
79+
/**
80+
* objectstack#26470 — true when this console renders inside another page's frame, e.g.
81+
* the ADR-0037 Live Canvas, which iframes the full console (draft preview or
82+
* the materialized app) beside the chat. A same-origin frame shares the tab's
83+
* sessionStorage, so without this the frame would read the host's expanded
84+
* flag and mount a second assistant dock next to the host's own.
85+
*
86+
* Judged on the frame itself, not on `?preview=draft`: the canvas drops that
87+
* flag once the app is materialized, and a top-level preview tab is an
88+
* ordinary console page that keeps its dock. A parent that refuses access
89+
* (cross-origin) still means framed.
90+
*/
91+
export function isFramedConsole(): boolean {
92+
try {
93+
return window.self !== window.top;
94+
} catch {
95+
return true;
96+
}
97+
}
98+
99+
/**
100+
* Read a stored expanded flag; unset OR storage failure (private mode) →
101+
* `fallback`. Inside a frame ({@link isFramedConsole}) the stored flag belongs
102+
* to the host page, so the frame's dock starts from `fallback` too.
103+
*/
80104
export function readStoredDockExpanded(key: string, fallback: boolean): boolean {
105+
if (isFramedConsole()) return fallback;
81106
try {
82107
const parsed = parseStoredDockExpanded(window.sessionStorage.getItem(key));
83108
return parsed === null ? fallback : parsed;
@@ -89,9 +114,12 @@ export function readStoredDockExpanded(key: string, fallback: boolean): boolean
89114
/**
90115
* Persist an expanded flag as `'1'` / `'0'` (never a remove) — see
91116
* {@link parseStoredDockExpanded} for why collapse must be stored explicitly.
92-
* Storage failures are swallowed.
117+
* Storage failures are swallowed. A no-op inside a frame
118+
* ({@link isFramedConsole}): the frame's dock keeps its state in memory and
119+
* never touches the host page's flag.
93120
*/
94121
export function writeStoredDockExpanded(key: string, expanded: boolean): void {
122+
if (isFramedConsole()) return;
95123
try {
96124
window.sessionStorage.setItem(key, expanded ? '1' : '0');
97125
} catch {

0 commit comments

Comments
 (0)