diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index f9a0dfdce3..6e4befba1f 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 620102f9f5e7fd76f38bf0031b856b26c2a7840d -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 3f00658c672908ba92627416ba5d5db381c0d9cd +2026-07-25-web-client-session-scope-and-provide-channel.md: 0f003edc4a53e7a04ea4f5613abe42c2d3926566 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1bae16cfaa1c54eebc6c8709ac6db5323b68d222 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 620102f9f5..0f003edc4a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -95,6 +95,8 @@ Slot scope is the closed set `root | session-maybe | session`: `conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. +Blank Sessions retain the header's leading and corner slots so navigation controls, including the right-sidebar opener, are available before the first message. Title, actions, utilities, and View tabs remain hidden in the blank phase. The header still requires a selected Session; the Files and Terminal entries use that Session's workspace and execution services without requiring a recorded Turn. + - The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing. - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders). - Third-party components take zero value dependencies; types are a one-line type-only import (declaration merging into `SessionStandardProps` / `SessionMaybeStandardProps`). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 3f00658c67..1bae16cfaa 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -95,6 +95,8 @@ slot scope 是闭集 `root | session-maybe | session`: `conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无会话 → blank 会话的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 +blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入口等导航控件在首条消息之前即可使用。标题、actions、utilities 和 View tabs 在 blank phase 中继续隐藏。header 仍要求已选中的 Session;Files 与 Terminal 入口使用该 Session 的工作区和执行服务,无需已有 Turn 记录。 + - 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。 - 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。 diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index a02bafac00..6f6fb83f10 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -1,5 +1,5 @@ // Recorded-session Sidebar geometry and per-Session view state through the shipped browser composition. -import { mkdir, readFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Locator, Page } from 'playwright' @@ -15,6 +15,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/details-session-lifecycle', import.meta.url)) const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md') const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') +const BLANK_EXPECTED = join(SNAPSHOT_DIR, 'blank-session.expected.md') const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0907-sidebar-rules', import.meta.url)) const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)) const SEED_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) @@ -127,17 +128,20 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S let browser: Browser let page: Page let tripwire: ReturnType + const sessionEvents: string[] = [] beforeAll(async () => { const fixture = await readFile(FIXTURE, 'utf8') expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false }) await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed') + scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event.type) }) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await appFrame(page).waitFor({ timeout: 30_000 }) + expect(await page.locator('[data-sidebar-right-expand]').count()).toBe(0) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) @@ -154,12 +158,77 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await mkdir(SHOT_DIR, { recursive: true }) await saveFailureShot(page, `screenshots/0907-sidebar-rules/details-session-lifecycle-${MODE}-${process.pid}`) }) + const blankColumn = page.locator('[data-rightbar-col]') + const workspace = join(scaffold.workspaceCwd, 'workspace') + await writeFile(join(workspace, 'before-chat.md'), '# Before the first message\n\nWorkspace preview is available.\n') + await page.locator('[data-sidebar-right-expand]').waitFor({ state: 'visible' }) + await page.screenshot({ path: join(SHOT_DIR, `blank-collapsed-${MODE}-${process.pid}.png`), fullPage: true }) + await page.locator('[data-sidebar-right-expand]').click() + await blankColumn.locator('[data-sidebar-right-guide-entry="files"]').click() + await blankColumn.locator('[data-files-entry="file"]').getByRole('button', { name: 'before-chat.md', exact: true }).click() + await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor() + await page.getByText('Into the Unknown', { exact: false }).waitFor() + const blankPanes = await paneSnapshot(page) + expect(blankPanes.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'before-chat.md']]) + await page.screenshot({ path: join(SHOT_DIR, `blank-preview-${MODE}-${process.pid}.png`), fullPage: true }) + + const blankViewport = page.viewportSize()! + try { + await blankColumn.locator('[data-sidebar-right-toggle]').click() + await page.setViewportSize({ width: 767, height: blankViewport.height }) + await page.locator('[data-sidebar-right-expand]').click() + await expect.poll(() => blankColumn.locator('[data-sidebar-right-panel]').boundingBox()) + .toEqual({ x: 0, y: 0, width: 767, height: blankViewport.height }) + await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor() + await blankColumn.locator('[data-sidebar-right-toggle]').click() + } finally { + await page.setViewportSize(blankViewport) + } + await page.locator('[data-sidebar-right-expand]').click() + await blankColumn.locator('[data-dockkit-add-tab]').click() + await blankColumn.locator('[data-sidebar-right-guide-entry="terminal"]') + .getByRole('button', { name: /^New terminal/u }).click() + const agent = scaffold.ctx.agents.list().find(agent => agent.session.header.cwd === workspace) + if (agent === undefined) throw new Error('Blank Session has no workspace Agent') + await expect.poll(() => scaffold.ctx.terminalController.list(agent.id).map(terminal => terminal.state)).toEqual(['running']) + await page.locator('.xterm-helper-textarea:visible').click() + await page.keyboard.insertText('node -e "require(\'fs\').writeFileSync(\'before-chat-terminal.txt\',\'READY\')"') + await page.keyboard.press('Enter') + await expect.poll(() => readFile(join(workspace, 'before-chat-terminal.txt'), 'utf8')).toBe('READY') + expect(sessionEvents).not.toContain('turn/start') + expect(sessionEvents).not.toContain('user/message') + await blankColumn.locator('[data-dockkit-tab][aria-selected="true"]').hover() + // The tab disappears before the Host finishes process cleanup; close responds after quiescence. + const [closed] = await Promise.all([ + page.waitForResponse('**/api/terminal/close'), + blankColumn.locator('[data-dockkit-tab][aria-selected="true"] [data-dockkit-tab-close]').click(), + ]) + expect(await closed.json()).toMatchObject({ result: { ok: true } }) + expect(scaffold.ctx.terminalController.list(agent.id)).toEqual([]) + await blankColumn.locator('[data-dockkit-tab]').filter({ hasText: 'before-chat.md' }).click() + await compareOrRefreshGolden(BLANK_EXPECTED, [ + '# Blank Session workspace sidebar', '', + '- No selected Session: expand control absent', + '- Selected workspace before first message: expand control visible', + '- Files: before-chat.md opens as a Markdown preview', + '- Narrow viewport: reopened preview fills the viewport', + '- Terminal: writes a file in the selected workspace before any user message or turn', '', + `\`\`\`json\n${JSON.stringify(await paneSnapshot(page), null, 2)}\n\`\`\``, + ].join('\n'), MODE) + const settled = scaffold.whenTurnSettled() const input = page.locator('[data-composer-input]').first() await input.fill(PROMPT) await input.press('Enter') await settled await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await paneSnapshot(page)).toEqual(blankPanes) + await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor() + for (const title of ['before-chat.md', 'Files']) { + const tab = blankColumn.locator('[data-dockkit-tab]').filter({ hasText: title }) + await tab.hover() + await tab.locator('[data-dockkit-tab-close]').click() + } await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) @@ -356,6 +425,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await compareOrRefreshGolden(SIDEBAR_EXPECTED, checkpoints.join('\n\n'), MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md', 'sidebar.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md', 'sidebar.expected.md', 'blank-session.expected.md']) }) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 00716afb9e..adfe0407f4 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 802ba3b8d853457e00cab3180c251ffec5291f19 -README.zh.md: 83199417289770640c9c038d84ee3e0fa6ac7866 +README.md: e07c2f66e55d0bfe55b869d5b395914670278be3 +README.zh.md: 81e5300e5bac3d6d54e83ead165598cb2c378a99 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 802ba3b8d8..e07c2f66e5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -46,6 +46,8 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. +A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites. + View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8319941728..81e5300e5b 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -46,6 +46,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册 strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 +blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。 + View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 57cbdbc577..a57c5462d3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -46,10 +46,14 @@ border-bottom: 0.5px solid var(--dsw-alias-border-l3); } -/* Blank hero/settling: keep the strict Session header mounted without taking - column space; the root-owned scrollport and composer remain below it. */ -.headerHidden { - display: none; +/* Blank Sessions retain navigation controls without conversation title or tabs. */ +.headerBlank { + min-height: 0; + border-bottom: none; +} + +.headerBlank .headerCorner { + margin-left: auto; } /* macOS hiddenInset titlebar: the title row doubles as the window drag @@ -67,15 +71,6 @@ -webkit-app-region: no-drag; } -/* Blank state on macOS desktop: the chrome rows are gone but the leading seat - (the hidden sidebar's reopen control) stays on screen, so the header keeps - its layout without the rule. */ -:global([data-platform='darwin']) .headerHidden { - display: block; - min-height: 0; - border-bottom: none; -} - .titleRow { display: flex; align-items: center; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 92816a6e1c..8f025cf548 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,7 +1,6 @@ /** Strict per-session header/body content inserted into the resident conversation layout. */ import clsx from 'clsx' -import { isDarwinDesktop } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { @@ -55,8 +54,7 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum /** * Renders Session header chrome above the resident conversation scrollport. * @param props - Strict Session store, view ledger, navigation, render, and locale shares. - * @returns the header: title and tabs when visible, and on macOS desktop the - * always-mounted leading seat even while blank-session chrome hides. + * @returns Session navigation controls, with title and tabs after conversation starts. */ export function ConversationSessionHeader({ sessionId, useSession, useSessions, useConversation, useConversationViews, useStore, @@ -69,16 +67,8 @@ export function ConversationSessionHeader({ const session = useSession(s => s) const conversation = useConversation(s => s) const hideChrome = session.blank && conversationPhase(session, conversation) === 'blank' - // macOS desktop keeps the leading seat mounted through the blank state (the - // hidden sidebar's reopen control lives there), so the header may not leave - // the layout or the accessibility tree while its chrome hides. - const darwinDesktop = isDarwinDesktop() - return ( -
+
{renderSlot('conversation.session.header.leading', {})} @@ -142,11 +132,11 @@ export function ConversationSessionHeader({
{renderSlot('conversation.session.header.utilities', {})}
-
- {renderSlot('conversation.session.header.corner', {})} -
)} +
+ {renderSlot('conversation.session.header.corner', {})} +
{!hideChrome && tabs.length > 1 && (
diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 31d811a9ee..7d7ba8b366 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -462,7 +462,7 @@ describe('ConversationRoot resident composer', () => { expect(seat?.contains(fallback)).toBe(true) }) - it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => { + it('hero phase: keeps sidebar controls accessible while hiding conversation chrome', () => { const b = mount( sessionSnapshotOf({ blank: true }), [ @@ -474,7 +474,11 @@ describe('ConversationRoot resident composer', () => { const host = b.view.container.querySelector('[data-conversation-scroll]') const header = b.view.container.querySelector('header') expect(host).not.toBeNull() - expect(header?.getAttribute('aria-hidden')).toBe('true') + expect(header?.getAttribute('aria-hidden')).not.toBe('true') + expect(b.view.getByTestId('view-conversation.session.header.corner')).toBeTruthy() + expect(b.view.queryByRole('tablist')).toBeNull() + expect(b.slotCalls).not.toContain('conversation.session.header.utilities') + expect(b.slotCalls).not.toContain('conversation.session.header.actions') expect(b.view.getByText('探索未至之境')).toBeTruthy() expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() diff --git a/snapshots/web/details-session-lifecycle/blank-session.expected.md b/snapshots/web/details-session-lifecycle/blank-session.expected.md new file mode 100644 index 0000000000..7ac71fccc0 --- /dev/null +++ b/snapshots/web/details-session-lifecycle/blank-session.expected.md @@ -0,0 +1,25 @@ +# Blank Session workspace sidebar + +- No selected Session: expand control absent +- Selected workspace before first message: expand control visible +- Files: before-chat.md opens as a Markdown preview +- Narrow viewport: reopened preview fills the viewport +- Terminal: writes a file in the selected workspace before any user message or turn + +```json +[ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": false + }, + { + "title": "before-chat.md", + "selected": true + } + ] + } +] +``` diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index 2a7bfef3e7..586271521d 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -22,6 +22,9 @@ - button "Settings": - img - text: Settings +- banner: + - button "Open right sidebar": + - img - text: Into the Unknown Preview - button "Choose workspace": - img diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index 3c3cb7da3a..044f7ebb12 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -22,6 +22,9 @@ - button "Settings": - img - text: Settings +- banner: + - button "Open right sidebar": + - img - text: Into the Unknown Preview - button "Choose workspace": - img