fix(workspace): preserve drafts and await newly created session views

This commit is contained in:
Tianyi Cui committed 2026-10-07 01:22:06 +08:00
1 parent 2b0dd1567d
commit f57c88f792
17 files changed
+130 -140

No files matched your search

@@ -388,7 +388,10 @@ describe('web e2e: agent-preset selection', () => {
const createdBody = await created.json() as { result: { value: { sessionId: SessionId; agentPreset: string } } }
expect(createdBody).toMatchObject({ result: { ok: true, value: { agentPreset: 'standard' } } })
const freshSessionId = createdBody.result.value.sessionId
const freshConversation = page.locator(`[data-conversation-session="${freshSessionId}"]`)
await freshConversation.waitFor()
await page.reload()
await freshConversation.waitFor()
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor()
await openSettings(page, 'en')
+24 -1
View File
@@ -11,7 +11,7 @@ import { globSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { act, cleanup } from '@testing-library/react'
import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, vi } from 'vitest'
import { bootInjections, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client'
@@ -310,6 +310,29 @@ export function mountAssembledApp(options: AssembledBootOptions = {}): Assembled
return remote
}
/**
* Create a fixture Session through the sidebar and wait for its own composer to mount.
* @param remote - fixture transport returned by mountAssembledApp.
* @returns composer inside the Session named by this creation's reply.
*/
export async function createAssembledSessionComposer(remote: AssembledRemote): Promise<HTMLElement> {
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
if (start === null) throw new Error('fixture Workspace new-session action missing')
const previousCalls = remote.mock.log.calls('session/create').length
fireEvent.click(start)
return await waitFor(() => {
const creation = remote.mock.log.calls('session/create')[previousCalls]
if (creation?.state !== 'answered') throw new Error('fixture Session creation has not completed')
const { value: { sessionId } } = creation.result as { value: { sessionId: string } }
const conversation = [...document.querySelectorAll<HTMLElement>('[data-conversation-session]')]
.find(element => element.getAttribute('data-conversation-session') === sessionId)
const composer = conversation?.querySelector<HTMLElement>('[data-composer-input][contenteditable="true"]')
if (composer === undefined || composer === null) throw new Error(`composer for Session ${sessionId} has not mounted`)
return composer
}, { timeout: 10_000 })
}
/**
* Match a CSS-module class by its logical name.
* Module class names carry a per-build hash in one of two schemes —
@@ -7,27 +7,12 @@
// consumes the images — serialized through the real draft-image chain into
// the commands/execute payload — and clears the composer on success, including
// when the image is the whole `/plan` task.
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, waitFor } from '@testing-library/react'
import { expect, it } from 'vitest'
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
import { createAssembledSessionComposer, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
installAssembledBootEnv()
/** Open a fresh fixture session and return its composer surface. */
async function freshComposer(): Promise<HTMLElement> {
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
if (start === null) throw new Error('fixture Workspace new-session action missing')
fireEvent.click(start)
return await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
}, { timeout: 10_000 })
}
/** Type through the clipboard: jsdom carries no editable beforeinput; the
* paste command inserts at the caret, committing a microtask later. */
async function pasteText(surface: HTMLElement, text: string): Promise<void> {
@@ -54,8 +39,8 @@ async function pasteImage(textarea: HTMLElement, name: string): Promise<void> {
}
it('refuses an image-carrying submit to a non-declaring command and keeps draft and images', async () => {
mountAssembledApp()
const textarea = await freshComposer()
const remote = mountAssembledApp()
const textarea = await createAssembledSessionComposer(remote)
await pasteImage(textarea, 'ref.png')
// /echo is a leadingInput fixture command without `input.attachments`.
@@ -80,8 +65,8 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft
})
it('consumes images through a declaring command and clears the composer on success', async () => {
mountAssembledApp()
const textarea = await freshComposer()
const remote = mountAssembledApp()
const textarea = await createAssembledSessionComposer(remote)
await pasteImage(textarea, 'goal-ref.png')
// /goal declares `input.attachments` in the fixture catalog; the claim submit
@@ -96,8 +81,8 @@ it('consumes images through a declaring command and clears the composer on succe
})
it('submits a bare /plan with an image as an image-only plan request', async () => {
mountAssembledApp()
const textarea = await freshComposer()
const remote = mountAssembledApp()
const textarea = await createAssembledSessionComposer(remote)
await pasteImage(textarea, 'plan-task.png')
// Trailing separator: a bare '/plan' leaves the caret on the token, where
+36 -34
View File
@@ -31,6 +31,11 @@ interface DraftSnapshot {
}
const EMPTY: DraftSnapshot = { text: '', references: [] }
// Chromium on macOS uses Command+Arrow for editor and line navigation.
const EDITOR_START = process.platform === 'darwin' ? 'Meta+ArrowUp' : 'Control+Home'
const EDITOR_END = process.platform === 'darwin' ? 'Meta+ArrowDown' : 'Control+End'
const LINE_END = process.platform === 'darwin' ? 'Meta+ArrowRight' : 'End'
const SELECT_LINE_END = process.platform === 'darwin' ? 'Meta+Shift+ArrowRight' : 'Shift+End'
interface DraftOptions {
prompt?: string
@@ -185,11 +190,8 @@ async function initialize(page: Page, prompt: string, workspaceId?: string, clea
await startSession(page, workspaceId, { prompt, clearPreviousDraft })
}
async function openWorkspaceSession(page: Page, workspace: { readonly title: string }, sessionId: SessionId) {
const row = page.getByRole('tree', { name: 'Sessions' }).getByText(workspace.title, { exact: true })
.locator('xpath=ancestor::*[@role="treeitem"][1]')
await row.hover()
await row.getByRole('button', { name: `New session in ${workspace.title}`, exact: true }).click()
async function reopenWorkspaceDraft(page: Page, workspace: { readonly id: string }, sessionId: SessionId) {
await startSession(page, workspace.id, { clearPreviousDraft: false })
await expect.poll(() => selectedSession(page), SETTLE).toBe(sessionId)
}
@@ -270,7 +272,7 @@ function structuredDraft(sessionId: SessionId, title: string): DraftSnapshot {
return { text, references }
}
it('preserves drafts through real New Session navigation and treats empty prompts according to clearPreviousDraft', async () => {
it('preserves drafts through explicit Workspace draft navigation and treats empty prompts according to clearPreviousDraft', async () => {
const { scaffold, page, console, first, firstId, second } = await launchDraftFixture()
const initial = { text: '纯文字 🧭\n第二行 e\u0301', references: [] }
await initialize(page, initial.text, second.id)
@@ -279,19 +281,19 @@ it('preserves drafts through real New Session navigation and treats empty prompt
expect(secondId).not.toBe(firstId)
await assertDraft(page, secondId, initial)
await openWorkspaceSession(page, first, firstId)
await openWorkspaceSession(page, second, secondId)
await reopenWorkspaceDraft(page, first, firstId)
await reopenWorkspaceDraft(page, second, secondId)
await assertDraft(page, secondId, initial)
await openWorkspaceSession(page, first, firstId)
await reopenWorkspaceDraft(page, first, firstId)
await initialize(page, '已有草稿不能被覆盖', second.id)
await assertDraft(page, secondId, initial)
expect(second.sessionIds).toEqual([secondId])
await openWorkspaceSession(page, first, firstId)
await reopenWorkspaceDraft(page, first, firstId)
await initialize(page, '', second.id, false)
await assertDraft(page, secondId, initial)
await initialize(page, '', undefined, true)
await assertDraft(page, secondId, EMPTY)
await openWorkspaceSession(page, first, firstId)
await reopenWorkspaceDraft(page, first, firstId)
await initialize(page, '', second.id, false)
await assertDraft(page, secondId, EMPTY)
const replacement = { text: '显式替换 🧪', references: [] }
@@ -302,8 +304,8 @@ it('preserves drafts through real New Session navigation and treats empty prompt
await startSession(page, undefined, { clearPreviousDraft: true })
await assertDraft(page, secondId, EMPTY)
for (let round = 0; round < 2; round++) {
await openWorkspaceSession(page, first, firstId)
await openWorkspaceSession(page, second, secondId)
await reopenWorkspaceDraft(page, first, firstId)
await reopenWorkspaceDraft(page, second, secondId)
await assertDraft(page, secondId, EMPTY)
}
await page.reload({ waitUntil: 'load' })
@@ -379,7 +381,7 @@ it('restores repeated file, folder and Session capsules across edits, Workspace
],
}
`)
await openWorkspaceSession(page, first, firstId)
await reopenWorkspaceDraft(page, first, firstId)
await assertDraft(page, firstId, firstDraft)
const drafts = [
@@ -388,10 +390,10 @@ it('restores repeated file, folder and Session capsules across edits, Workspace
]
for (let round = 0; round < 3; round++) {
for (const item of drafts) {
await openWorkspaceSession(page, item.workspace, item.id)
await reopenWorkspaceDraft(page, item.workspace, item.id)
await assertDraft(page, item.id, item.draft)
await composer(page).click()
await page.keyboard.press('ControlOrMeta+End')
await page.keyboard.press(EDITOR_END)
const suffix = ` · 编辑${round} 🧪`
await page.keyboard.insertText(suffix)
item.draft = { ...item.draft, text: item.draft.text + suffix }
@@ -401,7 +403,7 @@ it('restores repeated file, folder and Session capsules across edits, Workspace
await page.reload({ waitUntil: 'load' })
await assertDraft(page, secondId, drafts[1]!.draft)
for (const item of drafts) {
await openWorkspaceSession(page, item.workspace, item.id)
await reopenWorkspaceDraft(page, item.workspace, item.id)
await assertDraft(page, item.id, item.draft)
}
await composer(page).locator('[data-composer-chip]').first().click()
@@ -411,9 +413,9 @@ it('restores repeated file, folder and Session capsules across edits, Workspace
await startSession(page, undefined, { clearPreviousDraft: true })
await assertDraft(page, secondId, EMPTY)
for (let round = 0; round < 2; round++) {
await openWorkspaceSession(page, first, firstId)
await reopenWorkspaceDraft(page, first, firstId)
await assertDraft(page, firstId, drafts[0]!.draft)
await openWorkspaceSession(page, second, secondId)
await reopenWorkspaceDraft(page, second, secondId)
await assertDraft(page, secondId, EMPTY)
}
await page.reload({ waitUntil: 'load' })
@@ -435,7 +437,7 @@ it('reads a legacy string from the existing conversation key and saves ordinary
[...element.children].map(paragraph => paragraph.textContent).join('\n')), SETTLE).toBe(legacy)
expect(await composer(page).locator('[data-composer-chip]').count()).toBe(0)
await composer(page).click()
await page.keyboard.press('ControlOrMeta+End')
await page.keyboard.press(EDITOR_END)
await page.keyboard.insertText(',继续编辑')
await assertDraft(page, firstId, { text: `${legacy},继续编辑`, references: [] })
await assertUnsubmitted(scaffold, [firstId])
@@ -496,8 +498,8 @@ it('rematches the current draft after a delayed real skills catalog without repl
await assertDraft(page, firstId, initial)
expect(await composer(page).locator('[data-composer-text-ref]').count()).toBe(0)
await composer(page).click()
await page.keyboard.press('ControlOrMeta+Home')
await page.keyboard.press('End')
await page.keyboard.press(EDITOR_START)
await page.keyboard.press(LINE_END)
await expect.poll(() => captured?.request ?? '', SETTLE).toContain(firstId)
expect(captured?.status).toBe(200)
expect(captured?.body).toContain('draft-late-original')
@@ -506,15 +508,15 @@ it('rematches the current draft after a delayed real skills catalog without repl
const chip = await composer(page).locator('[data-composer-chip]').elementHandle()
if (chip === null) throw new Error('The initialized file capsule is missing')
await composer(page).click()
await page.keyboard.press('ControlOrMeta+Home')
await page.keyboard.press('Shift+End')
await page.keyboard.press(EDITOR_START)
await page.keyboard.press(SELECT_LINE_END)
await page.keyboard.insertText(currentPrefix.trimEnd())
const edited: DraftSnapshot = {
text: `${currentPrefix}${file} abcdef`,
references: [{ ...initial.references[0]!, offset: currentPrefix.length }],
}
await assertDraft(page, firstId, edited)
await page.keyboard.press('ControlOrMeta+End')
await page.keyboard.press(EDITOR_END)
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('Shift+ArrowLeft')
@@ -559,9 +561,9 @@ it('carries all reference capsules through the real Workspace picker and leaves
await assertDraft(page, secondId, draft)
await expect.poll(() => storedDraft(page, firstId), SETTLE).toEqual(EMPTY)
await openWorkspaceSession(page, first, firstId)
await reopenWorkspaceDraft(page, first, firstId)
await assertDraft(page, firstId, EMPTY)
await openWorkspaceSession(page, second, secondId)
await reopenWorkspaceDraft(page, second, secondId)
await assertDraft(page, secondId, draft)
await page.reload({ waitUntil: 'load' })
await assertDraft(page, secondId, draft)
@@ -615,7 +617,7 @@ it('applies a parameter transition matrix to one reusable target without changin
await assertDraft(page, secondId, transition.restore)
}
if (transition.target === 'explicit') {
await openWorkspaceSession(page, first, firstId)
await reopenWorkspaceDraft(page, first, firstId)
await assertDraft(page, firstId, source)
}
await startSession(page, transition.target === 'explicit' ? second.id : undefined, transition.options)
@@ -627,9 +629,9 @@ it('applies a parameter transition matrix to one reusable target without changin
await assertDraft(page, secondId, transition.expected, transition.name)
}
}
await openWorkspaceSession(page, first, firstId)
await reopenWorkspaceDraft(page, first, firstId)
await assertDraft(page, firstId, source)
await openWorkspaceSession(page, second, secondId)
await reopenWorkspaceDraft(page, second, secondId)
await assertDraft(page, secondId, EMPTY)
await assertUnsubmitted(scaffold, [firstId, secondId])
expect(console.pageErrors).toEqual([])
@@ -713,16 +715,16 @@ it.each(['older-first', 'newer-first'] as const)(
expect(await storedDraft(page, secondId)).toBeNull()
expect(await storedDraft(page, firstId)).toEqual(source)
await openWorkspaceSession(page, second, secondId)
await reopenWorkspaceDraft(page, second, secondId)
await expect.poll(() => composer(page).textContent(), SETTLE).toBe('')
expect(await composer(page).locator('[data-composer-chip]').count()).toBe(0)
await openWorkspaceSession(page, third, thirdId)
await reopenWorkspaceDraft(page, third, thirdId)
await assertDraft(page, thirdId, latest)
await openWorkspaceSession(page, first, firstId)
await reopenWorkspaceDraft(page, first, firstId)
await assertDraft(page, firstId, source)
await page.reload({ waitUntil: 'load' })
await assertDraft(page, firstId, source)
await openWorkspaceSession(page, third, thirdId)
await reopenWorkspaceDraft(page, third, thirdId)
await assertDraft(page, thirdId, latest)
await assertUnsubmitted(scaffold, [firstId, secondId, thirdId])
expect(console.pageErrors).toEqual([])
+7 -41
View File
@@ -8,7 +8,7 @@
// intake chain (paste → ordered thumbnail rail → image-only send enablement → remove).
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { expect, it } from 'vitest'
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
import { createAssembledSessionComposer, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
installAssembledBootEnv()
@@ -79,22 +79,11 @@ it('renders the history image pair through the authorized attachment route and o
})
it('accepts pasted images into the composer rail in order and removes them', async () => {
mountAssembledApp()
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
if (start === null) throw new Error('fixture Workspace new-session action missing')
fireEvent.click(start)
const remote = mountAssembledApp()
// Image-only send arming is pinned at package level (input-bar.spec.tsx);
// this assembled lane pins the intake chain over the built graph.
const textarea = await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
}, { timeout: 10_000 })
const textarea = await createAssembledSessionComposer(remote)
const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' })
fireEvent.paste(textarea, {
clipboardData: {
@@ -153,19 +142,8 @@ it('accepts pasted images into the composer rail in order and removes them', asy
})
it('accepts a whole-page drop under the limits-labeled overlay and refuses an over-limit batch at intake', async () => {
mountAssembledApp()
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
if (start === null) throw new Error('fixture Workspace new-session action missing')
fireEvent.click(start)
const textarea = await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
}, { timeout: 10_000 })
const remote = mountAssembledApp()
const textarea = await createAssembledSessionComposer(remote)
// A file drag anywhere over the page raises the full-viewport overlay whose
// desc line carries the projected limits — copy that can only render after
@@ -210,20 +188,8 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
})
it('renders a host dimension rejection with the projected 2000px limit', async () => {
mountAssembledApp({ remote: { rejectPrompt: true } })
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
if (start === null) throw new Error('fixture Workspace new-session action missing')
fireEvent.click(start)
const textarea = await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
}, { timeout: 10_000 })
const remote = mountAssembledApp({ remote: { rejectPrompt: true } })
const textarea = await createAssembledSessionComposer(remote)
const image = new File([new Uint8Array([137, 80, 78, 71])], 'too-wide.png', { type: 'image/png' })
fireEvent.paste(textarea, {
clipboardData: {
+10 -1
View File
@@ -152,10 +152,19 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
await details.waitFor()
await page.mouse.move(700, 100)
const previousSessionIds = [...workspace.sessionIds]
await bind('New Session')
const newSessionCreated = page.waitForResponse('**/api/session/create')
await page.keyboard.press('Meta+Shift+Comma')
const newSessionResponse = await newSessionCreated
const newSessionBody = await newSessionResponse.json() as { result: { ok: boolean; value: { sessionId: string } } }
expect(newSessionBody.result.ok).toBe(true)
const newSessionId = newSessionBody.result.value.sessionId
expect(previousSessionIds).not.toContain(newSessionId)
await page.locator(`[data-conversation-session="${newSessionId}"]`).waitFor()
await expect.poll(() => page.getByRole('treeitem', { selected: true }).textContent()).toContain('New Session')
expect(workspace.sessionIds.length).toBe(1)
await expect.poll(() => workspace.sessionIds).toHaveLength(previousSessionIds.length + 1)
expect(workspace.sessionIds).toEqual(expect.arrayContaining([...previousSessionIds, newSessionId]))
await bind('Open locally')
await page.keyboard.press('Meta+Shift+Comma')
+3 -13
View File
@@ -8,23 +8,13 @@
// path here is the production correlation, not a test hook.
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { expect, it } from 'vitest'
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
import { createAssembledSessionComposer, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
installAssembledBootEnv()
it('paints the submission echo on the send keystroke and swaps it for the durable node', async () => {
mountAssembledApp()
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
if (start === null) throw new Error('fixture Workspace new-session action missing')
fireEvent.click(start)
const composer = await waitFor(() => {
const surface = document.querySelector<HTMLElement>('[data-composer-input]')
if (surface === null) throw new Error('composer surface missing')
return surface
}, { timeout: 10_000 })
const remote = mountAssembledApp()
const composer = await createAssembledSessionComposer(remote)
const image = new File([new Uint8Array([137, 80, 78, 71])], 'echoed.png', { type: 'image/png' })
fireEvent.paste(composer, {
clipboardData: {
+10 -1
View File
@@ -743,11 +743,20 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
await page.getByRole('button', { name: 'View options', exact: true }).click()
await page.getByRole('menuitem', { name: 'Workspace Tree', exact: true }).click()
await section.getByText('project-two', { exact: true }).waitFor()
const previousParentSessionIds = [...parentWorkspace.sessionIds]
const newParentSession = page.waitForResponse('**/api/session/create')
await clickHoverAction(parent, 'New session in folder-group')
const createdResponse = await newParentSession
const createdBody = await createdResponse.json() as { result: { ok: boolean; value: { sessionId: string } } }
expect(createdBody.result.ok).toBe(true)
const createdSessionId = createdBody.result.value.sessionId
expect(previousParentSessionIds).not.toContain(createdSessionId)
await page.locator(`[data-conversation-session="${createdSessionId}"]`).waitFor()
await expect.poll(() => section.locator('[aria-selected="true"]').evaluate(row =>
row.closest('[class*="groupSection"]')?.querySelector('[role="treeitem"]')?.textContent,
), { timeout: 10_000 }).toBe('folder-group')
expect(parentWorkspace.sessionIds).toHaveLength(1)
await expect.poll(() => parentWorkspace.sessionIds).toHaveLength(previousParentSessionIds.length + 1)
expect(parentWorkspace.sessionIds).toEqual(expect.arrayContaining([...previousParentSessionIds, createdSessionId]))
expect([...childWorkspace.sessionIds]).toEqual(childSessionIds)
expect(tripwire.pageErrors).toEqual([])
})
@@ -33,14 +33,14 @@
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
/deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers:
en: dd0a7799c76c0f94
zh: 48d1cdacc9168aa2
en: 04956ad6ba31cc86
zh: 0e5fb72cfa932d25
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
en: 721d5a49fe231191
zh: 3ef1dd720219e00e
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/the-directory-flow-hole:
en: 978415ec6b2e0cc0
zh: 0d5c705ff34fb1da
en: 868904dbfb5713f8
zh: 225906bfef7398bf
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions:
en: a3b80d0e844d2929
zh: 85fe5dfb2968b491
+3 -3
View File
@@ -69,11 +69,11 @@ Grouped and flat Session rows show a clock mark when the Session has active sche
-----
`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` opens its result only if no later navigation has superseded the request; New Session uses `openWorkspace`. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` opens a Workspace blank only if no later navigation has superseded the request; an explicit New Session creates a fresh Session. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
Navigation and startup restoration obtain the selected Session's projections from its `follow`; neither issues a separate projection refresh for that Session or its parent.
New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request: an explicit New Session or hero Workspace pick shows the failure as a transient notice quoting the Host's code and message (for example a preset that fails to mount) or, for a failure that is not a Host refusal, that failure's own message; a request a later navigation or owner disposal superseded raises no notice, and startup restoration reports only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection.
Workspace reopening and explicit draft preparation try to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request: an explicit New Session or hero Workspace pick shows the failure as a transient notice quoting the Host's code and message (for example a preset that fails to mount) or, for a failure that is not a Host refusal, that failure's own message; a request a later navigation or owner disposal superseded raises no notice, and startup restoration reports only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection.
Once both Workspace and Session startup baselines are ready, an empty installation calls `workspaces.initializeDefault` and creates or reuses its blank Session. The composer becomes editable when that Session is selected; no message is submitted automatically. Later navigation or owner disposal prevents startup from selecting its result. Ineligible first use leaves the folder picker available without an error. Default Workspace creation failure shows a transient notice directing the user to Choose workspace, and is not retried until the next startup. Session creation failures use the ordinary restoration error handling. The registered Workspace remains available if Session creation or later submission fails.
@@ -93,7 +93,7 @@ Client callers can pass `prompt` and `clearPreviousDraft` in the second argument
### The directory-flow hole
Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the `-native` backend's renderless OS-chooser driver, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied; an empty hole means the composition has no picking affordance. This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed.
Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the `-native` backend's renderless OS-chooser driver, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied; an empty hole means the composition has no picking affordance. The sidebar Add workspace action preserves an existing blank Session and its draft. This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed.
### Session row actions
+3 -3
View File
@@ -69,11 +69,11 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
-----
`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。`forkSession(id)` 创建子会话,不导航,也不替代尚未完成的导航。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 仅在请求未被后续导航替代时打开工作区空白 Session;显式新会话操作会创建新的 Session。`forkSession(id)` 创建子会话,不导航,也不替代尚未完成的导航。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
导航和启动恢复通过所选 Session 的 `follow` 获取投影,不会另行刷新该 Session 或其父会话的投影。
新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败),非 Host 拒绝的失败则显示其自身消息;已被后续导航或 owner 销毁取代的请求不弹提示,启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。
重新打开工作区和显式准备草稿尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败),非 Host 拒绝的失败则显示其自身消息;已被后续导航或 owner 销毁取代的请求不弹提示,启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。
Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `workspaces.initializeDefault`,创建或复用其空白 Session。选中该 Session 后输入框才可编辑,不会自动提交消息。后续导航或所属上下文销毁会阻止启动流程选中其结果。不符合首次使用条件时仍可选择文件夹,不显示错误。默认工作区创建失败时显示短暂提示,引导用户通过“选择工作区”选择文件夹,直到下次启动才重试。Session 创建失败沿用普通的恢复错误处理。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。
@@ -93,7 +93,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works
### 目录流子 slot
每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——`-native` 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的**添加工作区…** 操作仅在当前界面的 slot 被占用时渲染;slot 为空意味着该组合没有目录选择能力。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace。
每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——`-native` 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的**添加工作区…** 操作仅在当前界面的 slot 被占用时渲染;slot 为空意味着该组合没有目录选择能力。侧栏的添加工作区操作保留已有空白 Session 及其草稿。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace。
### Session 行 action
@@ -52,6 +52,7 @@ import type { SessionActivity, WorkspaceId, WorkspaceView } from '@deepseek-ai/d
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { WorkspaceShortcutState } from '../shortcuts.ts'
import type { StartSessionOptions } from '../navigation.ts'
import type { createWorkspaceViewStore } from '../stores.ts'
/**
@@ -235,11 +236,11 @@ export type WorkspaceBrowserInjected = {
/** Dismiss the shortcut's fork-failure notification. */
dismissForkError: () => void
/**
* Start a New Session in a Workspace: reuse-or-create its blank session and
* open it; without an explicit workspace, inherit the current Session
* Workspace, then the recent Workspace, or clear into the New Session view.
* Create a fresh Session, or preserve a reusable blank with explicit draft options.
* Without an explicit Workspace, inherit the current Session Workspace,
* then the recent Workspace, or clear into the New Session view.
*/
startSession: (workspaceId?: WorkspaceId) => void
startSession: (workspaceId?: WorkspaceId, options?: StartSessionOptions) => void
/** Open a real Session. */
open: (sessionId: SessionId) => void
/**
@@ -231,7 +231,7 @@ export function apply(ctx: Context): void {
const browserInjected = (): WorkspaceBrowserInjected => ({
// Explicit group actions keep their target; unscoped New Session inherits
// the current Session Workspace before the recent-Workspace fallback.
startSession: (workspaceId) => { uiWorkspace.startSession(workspaceId) },
startSession: (workspaceId, options) => { uiWorkspace.startSession(workspaceId, options) },
open: openSession,
searchSessions,
searchResultLimit: sessions.searchResultLimit,
@@ -1315,7 +1315,7 @@ export function WorkspaceBrowser({
side="right"
onPick={(workspaceId) => {
closeAddWorkspace()
startSession(workspaceId)
startSession(workspaceId, { clearPreviousDraft: false })
}}
onClose={() => { closeAddWorkspace() }}
/>
@@ -530,9 +530,11 @@ describe('ui-workspace apply', () => {
const browser = faceOf(b.slots.entries('sidebar.workspaces')[0]!) as WorkspaceBrowserInjected
// The browser share delegates to the shared Session navigation action.
browser.startSession('ws' as never)
expect(startSession).toHaveBeenLastCalledWith('ws')
expect(startSession).toHaveBeenLastCalledWith('ws', undefined)
browser.startSession()
expect(startSession).toHaveBeenLastCalledWith(undefined)
expect(startSession).toHaveBeenLastCalledWith(undefined, undefined)
browser.startSession(undefined, { clearPreviousDraft: false })
expect(startSession).toHaveBeenLastCalledWith(undefined, { clearPreviousDraft: false })
browser.open('session' as never)
expect(b.retain).toHaveBeenCalledWith('session', { source: 'mainView' })
const signal = new AbortController().signal
@@ -2417,7 +2417,7 @@ describe('Workspace tree grouping', () => {
const child = { ...workspace('child', ['child-session'], 'Child'), path: '/projects/team/child' }
const section = (title: string) => screen.getByText(title).closest<HTMLElement>('[class*="groupSection"]')!
it('adopts a parent directory and opens its Session without confirmation', async () => {
it('adopts a parent directory and preserves its existing draft without confirmation', async () => {
const b = mount({
useWorkspaces: hook(workspaceState([child])),
createWorkspace: vi.fn(async () => root),
@@ -2426,7 +2426,7 @@ describe('Workspace tree grouping', () => {
})
fireEvent.click(screen.getByRole('button', { name: '添加工作区' }))
fireEvent.click(screen.getByRole('button', { name: 'Pick directory' }))
await waitFor(() => { expect(b.props.startSession).toHaveBeenCalledWith(wid('root')) })
await waitFor(() => { expect(b.props.startSession).toHaveBeenCalledWith(wid('root'), { clearPreviousDraft: false }) })
expect(b.props.createWorkspace).toHaveBeenCalledWith({ path: '/projects' })
expect(screen.queryByRole('dialog')).toBeNull()
rerender(b, { useWorkspaces: hook(workspaceState([child, root])) })
@@ -746,7 +746,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:117',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:118',
},
{
key: 'conversation.input.activity',
@@ -3834,7 +3834,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.session.row.hover\', () => ctx.slots.register(\n { name: \'sidebar.session.row.hover\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:134',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:135',
},
{
key: 'sidebar.session.row.leading',
@@ -3886,7 +3886,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.session.row.leading\', () => ctx.slots.register(\n { name: \'sidebar.session.row.leading\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:129',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:130',
},
{
key: 'sidebar.settings',
@@ -4009,7 +4009,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.directoryFlow\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:119',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:120',
},
{
key: 'sidebar.workspaces.session.menu.item',
@@ -4064,7 +4064,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n const copyLabel = \'Copy Session ID\' // Localize in the contributing package.\n ctx.slots.inject(\'sidebar.workspaces.session.menu.item\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.session.menu.item\', id: \'copy-session-id\', order: 500 },\n ({ sessionId, useMenuOpenState }) => {\n const [, setMenuOpen] = useMenuOpenState()\n return React.createElement(\n \'button\',\n { type: \'button\', role: \'menuitem\', onClick: () => { setMenuOpen(false); void navigator.clipboard.writeText(sessionId) } },\n copyLabel,\n )\n },\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:166',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:167',
},
{
key: 'sidebar.workspaces.session.row.action',
@@ -4117,7 +4117,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.session.row.action\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.session.row.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:184',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:185',
},
{
key: 'tool.call.images',