refactor(ui-workspace): share the viewing store with the row toast as a declared store
The archived filter reaches the row toast through the registration's declared store instead of a registrant-owned view hook, per the cross-entry view-state rule. The README names the toast action by its actual label, and the unarchive e2e switches back to Hide archived so it again proves the restored row is an ordinary row in the default view.
This commit is contained in:
1 parent
d6e5dc7748
commit
6ddece2439
10 files changed
+44
-25
No files matched your search
@@ -111,8 +111,10 @@ describe('web e2e: archived sessions are restored from the sidebar filter', () =
|
||||
() => [...scaffold.ctx.workspaceRegistry.archivedSessionIds],
|
||||
{ timeout: 10_000 },
|
||||
).toEqual([])
|
||||
// Back to the default filter: the restored row must be an ordinary row
|
||||
// again, visible without any archived rows in the view.
|
||||
await page.getByRole('button', { name: 'View options' }).click()
|
||||
await page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Hide archived', exact: true }).click()
|
||||
await ungroupedSection()
|
||||
await expect.poll(() => sessionRow.count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
en: 9eab3bfbaf98c987
|
||||
zh: 54e7e141dae5c9e8
|
||||
/deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions:
|
||||
en: ca18d098d32fb755
|
||||
en: d49eb4c9b3158cfe
|
||||
zh: 251be2fe3d1ac857
|
||||
/deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions:
|
||||
en: 9fb88260380bf093
|
||||
|
||||
@@ -49,7 +49,7 @@ Collapsed search is one header action beside the view and add actions: activatin
|
||||
|
||||
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action forks at the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, and Archive are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it.
|
||||
|
||||
Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and — while archived rows are hidden — an "all conversations" action that switches the filter directly; a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position. An empty list shows a centered glyph-over-text placeholder; the Archived-only view names its own emptiness (No archived sessions yet) and adds a View other sessions text action that switches the filter back to Hide archived.
|
||||
Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and — while archived rows are hidden — a "filter archived sessions" action that switches the filter to All conversations (show archived); a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position. An empty list shows a centered glyph-over-text placeholder; the Archived-only view names its own emptiness (No archived sessions yet) and adds a View other sessions text action that switches the filter back to Hide archived.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ 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 { createWorkspaceViewStore } from '../stores.ts'
|
||||
import type { ArchivedFilter } from '../tree.ts'
|
||||
|
||||
/**
|
||||
* Owner share of the directory-flow holes: the complete conversation between
|
||||
@@ -416,8 +415,6 @@ export interface RowToastInjected {
|
||||
hooks: {
|
||||
/** The notice on display, or none. */
|
||||
toast: HostObservable<RowToastState | null>
|
||||
/** The viewing state's archived filter; the archived notice omits its filter action when archived rows are already visible. */
|
||||
view: HostObservable<{ archivedFilter?: ArchivedFilter }>
|
||||
}
|
||||
/** Take the notice down. */
|
||||
dismissToast: () => void
|
||||
@@ -441,10 +438,15 @@ export type SessionArchiveConfirmProps =
|
||||
& Omit<SessionArchiveConfirmInjected, 'hooks'>
|
||||
& PropsHooks<SessionArchiveConfirmInjected['hooks']>
|
||||
|
||||
/** Props of the row toast entry in `shell.overlay`. */
|
||||
/**
|
||||
* Props of the row toast entry in `shell.overlay`. The declared viewing store
|
||||
* carries the archived filter; the archived notice omits its filter action
|
||||
* when archived rows are already visible.
|
||||
*/
|
||||
export type RowToastProps =
|
||||
PropsRuntime<'shell.overlay'>
|
||||
& PropsLocale<'workspace'>
|
||||
& PropsStore<WorkspaceViewStoreHandle>
|
||||
& Omit<RowToastInjected, 'hooks'>
|
||||
& PropsHooks<RowToastInjected['hooks']>
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ export function apply(ctx: Context): void {
|
||||
renameSession,
|
||||
})
|
||||
const rowToastInjected = (): RowToastInjected => ({
|
||||
hooks: { toast: rowToast, view: viewInstance.store },
|
||||
hooks: { toast: rowToast },
|
||||
dismissToast: () => { rowToast.set(null) },
|
||||
undoArchive: unarchiveSession,
|
||||
showArchived: () => { viewInstance.actions.setArchivedFilter('show') },
|
||||
@@ -298,8 +298,10 @@ export function apply(ctx: Context): void {
|
||||
yield ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'workspace.session-archive', locale: NS, inject: archiveConfirmInjected,
|
||||
}, SessionArchiveConfirmDialog)
|
||||
// The toast shares the browser's viewing store: it reads the archived
|
||||
// filter to drop the archived notice's filter action once rows are visible.
|
||||
yield ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'workspace.row-toast', locale: NS, inject: rowToastInjected,
|
||||
name: 'shell.overlay', id: 'workspace.row-toast', locale: NS, store: viewStore, inject: rowToastInjected,
|
||||
}, RowActionToast)
|
||||
})
|
||||
ctx.slots.inject('conversation.hero.workspace', () => ctx.slots.register(
|
||||
|
||||
@@ -897,6 +897,7 @@ export function WorkspaceBrowser({
|
||||
}
|
||||
open(sessionId)
|
||||
}
|
||||
const leaveArchivedOnly = (): void => { actions.setArchivedFilter('default') }
|
||||
const workspaceReady = workspacePhase === 'ready' && workspaceStreamState !== 'loading'
|
||||
const mainSessionId = Object.values(list.byId)
|
||||
.find(session => (session.retainedBy.mainView ?? 0) > 0)?.id
|
||||
@@ -1349,7 +1350,7 @@ export function WorkspaceBrowser({
|
||||
list={list}
|
||||
sessionIds={orderedFlatSessionIds}
|
||||
rowState={rowState}
|
||||
onLeaveArchivedOnly={() => { actions.setArchivedFilter('default') }}
|
||||
onLeaveArchivedOnly={leaveArchivedOnly}
|
||||
workspaceReady={workspaceReady}
|
||||
animationResetKey={`${groupBy}/${orderBy}/${archivedFilter}`}
|
||||
useSessionStatus={useSessionStatus}
|
||||
@@ -1379,7 +1380,7 @@ export function WorkspaceBrowser({
|
||||
setGroupExpanded={actions.setGroupExpanded}
|
||||
setSessionOrder={saveSessionOrder}
|
||||
rowState={rowState}
|
||||
onLeaveArchivedOnly={() => { actions.setArchivedFilter('default') }}
|
||||
onLeaveArchivedOnly={leaveArchivedOnly}
|
||||
startSession={startSession}
|
||||
open={guardedOpen}
|
||||
insertWorkspaceBefore={insertWorkspaceBefore}
|
||||
|
||||
@@ -19,12 +19,12 @@ const LONG_TOAST_HOLD_MS = 6000
|
||||
* are hidden — on a 6 s hold, a refused Session creation with the Host's
|
||||
* reason on the same hold, or a plain warning for a failed pin, an archived
|
||||
* row that was clicked, or default Workspace creation.
|
||||
* @param props - the notice and view hooks, the notice dismissal, the two archived-notice actions, and the locale seat.
|
||||
* @param props - the notice hook, the shared viewing store, the notice dismissal, the two archived-notice actions, and the locale seat.
|
||||
* @returns the notice on display, or null.
|
||||
*/
|
||||
export function RowActionToast({ useToast, useView, dismissToast, undoArchive, showArchived, t }: RowToastProps) {
|
||||
export function RowActionToast({ useToast, useStore, dismissToast, undoArchive, showArchived, t }: RowToastProps) {
|
||||
const toast = useToast(current => current)
|
||||
const archivedRowsVisible = useView(state => (state.archivedFilter ?? 'default') !== 'default')
|
||||
const archivedRowsVisible = useStore(state => (state.archivedFilter ?? 'default') !== 'default')
|
||||
if (toast === null) return null
|
||||
if (toast.kind === 'archived' || toast.kind === 'stoppedAndArchived') {
|
||||
const { sessionId } = toast
|
||||
|
||||
@@ -251,13 +251,17 @@ describe('ui-workspace apply', () => {
|
||||
['workspace.session-archive', undefined, SessionArchiveConfirmDialog, 'workspace'],
|
||||
['workspace.row-toast', undefined, RowActionToast, 'workspace'],
|
||||
])
|
||||
// Only the browser declares the viewing store; its handle hands out the
|
||||
// one instance the injected callbacks write view state through. The
|
||||
// row actions and overlay surfaces declare none.
|
||||
// The browser and the row toast declare the same viewing-store handle,
|
||||
// which hands out one instance: the browser's injected callbacks write
|
||||
// view state through it and the toast reads the archived filter from it.
|
||||
// The row actions and the other overlay surfaces declare none.
|
||||
const browser = b.slots.entries('sidebar.workspaces')[0]!
|
||||
expect(browser.store).toBeDefined()
|
||||
expect(viewInstance(b.slots)).toBe(viewInstance(b.slots))
|
||||
const rowToastEntry = entry(b.slots, 'shell.overlay', 'workspace.row-toast')
|
||||
expect(rowToastEntry.store).toBe(browser.store)
|
||||
for (const registration of [...b.slots.entries(MENU_ITEM), ...b.slots.entries(ROW_ACTION), ...b.slots.entries('shell.overlay')]) {
|
||||
if (registration === rowToastEntry) continue
|
||||
expect(registration.store).toBeUndefined()
|
||||
}
|
||||
// Each share raises its own notices inside its callbacks.
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ForkSessionMenuItem } from '../src/client/session-actions/ForkSession.t
|
||||
import { PinSessionMenuItem, PinSessionRowButton } from '../src/client/session-actions/PinSession.tsx'
|
||||
import { RenameSessionMenuItem, SessionRenameDialog } from '../src/client/session-actions/RenameSession.tsx'
|
||||
import { RowActionToast } from '../src/client/session-actions/RowActionToast.tsx'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { ShortcutRegistry } from '../../shortcuts/src/client/registry.ts'
|
||||
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
@@ -491,7 +492,13 @@ describe('RowActionToast', () => {
|
||||
/** The notice surface over a test-owned notice source; dismissal clears the notice the way apply does. */
|
||||
function toastSurface(viewState: { archivedFilter?: 'default' | 'show' | 'only' } = { archivedFilter: 'default' }) {
|
||||
const toast = createSnapshotStore<RowToastState | null>(null)
|
||||
const view = createSnapshotStore<{ archivedFilter?: 'default' | 'show' | 'only' }>(viewState)
|
||||
const instance = createWorkspaceViewStore().create()
|
||||
// A v5 snapshot hydrates without the filter key; mirror it by dropping the
|
||||
// fresh store's default rather than writing an explicit undefined.
|
||||
const state = { ...instance.store.getSnapshot() }
|
||||
if (viewState.archivedFilter === undefined) delete state.archivedFilter
|
||||
else state.archivedFilter = viewState.archivedFilter
|
||||
const view = createSnapshotStore(state)
|
||||
const dismissToast = vi.fn(() => { toast.set(null) })
|
||||
const undoArchive = vi.fn()
|
||||
const showArchived = vi.fn()
|
||||
@@ -499,7 +506,8 @@ describe('RowActionToast', () => {
|
||||
<RowActionToast
|
||||
{...overlay}
|
||||
useToast={bindSnapshotSelector(toast)}
|
||||
useView={bindSnapshotSelector(view)}
|
||||
useStore={bindSnapshotSelector(view)}
|
||||
actions={instance.actions}
|
||||
dismissToast={dismissToast}
|
||||
undoArchive={undoArchive}
|
||||
showArchived={showArchived}
|
||||
|
||||
@@ -698,7 +698,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:118',
|
||||
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:117',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.activity',
|
||||
@@ -3602,7 +3602,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:135',
|
||||
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:134',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.session.row.leading',
|
||||
@@ -3654,7 +3654,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:130',
|
||||
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:129',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.settings',
|
||||
@@ -3777,7 +3777,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:120',
|
||||
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:119',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.workspaces.session.menu.item',
|
||||
@@ -3832,7 +3832,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:167',
|
||||
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:166',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.workspaces.session.row.action',
|
||||
@@ -3885,7 +3885,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:185',
|
||||
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:184',
|
||||
},
|
||||
{
|
||||
key: 'tool.call.images',
|
||||
|
||||
Reference in new issue
Block a user