fix(client): preserve the latest panel and session navigation
This commit is contained in:
1 parent
bc840b483e
commit
8715fd2a87
16 files changed
+279
-38
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -86,9 +86,10 @@ const ABSENT_FILE_UPLOADS = {
|
||||
|
||||
interface WorkspaceNavigation {
|
||||
openSession(sessionId: SessionId): void
|
||||
connectWorkspace(
|
||||
openWorkspace(
|
||||
workspaceId: Parameters<ConversationInjected['selectWorkspace']>[0],
|
||||
): Promise<SessionId>
|
||||
beforeOpen: (sessionId: SessionId) => void,
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
/** 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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
})
|
||||
|
||||
@@ -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<void>
|
||||
/**
|
||||
* 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<void>
|
||||
/**
|
||||
* 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<WorkspaceId, Promise<SessionId>>()
|
||||
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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<typeof vi.fn<ISessions['create']>>
|
||||
readonly open: ReturnType<typeof vi.fn<(id: SessionId) => void>>
|
||||
readonly clear: ReturnType<typeof vi.fn<() => void>>
|
||||
readonly fork = vi.fn<ISessions['fork']>(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<void> {
|
||||
@@ -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<SessionId>()
|
||||
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<SessionId>()
|
||||
const newer = Promise.withResolvers<SessionId>()
|
||||
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<SessionId>()
|
||||
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<SessionId>()
|
||||
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<SessionId>()
|
||||
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')
|
||||
|
||||
Reference in new issue
Block a user