diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 91c78811e0..947eb739ed 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -16,6 +16,7 @@ import { apply as applyChat, inject as injectChat, type ChatViewInjected, } from '@deepseek-ai/dsh-client-ui-chat/client' import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { createChatStore } from '../src/client/stores.ts' usePinnedBrowserLanguages('zh-CN') @@ -57,7 +58,10 @@ async function bench() { ) new TestRemote(runtime.ctx, { session: { openWorkspacePath } }) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => ROOT), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(ROOT) + runtime.sessions.open(ROOT) + }), openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const session = sessionFakeFor() diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index 6c1ab9c6a0..ac364e4cb7 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -9,6 +9,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { apply as applyConversation, inject as injectConversation, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -44,7 +45,10 @@ async function bench() { runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) new TestRemote(runtime.ctx, { diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 7827186ede..7eba82cfc0 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -86,9 +86,10 @@ const ABSENT_FILE_UPLOADS = { interface WorkspaceNavigation { openSession(sessionId: SessionId): void - connectWorkspace( + openWorkspace( workspaceId: Parameters[0], - ): Promise + beforeOpen: (sessionId: SessionId) => void, + ): Promise } /** Resolve the session-scoped Conversation action face, failing loud. */ @@ -232,8 +233,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId), }, - selectWorkspace: async (workspaceId) => { - const nextId = await workspaceNavigation.connectWorkspace(workspaceId) + selectWorkspace: workspaceId => workspaceNavigation.openWorkspace(workspaceId, (nextId) => { if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft @@ -253,8 +253,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { } } } - workspaceNavigation.openSession(nextId) - }, + }), }), }, ConversationRoot) diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index aa50a19ba2..c6d6226bfe 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -50,7 +50,11 @@ async function bench() { runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const connectWorkspace = vi.fn(async () => ROOT) runtime.ctx.provide('uiWorkspace', { - connectWorkspace, + openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + const id = await connectWorkspace() + beforeOpen(id) + runtime.sessions.open(id) + }, openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const sessionFake = sessionFakeFor() diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index a71103fb32..3c59deb1c2 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -15,7 +15,10 @@ const SID = 'session-1' as SessionId async function bench(options: { declareConversation?: boolean } = {}) { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: unknown, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index fc4eccb528..573a732780 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -12,6 +12,7 @@ import { import { InputHub } from '../src/client/input/hub.ts' import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). @@ -61,7 +62,10 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { async function bench(opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) @@ -86,7 +90,10 @@ describe('resident composer', () => { it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) @@ -116,7 +123,10 @@ describe('resident composer', () => { it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) @@ -184,7 +194,10 @@ describe('prompt rejection through the assembled composer', () => { it('renders the promptError alert strip and keeps the draft in the machine', async () => { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 83439c9d8e..ffbbd68bb9 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -133,7 +133,8 @@ export function apply(ctx: ClientContext): void { const handle = createLayoutStore() const instance = handle.create() const store: typeof handle = { ...handle, create: () => instance } - const layout = new LayoutController(instance.actions) + const layout = new LayoutController(instance.actions, id => + ctx.slots.entries('main').some(entry => entry.options.key === id)) const retainMainPanels = (): void => { instance.actions.retainMainPanels(ctx.slots.entries('main').flatMap(entry => entry.options.key === undefined ? [] : [entry.options.key])) @@ -158,6 +159,7 @@ export function apply(ctx: ClientContext): void { const disposePanels = ctx.slots.subscribe('main', retainMainPanels) retainMainPanels() return () => { + layout.dispose() disposePanels() disposeRegistration() disposePanelInfo() diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts index 7835f3aa20..14a3924d63 100644 --- a/packages/client/ui-layout/src/client/service.ts +++ b/packages/client/ui-layout/src/client/service.ts @@ -29,8 +29,14 @@ export interface ILayout { /** * Select a global central panel without changing the current Session. * @param panelId - registered main key, or null to show the Conversation. + * @throws if the selected main key is not registered; preserves the current selection. */ selectPanel(panelId: MainPanelId | null): void + /** + * Start an asynchronous navigation, superseding any earlier pending navigation. + * @returns a signal aborted by the next navigation or layout disposal; check it before committing UI state. + */ + beginNavigation(): AbortSignal /** Toggle the sidebar panel (closed ⟷ contract default width). */ toggleSidebar(): void /** @@ -47,14 +53,38 @@ export interface ILayout { /** Cross-plugin panel-action face (ctx.layout). */ export class LayoutController implements ILayout { - /** @param panels - actions of the instance shared with the root entry. */ - constructor(private readonly panels: PanelActions) {} + private navigation = new AbortController() + + /** + * @param panels - actions of the instance shared with the root entry. + * @param hasMainPanel - checks the live main-slot registry for a panel id. + */ + constructor( + private readonly panels: PanelActions, + private readonly hasMainPanel: (id: MainPanelId) => boolean, + ) {} /** Select a global panel or return to the Conversation. */ selectPanel(panelId: MainPanelId | null): void { + if (panelId !== null && !this.hasMainPanel(panelId)) { + throw new Error(`layout.selectPanel: main panel "${panelId}" is not registered`) + } + this.navigation.abort() this.panels.selectPanel(panelId) } + /** @returns the new pending navigation's cancellation signal. */ + beginNavigation(): AbortSignal { + this.navigation.abort() + this.navigation = new AbortController() + return this.navigation.signal + } + + /** Invalidate pending navigations when the layout owner is unloaded. */ + dispose(): void { + this.navigation.abort() + } + /** Toggle the sidebar panel (closed ⟷ contract default width). */ toggleSidebar(): void { this.panels.toggleSidebar() diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index 94124c2805..74c58022c9 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -101,6 +101,7 @@ describe('ui-layout client apply', () => { const instance = handle.create() expect(handle.create()).toBe(instance) const layout = ctx.get('layout') as LayoutController + expect(() => { layout.selectPanel('missing' as MainPanelId) }).toThrow('main panel "missing" is not registered') layout.toggleSidebar() expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0) const host = rendererHost() @@ -113,7 +114,11 @@ describe('ui-layout client apply', () => { expect(panelInfo.getSnapshot()).toEqual({ activePanelId: panelId }) disposePanel() await vi.waitFor(() => { expect(panelInfo.getSnapshot()).toEqual({ activePanelId: null }) }) + expect(() => { layout.selectPanel(panelId) }).toThrow('main panel "panel-a" is not registered') expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0) + const pending = layout.beginNavigation() + await fiber.dispose() + expect(pending.aborted).toBe(true) }) it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => { diff --git a/packages/client/ui-layout/tests/service.client.spec.ts b/packages/client/ui-layout/tests/service.client.spec.ts index 14c3fb479e..7ccadb5c3c 100644 --- a/packages/client/ui-layout/tests/service.client.spec.ts +++ b/packages/client/ui-layout/tests/service.client.spec.ts @@ -18,7 +18,7 @@ function fakePanels(): PanelActions { describe('LayoutController', () => { it('forwards right column transitions to the constructor-supplied actions', () => { const panels = fakePanels() - const service = new LayoutController(panels) + const service = new LayoutController(panels, () => true) service.openRightbar(true, false) service.openRightbar(true, true) @@ -35,7 +35,7 @@ describe('LayoutController', () => { it('can toggle the sidebar immediately after construction', () => { const panels = fakePanels() - const service = new LayoutController(panels) + const service = new LayoutController(panels, () => true) service.toggleSidebar() @@ -45,7 +45,7 @@ describe('LayoutController', () => { it('forwards panel selection and returning to the Conversation without changing geometry', () => { const panels = fakePanels() - const service = new LayoutController(panels) + const service = new LayoutController(panels, () => true) const panelId = 'panel-a' as MainPanelId service.selectPanel(panelId) service.selectPanel(panelId) @@ -61,8 +61,8 @@ describe('LayoutController', () => { it('keeps separately constructed controllers bound to their own instances', () => { const first = fakePanels() const second = fakePanels() - const firstService = new LayoutController(first) - const secondService = new LayoutController(second) + const firstService = new LayoutController(first, () => true) + const secondService = new LayoutController(second, () => true) firstService.toggleSidebar() expect(first.toggleSidebar).toHaveBeenCalledTimes(1) expect(second.toggleSidebar).not.toHaveBeenCalled() @@ -70,4 +70,35 @@ describe('LayoutController', () => { expect(first.closeRightbar).not.toHaveBeenCalled() expect(second.closeRightbar).toHaveBeenCalledTimes(1) }) + + it('rejects an absent main entry without changing selection or cancelling pending navigation', () => { + const panels = fakePanels() + const present = new Set(['panel-a']) + const service = new LayoutController(panels, id => present.has(id)) + service.selectPanel('panel-a' as MainPanelId) + const navigation = service.beginNavigation() + present.delete('panel-a') + expect(() => { service.selectPanel('panel-a' as MainPanelId) }).toThrow('main panel "panel-a" is not registered') + expect(panels.selectPanel).toHaveBeenCalledOnce() + expect(navigation.aborted).toBe(false) + service.selectPanel(null) + expect(navigation.aborted).toBe(true) + }) + + it('supersedes asynchronous navigation on another request, any valid selection, and disposal', () => { + const service = new LayoutController(fakePanels(), () => true) + const first = service.beginNavigation() + const second = service.beginNavigation() + expect(first.aborted).toBe(true) + expect(second.aborted).toBe(false) + service.selectPanel('panel-a' as MainPanelId) + expect(second.aborted).toBe(true) + const repeated = service.beginNavigation() + service.selectPanel('panel-a' as MainPanelId) + expect(repeated.aborted).toBe(true) + const pending = service.beginNavigation() + service.dispose() + expect(pending.aborted).toBe(true) + service.dispose() + }) }) diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 99ee175099..7959b9c60a 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -6,6 +6,7 @@ import { cleanup, fireEvent, waitFor } from '@testing-library/react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { TodoItem } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyChat, inject as injectChat, type ToolResultNode, @@ -79,7 +80,10 @@ async function bench(nodes: ToolResultNode[]) { runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const locale = new LocaleRuntime(runtime.ctx) diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 2f72a5d2b7..67417ba4a3 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup } from '@testing-library/react' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { apply as applyChat, inject as injectChat, type ToolResultNode, } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -66,7 +67,10 @@ async function bench(nodes: ToolResultNode[]) { const sidebarRight = { openResource: vi.fn<(address: string) => void>() } runtime.ctx.provide('sidebarRight', sidebarRight as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const locale = new LocaleRuntime(runtime.ctx) @@ -213,7 +217,10 @@ describe('registrant declaration injection', () => { runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const locale = new LocaleRuntime(runtime.ctx) diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 0633e96b85..9204fe7774 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -115,8 +115,7 @@ export function apply(ctx: Context): void { if (!result.ok) throw new Error(result.error.message) }, forkSession: (sessionId) => { - sessions.fork({ sessionId, increaseTitle: true }) - .then(openSession) + uiWorkspace.forkSession(sessionId) .catch(() => { // Fork or child-rename failure keeps the current selection. }) diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index c9dcc5e984..6d86beb826 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -19,6 +19,19 @@ export interface UiWorkspace { * @param sessionId - listed or retained Session to display. */ openSession(sessionId: SessionId): void + /** + * Connect a Workspace and open its Session unless a later navigation supersedes it. + * @param workspaceId - target Workspace. + * @param beforeOpen - optional synchronous preparation for the selected Session, skipped after supersession. + * @returns completion; a superseded request may create a Session but does not open it. + */ + openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise + /** + * Fork a Session and open the child unless a later navigation supersedes it. + * @param sessionId - source Session. + * @returns completion; a superseded request leaves its child available without selecting it. + */ + forkSession(sessionId: SessionId): Promise /** * Resolve the reusable or newly created blank Session for a Workspace. * @param workspaceId - target Workspace. @@ -76,6 +89,7 @@ export class DirectoryBrowseError extends Error { /** Implements Workspace archive and directory UI operations. */ class UiWorkspaceService extends Service implements UiWorkspace { private readonly connecting = new Map>() + private readonly lifetime = new AbortController() /** * @param ctx - Client root Context. @@ -122,6 +136,20 @@ class UiWorkspaceService extends Service implements UiWorkspace { this.ctx.layout.selectPanel(null) } + async openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise { + const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) + const sessionId = await this.connectWorkspace(workspaceId) + if (navigation.aborted) return + beforeOpen?.(sessionId) + if (!navigation.aborted) this.openSession(sessionId) + } + + async forkSession(sessionId: SessionId): Promise { + const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) + const childId = await this.sessions.fork({ sessionId, increaseTitle: true }) + if (!navigation.aborted) this.openSession(childId) + } + startSession(workspaceId?: WorkspaceId): void { const workspace = this.workspaces.list.getSnapshot() const sessions = this.sessions.list.getSnapshot() @@ -138,8 +166,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { this.ctx.layout.selectPanel(null) return } - void this.connectWorkspace(target).then( - (sessionId) => { this.openSession(sessionId) }, + void this.openWorkspace(target).catch( (reason: unknown) => { console.warn('new session failed:', reason) }, ) } @@ -168,9 +195,8 @@ class UiWorkspaceService extends Service implements UiWorkspace { private watchNavigation(): () => void { let initial: 'waiting' | 'connecting' | 'done' = 'waiting' - let disposed = false const reconcile = (): void => { - if (disposed) return + if (this.lifetime.signal.aborted) return if (this.clearArchivedCurrent()) return if (initial !== 'waiting') return const workspace = this.workspaces.list.getSnapshot() @@ -188,14 +214,14 @@ class UiWorkspaceService extends Service implements UiWorkspace { initial = 'connecting' void this.connectWorkspace(target).then( (sessionId) => { - if (disposed) return + if (this.lifetime.signal.aborted) return if (this.sessions.list.getSnapshot().current === undefined) { this.sessions.open(sessionId) } initial = 'done' }, (reason: unknown) => { - if (disposed) return + if (this.lifetime.signal.aborted) return initial = 'waiting' console.warn('initial workspace selection failed:', reason) }, @@ -205,7 +231,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { const disposeSessions = this.sessions.list.subscribe(reconcile) reconcile() return () => { - disposed = true + this.lifetime.abort() disposeSessions() disposeWorkspaces() } diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index d35e29c81a..8035b099a1 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -22,7 +22,7 @@ async function bench() { const open = vi.fn() const clear = vi.fn() const selectPanel = vi.fn() - ctx.provide('layout', { selectPanel }) + ctx.provide('layout', { selectPanel, beginNavigation: () => new AbortController().signal }) const search = vi.fn(async () => ({ ok: true as const, value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false }, diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 7bc3b537c3..52f106f0f0 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -10,6 +10,8 @@ import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remote import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { SessionId } from '@deepseek-ai/dsh-session/types' +import { LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts' const sid = (id: string): SessionId => SessionId(id) @@ -108,6 +110,7 @@ class FakeSessions { readonly create: ReturnType> readonly open: ReturnType void>> readonly clear: ReturnType void>> + readonly fork = vi.fn(async () => sid('forked')) constructor(initial: SessionListState) { this.list = new MutableSource(initial) @@ -189,8 +192,14 @@ interface BenchOptions { function bench(options: BenchOptions = {}) { const ctx = new Context() - const selectPanel = vi.fn() - ctx.provide('layout', { selectPanel }) + const layout = new LayoutController({ + selectPanel: vi.fn(), retainMainPanels: vi.fn(), + setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(), + setRightbar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn(), + }, () => true) + const selectPanel = vi.spyOn(layout, 'selectPanel') + ctx.provide('layout', layout) + ctx.effect(() => () => { layout.dispose() }) const directoryPicker = new FakeDirectoryPicker() const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending')) const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending')) @@ -200,7 +209,7 @@ function bench(options: BenchOptions = {}) { workspaces, sessions as unknown as ISessions, ) - return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, selectPanel } + return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel } } async function flush(): Promise { @@ -228,6 +237,107 @@ describe('UiWorkspaceService', () => { expect(b.selectPanel).not.toHaveBeenCalled() }) + it('leaves a later panel selection in place when New Session finishes', async () => { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha')]), + }) + const created = Promise.withResolvers() + b.sessions.create.mockReturnValue(created.promise) + const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace') + b.uiWorkspace.startSession(wid('alpha')) + b.layout.selectPanel('panel-a' as MainPanelId) + created.resolve(sid('late')) + await opening.mock.results[0]!.value + expect(b.sessions.open).not.toHaveBeenCalled() + expect(b.selectPanel).toHaveBeenCalledExactlyOnceWith('panel-a') + expect(b.sessions.list.getSnapshot().current).toBe(sid('current')) + }) + + it('opens only the latest Workspace request when creation completes out of order', async () => { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha'), workspace('beta')]), + }) + const older = Promise.withResolvers() + const newer = Promise.withResolvers() + b.sessions.create.mockImplementation(options => options?.workspaceId === wid('alpha') ? older.promise : newer.promise) + const oldDraft = vi.fn() + const newDraft = vi.fn() + const first = b.uiWorkspace.openWorkspace(wid('alpha'), oldDraft) + const second = b.uiWorkspace.openWorkspace(wid('beta'), newDraft) + newer.resolve(sid('newer')) + await second + older.resolve(sid('older')) + await first + expect(oldDraft).not.toHaveBeenCalled() + expect(newDraft).toHaveBeenCalledExactlyOnceWith(sid('newer')) + expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('newer')) + }) + + it('does not move drafts or reopen a Workspace after reselecting the current Session', async () => { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha')]), + }) + const created = Promise.withResolvers() + b.sessions.create.mockReturnValue(created.promise) + const moveDraft = vi.fn() + const pending = b.uiWorkspace.openWorkspace(wid('alpha'), moveDraft) + b.uiWorkspace.openSession(sid('current')) + created.resolve(sid('late')) + await pending + expect(moveDraft).not.toHaveBeenCalled() + expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('current')) + }) + + it('keeps navigation performed by preparation and propagates preparation failures', async () => { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha')]), + }) + await b.uiWorkspace.openWorkspace(wid('alpha'), () => { b.layout.selectPanel('panel-a' as MainPanelId) }) + expect(b.sessions.open).not.toHaveBeenCalled() + await expect(b.uiWorkspace.openWorkspace(wid('alpha'), () => { throw new Error('draft failed') })).rejects.toThrow('draft failed') + expect(b.sessions.open).not.toHaveBeenCalled() + }) + + it('opens a fork only while its navigation is current and preserves fork failures', async () => { + const b = bench() + await b.uiWorkspace.forkSession(sid('source')) + expect(b.sessions.fork).toHaveBeenCalledWith({ sessionId: sid('source'), increaseTitle: true }) + expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('forked')) + const forked = Promise.withResolvers() + b.sessions.fork.mockReturnValueOnce(forked.promise) + const pending = b.uiWorkspace.forkSession(sid('source')) + b.layout.selectPanel('panel-a' as MainPanelId) + forked.resolve(sid('late-fork')) + await pending + expect(b.sessions.open).toHaveBeenCalledOnce() + b.sessions.fork.mockRejectedValueOnce(new Error('fork failed')) + await expect(b.uiWorkspace.forkSession(sid('source'))).rejects.toThrow('fork failed') + expect(b.sessions.open).toHaveBeenCalledOnce() + }) + + it('does not open a pending Workspace or fork after the navigation owner is disposed', async () => { + for (const kind of ['workspace', 'fork'] as const) { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha')]), + }) + const resolved = Promise.withResolvers() + b.sessions.create.mockReturnValue(resolved.promise) + b.sessions.fork.mockReturnValue(resolved.promise) + const pending = kind === 'workspace' + ? b.uiWorkspace.openWorkspace(wid('alpha')) + : b.uiWorkspace.forkSession(sid('current')) + await b.ctx.fiber.dispose() + resolved.resolve(sid('late')) + await pending + expect(b.sessions.open).not.toHaveBeenCalled() + } + }) + it('reuses only an unarchived member blank and coalesces concurrent creation', async () => { const b = bench() const memberBlank = sid('member-blank')