Merge remote-tracking branch 'origin/master' into worktree/code-diff-card
This commit is contained in:
commit
5991ea36ae
13 files changed
+133
-36
No files matched your search
+2
-2
@@ -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
|
||||
+2
@@ -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`).
|
||||
|
||||
+2
@@ -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`)。
|
||||
|
||||
@@ -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<typeof watchConsole>
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 不会激活。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<header
|
||||
className={clsx(css.header, hideChrome && css.headerHidden)}
|
||||
aria-hidden={(hideChrome && !darwinDesktop) || undefined}
|
||||
>
|
||||
<header className={clsx(css.header, hideChrome && css.headerBlank)}>
|
||||
<div className={css.titleRow}>
|
||||
<div className={css.headerLeading} data-conversation-header-leading="">
|
||||
{renderSlot('conversation.session.header.leading', {})}
|
||||
@@ -142,11 +132,11 @@ export function ConversationSessionHeader({
|
||||
<div className={css.headerUtilities}>
|
||||
{renderSlot('conversation.session.header.utilities', {})}
|
||||
</div>
|
||||
<div className={css.headerCorner} data-conversation-header-corner="">
|
||||
{renderSlot('conversation.session.header.corner', {})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className={css.headerCorner} data-conversation-header-corner="">
|
||||
{renderSlot('conversation.session.header.corner', {})}
|
||||
</div>
|
||||
</div>
|
||||
{!hideChrome && tabs.length > 1 && (
|
||||
<div className={css.tabs} role="tablist">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -22,6 +22,9 @@
|
||||
- button "Settings":
|
||||
- img
|
||||
- text: Settings
|
||||
- banner:
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- text: Into the Unknown Preview
|
||||
- button "Choose workspace":
|
||||
- img
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
- button "Settings":
|
||||
- img
|
||||
- text: Settings
|
||||
- banner:
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- text: Into the Unknown Preview
|
||||
- button "Choose workspace":
|
||||
- img
|
||||
|
||||
Reference in new issue
Block a user