fix(ui-workspace): unify shortcut fork toast notifications
This commit is contained in:
1 parent
38c700f08e
commit
8423a388ec
11 files changed
+131
-46
No files matched your search
@@ -59,7 +59,7 @@ Archive commits without a confirmation dialog for a quiet Session and retains th
|
||||
|
||||
Session update times use the tertiary label color, including on archived rows. A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start.
|
||||
|
||||
The keyboard reference exposes New Session, Search sessions, Add workspace, Rename session, Fork session, and Archive session. Desktop defaults use the platform's primary modifier, with Mod+K for search; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves these commands unbound until configured. Button tooltips and Session row menus display the effective bindings. Clicking a menu item targets that row; pressing its shortcut targets the main Session. Desktop and Windows/macOS Web rename use Mod+Alt+G, leaving Mod+Shift+R to browser reload. Rename requires a nonblank main Conversation with no modal obscuring it; a retained Session behind another main panel is unavailable. Other Windows and macOS Desktop bindings also execute from terminal input and modal dialogs; other environments follow the command's region and modal restrictions. Search and rename requests belong to this package; input drafts remain in the browser. The directory picker rejects another open while selection or Workspace adoption is pending. Fork captures the retained main-view Session, for which navigation has requested history opening, and keeps automatic migration. It uses the row action's Host operation to select its last completed turn, without loading older Client history. Missing or blank Sessions are unavailable; the Host rejects a source with no completed turn. A rejected shortcut fork preserves the current selection, displays a localized notification, and can be retried; unexpected failures also retain diagnostic logging.
|
||||
The keyboard reference exposes New Session, Search sessions, Add workspace, Rename session, Fork session, and Archive session. Desktop defaults use the platform's primary modifier, with Mod+K for search; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves these commands unbound until configured. Button tooltips and Session row menus display the effective bindings. Clicking a menu item targets that row; pressing its shortcut targets the main Session. Desktop and Windows/macOS Web rename use Mod+Alt+G, leaving Mod+Shift+R to browser reload. Rename requires a nonblank main Conversation with no modal obscuring it; a retained Session behind another main panel is unavailable. Other Windows and macOS Desktop bindings also execute from terminal input and modal dialogs; other environments follow the command's region and modal restrictions. Search and rename requests belong to this package; input drafts remain in the browser. The directory picker rejects another open while selection or Workspace adoption is pending. Fork captures the retained main-view Session, for which navigation has requested history opening, and keeps automatic migration. It uses the row action's Host operation to select its last completed turn, without loading older Client history. Missing or blank Sessions are unavailable; the Host rejects a source with no completed turn. A rejected shortcut fork preserves the current selection, displays a localized notification, and can be retried; unexpected failures also retain diagnostic logging. Its notification shares the frame-wide Workspace notice channel: only the latest notice remains visible, and removing the browsing region does not dismiss it.
|
||||
|
||||
### Pending interactions
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ Session 行内的 Rename 操作打开一个以该行显示标题预填的对话
|
||||
|
||||
会话更新时间使用 tertiary 文本色,包括已归档行。标题宽于所在行时,静止状态以省略号裁切。把指针停在行上,标题会滚动到远端——例如 fork 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。
|
||||
|
||||
快捷键速查提供新建会话、搜索会话、添加工作区、重命名会话、分叉会话和归档会话。桌面默认使用平台的主修饰键,搜索为 Mod+K;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 在用户配置前不绑定这些命令。按钮提示和会话行菜单显示当前有效绑定。点击菜单项操作该行,按快捷键操作主会话。Desktop 和 Windows/macOS Web 的重命名均使用 Mod+Alt+G,将 Mod+Shift+R 留给浏览器刷新。重命名要求主区域正在显示非空会话,且没有模态对话框遮挡;切换到其他主面板后,后台保留的会话不可作为重命名目标。其他 Windows 和 macOS Desktop 绑定也可从终端输入区域和模态对话框中执行;其他环境遵循命令的区域和模态限制。搜索和重命名的打开请求归本包管理,输入草稿保留在浏览器中。目录选择或工作区接纳尚未结束时,目录选择器拒绝重复打开。分叉捕获主视图已持有并发起历史打开的会话,保留自动迁移;它使用与行菜单相同的 Host 操作选择最近已完成轮次,不读取更早的 Client 历史。没有会话或会话为空时不可用;没有已完成轮次的源会话由 Host 拒绝。快捷键分叉被拒绝时保留当前选择、显示本地化提示,并允许重试;非预期失败还会保留诊断日志。
|
||||
快捷键速查提供新建会话、搜索会话、添加工作区、重命名会话、分叉会话和归档会话。桌面默认使用平台的主修饰键,搜索为 Mod+K;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 在用户配置前不绑定这些命令。按钮提示和会话行菜单显示当前有效绑定。点击菜单项操作该行,按快捷键操作主会话。Desktop 和 Windows/macOS Web 的重命名均使用 Mod+Alt+G,将 Mod+Shift+R 留给浏览器刷新。重命名要求主区域正在显示非空会话,且没有模态对话框遮挡;切换到其他主面板后,后台保留的会话不可作为重命名目标。其他 Windows 和 macOS Desktop 绑定也可从终端输入区域和模态对话框中执行;其他环境遵循命令的区域和模态限制。搜索和重命名的打开请求归本包管理,输入草稿保留在浏览器中。目录选择或工作区接纳尚未结束时,目录选择器拒绝重复打开。分叉捕获主视图已持有并发起历史打开的会话,保留自动迁移;它使用与行菜单相同的 Host 操作选择最近已完成轮次,不读取更早的 Client 历史。没有会话或会话为空时不可用;没有已完成轮次的源会话由 Host 拒绝。快捷键分叉被拒绝时保留当前选择、显示本地化提示,并允许重试;非预期失败还会保留诊断日志。该提示共用应用级 Workspace 通知通道:只显示最新提示,移除浏览区域不会关闭它。
|
||||
|
||||
### 待处理交互
|
||||
|
||||
|
||||
@@ -233,8 +233,6 @@ export type WorkspaceBrowserInjected = {
|
||||
closeAddWorkspace: () => void
|
||||
/** Publish directory interaction occupancy for command availability. */
|
||||
setDirectoryBusy: (busy: boolean) => void
|
||||
/** Dismiss the shortcut's fork-failure notification. */
|
||||
dismissForkError: () => void
|
||||
/**
|
||||
* Create a fresh Session, or preserve a reusable blank with explicit draft options.
|
||||
* Without an explicit Workspace, inherit the current Session Workspace,
|
||||
@@ -298,6 +296,7 @@ export type RowToast =
|
||||
| { kind: 'archivedNotOpenable' }
|
||||
| { kind: 'defaultWorkspaceFailed' }
|
||||
| { kind: 'forkRequiresOpen'; sessionId: SessionId }
|
||||
| { kind: 'shortcutForkFailed'; reason: 'unavailable' | 'failed' }
|
||||
/**
|
||||
* An explicit New Session request that failed. `message` is untranslated:
|
||||
* a Host refusal as `code: message` — the stable code stays in the copy so
|
||||
|
||||
@@ -200,7 +200,8 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
unarchiveSession,
|
||||
})
|
||||
installWorkspaceShortcuts(ctx, uiWorkspace, shortcutControls, archiveInjected().archiveSession)
|
||||
installWorkspaceShortcuts(ctx, uiWorkspace, shortcutControls, archiveInjected().archiveSession,
|
||||
reason => { notify({ kind: 'shortcutForkFailed', reason }) })
|
||||
const archiveConfirmInjected = (): SessionArchiveConfirmInjected => ({
|
||||
hooks: { archiveRequest },
|
||||
settleSessionArchive: () => { archiveRequest.set(null) },
|
||||
@@ -272,7 +273,6 @@ export function apply(ctx: Context): void {
|
||||
requestAddWorkspace: shortcutControls.add,
|
||||
closeAddWorkspace: shortcutControls.closeAdd,
|
||||
setDirectoryBusy: shortcutControls.directoryBusy,
|
||||
dismissForkError: shortcutControls.dismissForkError,
|
||||
hooks: { directoryFlow: browserFlowSource, hostInfo, workspaceShortcuts: shortcutControls.state, shortcuts: ctx.shortcuts.catalog },
|
||||
})
|
||||
const pickerInjected = (): WorkspacePickerInjected => ({
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
IconChevronsUpDownOutlineRegular, IconClockOutlineRegular, IconCloseFillRegular,
|
||||
IconFlatListOutlineRegular, IconFolderCloseRegular, IconProjectAddOutlineRegular,
|
||||
IconQueueOutlineRegular, IconSearchOutlineRegular, IconSlidersTwoOutlineRegular,
|
||||
IconWorkspaceTreeOutlineRegular, Menu, Modal, Toast, Tooltip,
|
||||
IconWorkspaceTreeOutlineRegular, Menu, Modal, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
SessionListState, SessionSearchResultItem,
|
||||
@@ -861,7 +861,6 @@ export function WorkspaceBrowser({
|
||||
requestAddWorkspace,
|
||||
closeAddWorkspace,
|
||||
setDirectoryBusy,
|
||||
dismissForkError,
|
||||
renderSlot,
|
||||
t,
|
||||
}: WorkspaceBrowserProps) {
|
||||
@@ -1479,9 +1478,6 @@ export function WorkspaceBrowser({
|
||||
{deleting && <div className={css.deleteStatus} role="status">{t('delete.pending')}</div>}
|
||||
{deleteError !== null && <div className={css.renameError} role="alert">{deleteError}</div>}
|
||||
</Modal>
|
||||
{shortcutState.forkError !== null && <Toast key={shortcutState.forkError.seq}
|
||||
text={t(shortcutState.forkError.reason === 'unavailable' ? 'shortcut.noCompletedTurn' : 'shortcut.forkFailed')}
|
||||
onDone={dismissForkError} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,15 @@ export function RowActionToast({ useToast, useStore, dismissToast, undoArchive,
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (toast.kind === 'shortcutForkFailed') {
|
||||
return (
|
||||
<Toast
|
||||
key={`toast-${String(toast.seq)}`}
|
||||
text={t(toast.reason === 'unavailable' ? 'shortcut.noCompletedTurn' : 'shortcut.forkFailed')}
|
||||
onDone={dismissToast}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (toast.kind === 'forkRequiresOpen') {
|
||||
return (
|
||||
<Toast
|
||||
@@ -87,7 +96,7 @@ export function RowActionToast({ useToast, useStore, dismissToast, undoArchive,
|
||||
|
||||
/** The copy of one plain warning, keyed by the notice kind the union closes over. */
|
||||
function plainNoticeText(
|
||||
toast: Exclude<RowToastState, { kind: 'archived' | 'stoppedAndArchived' | 'createFailed' | 'forkRequiresOpen' | 'sessionIdCopied' }>,
|
||||
toast: Exclude<RowToastState, { kind: 'archived' | 'stoppedAndArchived' | 'createFailed' | 'forkRequiresOpen' | 'sessionIdCopied' | 'shortcutForkFailed' }>,
|
||||
t: RowToastProps['t'],
|
||||
): string {
|
||||
switch (toast.kind) {
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface WorkspaceShortcutState {
|
||||
readonly addRequested: boolean
|
||||
readonly directoryBusy: boolean
|
||||
readonly renameTarget: { readonly sessionId: SessionId; readonly currentTitle: string } | null
|
||||
readonly forkError: { readonly reason: 'unavailable' | 'failed'; readonly seq: number } | null
|
||||
}
|
||||
|
||||
interface WorkspaceShortcutControls {
|
||||
@@ -24,8 +23,6 @@ interface WorkspaceShortcutControls {
|
||||
directoryBusy: (busy: boolean) => void
|
||||
rename: (sessionId: SessionId, currentTitle: string) => void
|
||||
closeRename: () => void
|
||||
forkFailed: (reason: 'unavailable' | 'failed') => void
|
||||
dismissForkError: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,9 +31,8 @@ interface WorkspaceShortcutControls {
|
||||
*/
|
||||
export function createWorkspaceShortcutControls(): WorkspaceShortcutControls {
|
||||
const state = createSnapshotStore<WorkspaceShortcutState>({
|
||||
searchRequest: 0, addRequested: false, directoryBusy: false, renameTarget: null, forkError: null,
|
||||
searchRequest: 0, addRequested: false, directoryBusy: false, renameTarget: null,
|
||||
})
|
||||
let forkErrorSeq = 0
|
||||
return {
|
||||
state,
|
||||
search: () => { state.set({ ...state.getSnapshot(), searchRequest: state.getSnapshot().searchRequest + 1 }) },
|
||||
@@ -47,11 +43,6 @@ export function createWorkspaceShortcutControls(): WorkspaceShortcutControls {
|
||||
state.set({ ...state.getSnapshot(), renameTarget: { sessionId, currentTitle } })
|
||||
},
|
||||
closeRename: () => { state.set({ ...state.getSnapshot(), renameTarget: null }) },
|
||||
forkFailed: (reason) => {
|
||||
forkErrorSeq += 1
|
||||
state.set({ ...state.getSnapshot(), forkError: { reason, seq: forkErrorSeq } })
|
||||
},
|
||||
dismissForkError: () => { state.set({ ...state.getSnapshot(), forkError: null }) },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +53,14 @@ export function createWorkspaceShortcutControls(): WorkspaceShortcutControls {
|
||||
* @param navigation - session creation and forking from the pointer controls' navigation service.
|
||||
* @param controls - browser-owned opening requests.
|
||||
* @param archiveSession - shared archive action, including running-work confirmation and notices.
|
||||
* @param reportForkFailure - publish a classified failure through the frame-wide notice channel.
|
||||
*/
|
||||
export function installWorkspaceShortcuts(
|
||||
ctx: Context,
|
||||
navigation: Pick<UiWorkspace, 'startSession' | 'forkSession'>,
|
||||
controls: ReturnType<typeof createWorkspaceShortcutControls>,
|
||||
archiveSession: (sessionId: SessionId) => void,
|
||||
reportForkFailure: (reason: 'unavailable' | 'failed') => void,
|
||||
): void {
|
||||
const t = ctx.locale.bind('workspace')
|
||||
const current = () => Object.values(ctx.sessions.list.getSnapshot().byId)
|
||||
@@ -110,7 +103,7 @@ export function installWorkspaceShortcuts(
|
||||
// Client plugin bundles do not share error-class identity.
|
||||
const unavailable = error instanceof Error && error.name === 'SessionForkError'
|
||||
&& (error as SessionForkError).rpcError.code === 'session/fork-unavailable'
|
||||
controls.forkFailed(unavailable ? 'unavailable' : 'failed')
|
||||
reportForkFailure(unavailable ? 'unavailable' : 'failed')
|
||||
if (!unavailable) console.warn('session fork rejected:', error)
|
||||
})
|
||||
} }
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
@@ -54,7 +55,14 @@ const workspaceState = (
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
ctx.provide('shortcuts', { register: () => () => {}, catalog: createSnapshotStore([]) })
|
||||
const commands = new Map<string, ShortcutCommand>()
|
||||
ctx.provide('shortcuts', {
|
||||
register: (command: ShortcutCommand) => {
|
||||
commands.set(command.id, command)
|
||||
return () => { commands.delete(command.id) }
|
||||
},
|
||||
catalog: createSnapshotStore([]),
|
||||
})
|
||||
ctx.provide('uiConversation', {})
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const create = vi.fn(async (input: { name: string } | { path: string }) => ({
|
||||
@@ -149,7 +157,7 @@ async function bench() {
|
||||
locale.setLocale('zh')
|
||||
ctx.provide('locale', locale)
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename,
|
||||
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands,
|
||||
retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, pinSession, unpinSession,
|
||||
workspacesSubscribe, initializeDefault, history, scope, sessionOf,
|
||||
setWorkspaces: (snapshot: WorkspaceSnapshot): void => { workspaceSnapshot = snapshot },
|
||||
@@ -171,6 +179,9 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void {
|
||||
return slots.register({ name: 'root', children } as never, () => null)
|
||||
}
|
||||
|
||||
/** Own the sidebar declaration independently of the frame-wide overlay in lifecycle tests. */
|
||||
function SidebarDeclarationOwner() { return null }
|
||||
|
||||
/** The one entry registered under `id` in a list. */
|
||||
function entry(slots: SlotRegistry, key: RowListName, id: string): StoredEntry {
|
||||
const found = slots.entries(key).find(candidate => candidate.options.id === id)
|
||||
@@ -321,6 +332,57 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.binding).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('publishes shortcut fork failures through the same latest-notice source as row actions', async () => {
|
||||
const b = await bench()
|
||||
onTestFinished(() => b.ctx.fiber.dispose())
|
||||
declare(b.slots, 'shell.overlay')
|
||||
const removeBrowser = b.slots.register({
|
||||
name: 'shell.overlay', id: 'test-sidebar-owner',
|
||||
children: { 'sidebar.workspaces': { kind: 'single', scope: 'root' } },
|
||||
}, SidebarDeclarationOwner)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.setSessions(sessionState([{ ...summary('source', 1), retainedBy: { mainView: 1 } }]))
|
||||
const browser = faceOf(b.slots.entries('sidebar.workspaces')[0]!) as WorkspaceBrowserInjected
|
||||
const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
onTestFinished(() => warning.mockRestore())
|
||||
const invoke = () => {
|
||||
const resolution = b.commands.get('session.fork')!.resolve({ region: 'page', modal: null, target: null })
|
||||
if (resolution.status !== 'handled') throw new Error('the source must be available')
|
||||
resolution.run()
|
||||
}
|
||||
|
||||
browser.notifyArchivedNotOpenable()
|
||||
expect(toast.hooks.toast.getSnapshot()).toMatchObject({ kind: 'archivedNotOpenable' })
|
||||
b.fork.mockRejectedValueOnce(new SessionForkError(
|
||||
new RemoteError('session/fork-unavailable', 'no completed turn', { sessionId: sid('source') }), sid('source'),
|
||||
))
|
||||
invoke()
|
||||
await vi.waitFor(() => {
|
||||
expect(toast.hooks.toast.getSnapshot()).toMatchObject({ kind: 'shortcutForkFailed', reason: 'unavailable' })
|
||||
})
|
||||
expect(b.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('source'), increaseTitle: true })
|
||||
expect(warning).not.toHaveBeenCalled()
|
||||
const unavailable = toast.hooks.toast.getSnapshot()
|
||||
b.fork.mockRejectedValueOnce(new Error('connection closed'))
|
||||
invoke()
|
||||
await vi.waitFor(() => {
|
||||
expect(toast.hooks.toast.getSnapshot()).toMatchObject({ kind: 'shortcutForkFailed', reason: 'failed' })
|
||||
})
|
||||
expect(toast.hooks.toast.getSnapshot()).not.toBe(unavailable)
|
||||
expect(warning).toHaveBeenCalledExactlyOnceWith('session fork rejected:', expect.any(Error))
|
||||
const failure = toast.hooks.toast.getSnapshot()
|
||||
removeBrowser()
|
||||
expect(b.slots.entries('sidebar.workspaces')).toEqual([])
|
||||
expect(entry(b.slots, 'shell.overlay', 'workspace.row-toast').component).toBe(RowActionToast)
|
||||
expect(toast.hooks.toast.getSnapshot()).toBe(failure)
|
||||
browser.notifyArchivedNotOpenable()
|
||||
expect(toast.hooks.toast.getSnapshot()).toMatchObject({ kind: 'archivedNotOpenable' })
|
||||
toast.dismissToast()
|
||||
expect(toast.hooks.toast.getSnapshot()).toBeNull()
|
||||
expect(browser).not.toHaveProperty('dismissForkError')
|
||||
})
|
||||
|
||||
it('derives the pinned and archived Sets from the Workspace snapshot, rebuilt only when it changes', async () => {
|
||||
const b = await bench()
|
||||
b.setWorkspaces(workspaceState([workspace('alpha', ['one', 'two'])], [sid('two')], [sid('one')]))
|
||||
|
||||
@@ -603,6 +603,46 @@ describe('RowActionToast', () => {
|
||||
expect(screen.getByRole('alert').textContent).toBe(translate('toast.sessionIdCopyFailed'))
|
||||
})
|
||||
|
||||
it.each([t, tEn])('replaces other notices with plain shortcut fork failures and only keeps the latest', (translate) => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { dismissToast, notify } = toastSurface(undefined, translate)
|
||||
notify({ kind: 'archived', sessionId: sid('one') })
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
notify({ kind: 'shortcutForkFailed', reason: 'unavailable' })
|
||||
const first = screen.getByRole('alert')
|
||||
expect(first.textContent).toBe(translate('shortcut.noCompletedTurn'))
|
||||
expect(first.querySelector('svg')).toBeNull()
|
||||
expect(first.querySelector('button')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(3000) })
|
||||
expect(screen.getByRole('alert')).toBe(first)
|
||||
notify({ kind: 'shortcutForkFailed', reason: 'failed' })
|
||||
const retry = screen.getByRole('alert')
|
||||
expect(retry).not.toBe(first)
|
||||
expect(retry.textContent).toBe(translate('shortcut.forkFailed'))
|
||||
expect(retry.querySelector('svg')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(3999) })
|
||||
expect(screen.getAllByRole('alert')).toEqual([retry])
|
||||
expect(dismissToast).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(dismissToast).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
|
||||
notify({ kind: 'shortcutForkFailed', reason: 'failed' })
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
notify({ kind: 'sessionIdCopied' })
|
||||
const latest = screen.getByRole('alert')
|
||||
expect(latest.textContent).toBe(translate('toast.sessionIdCopied'))
|
||||
act(() => { vi.advanceTimersByTime(3000) })
|
||||
expect(screen.getAllByRole('alert')).toEqual([latest])
|
||||
expect(dismissToast).toHaveBeenCalledOnce()
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('the stopped-and-archived notice offers the same undo and filter actions under its own wording', () => {
|
||||
const { undoArchive, notify } = toastSurface()
|
||||
notify({ kind: 'stoppedAndArchived', sessionId: sid('one') })
|
||||
|
||||
@@ -54,14 +54,16 @@ async function bench(runtime: 'web' | 'desktop' = 'desktop', platform: ShortcutP
|
||||
startSession: vi.fn(), forkSession: vi.fn(async () => 'fork-child' as SessionId), archiveSession: vi.fn(async (_sessionId: SessionId) => {}),
|
||||
}
|
||||
const controls = createWorkspaceShortcutControls()
|
||||
const reportForkFailure = vi.fn()
|
||||
const fiber = ctx.plugin((scoped) => {
|
||||
installWorkspaceShortcuts(scoped, navigation, controls, (id) => { void navigation.archiveSession(id) })
|
||||
installWorkspaceShortcuts(scoped, navigation, controls,
|
||||
(id) => { void navigation.archiveSession(id) }, reportForkFailure)
|
||||
})
|
||||
await fiber.await()
|
||||
const select = (id: string) => { list.set({ ...list.getSnapshot(), byId: {
|
||||
[sid('a')]: row('a', id === 'a'), [sid('b')]: row('b', id === 'b'),
|
||||
} }) }
|
||||
return { ctx, fiber, registry, commands, navigation, controls, list, directory, select, history, loadOlder, panelInfo }
|
||||
return { ctx, fiber, registry, commands, navigation, controls, reportForkFailure, list, directory, select, history, loadOlder, panelInfo }
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
@@ -188,12 +190,11 @@ describe('workspace shortcut ownership', () => {
|
||||
))
|
||||
const invoke = () => b.registry.dispatch(key('KeyF', { alt: true }), context, vi.fn())
|
||||
expect(invoke().status).toBe('handled')
|
||||
await vi.waitFor(() => { expect(b.controls.state.getSnapshot().forkError).toMatchObject({ reason: 'unavailable' }) })
|
||||
await vi.waitFor(() => { expect(b.reportForkFailure).toHaveBeenCalledExactlyOnceWith('unavailable') })
|
||||
expect(warning).not.toHaveBeenCalled()
|
||||
b.controls.dismissForkError()
|
||||
b.navigation.forkSession.mockRejectedValueOnce(new Error('connection closed'))
|
||||
expect(invoke().status).toBe('handled')
|
||||
await vi.waitFor(() => { expect(b.controls.state.getSnapshot().forkError).toMatchObject({ reason: 'failed' }) })
|
||||
await vi.waitFor(() => { expect(b.reportForkFailure).toHaveBeenNthCalledWith(2, 'failed') })
|
||||
expect(warning).toHaveBeenCalledOnce()
|
||||
b.list.set({ ...b.list.getSnapshot(), byId: { [sid('a')]: { ...row('a', true), running: true } } })
|
||||
expect(invoke().status).toBe('handled')
|
||||
|
||||
@@ -11,13 +11,12 @@ import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/c
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import type { DirectoryFlowOwnerProps, WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
|
||||
import { createWorkspaceShortcutControls } from '../src/client/shortcuts.ts'
|
||||
import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts'
|
||||
import { UNGROUPED_KEY } from '../src/client/tree.ts'
|
||||
import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
|
||||
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
|
||||
@@ -114,7 +113,6 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
closeAddWorkspace: controls.closeAdd,
|
||||
setDirectoryBusy: controls.directoryBusy,
|
||||
requestSessionRename: controls.rename,
|
||||
dismissForkError: controls.dismissForkError,
|
||||
wide: true,
|
||||
expandSidebar: vi.fn(),
|
||||
useSessions: hook(sessionState([])),
|
||||
@@ -151,19 +149,6 @@ function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrows
|
||||
}
|
||||
|
||||
describe('WorkspaceBrowser', () => {
|
||||
it.each([{ messages: en, common: commonEn }, { messages: zh, common: commonZh }])('shows localized fork failures and dismisses them', ({ messages, common }) => {
|
||||
const b = mount({ t: makeTranslate(messages, common) })
|
||||
act(() => { b.controls.forkFailed('unavailable') })
|
||||
const first = screen.getByRole('alert')
|
||||
expect(first.textContent).toBe(messages['shortcut.noCompletedTurn'])
|
||||
act(() => { b.controls.dismissForkError(); b.controls.forkFailed('unavailable') })
|
||||
expect(screen.getByRole('alert')).not.toBe(first)
|
||||
act(() => { b.controls.forkFailed('failed') })
|
||||
expect(screen.getByRole('alert').textContent).toBe(messages['shortcut.forkFailed'])
|
||||
act(() => { b.controls.dismissForkError() })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['workspace', 'flat', 'ungrouped'] as const)('keeps %s recency independent of arrival order and saved manual positions', (mode) => {
|
||||
localStorage.clear()
|
||||
const preferences = createWorkspaceViewStore().create()
|
||||
|
||||
Reference in new issue
Block a user