diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index 08c13a208e..48475c7828 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -42,6 +42,7 @@ export interface ISessions { * @param id - session id (must exist in the list; unknown ids fail loud). */ open(id: SessionId): void + openAndWait(id: SessionId): Promise /** * Open a healthy catalog child through its exact direct-parent address. * @param address - catalog-derived parent and child ids. diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 4bad2bb8c2..b5e5501ced 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent/types' import { createSessionControlStream } from './transport.ts' import { ClientSessions } from './sessions/service.ts' +import { NavigationAccessRegistry } from './navigation/access.ts' import type { SessionRemotes } from './sessions/remotes.ts' import type {} from '../remote-events.ts' @@ -24,6 +25,8 @@ export type { export { createScope, scopeOf } from './scope.ts' export type { AgentContext, AgentScopeHandle } from './scope.ts' export { SessionCreateError, SessionForkError } from './sessions/service.ts' +export { NavigationAccessRegistry } from './navigation/access.ts' +export type { NavigationAccess, NavigationAccessProvider, NavigationAccessState, NavigationDecision } from './navigation/access.ts' export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' export type { SessionListPhase, @@ -87,6 +90,7 @@ export const inject = [ * @param ctx - Client Cordis context. */ export function apply(ctx: Context): void { + if (ctx.get('navigationAccess') === undefined) new NavigationAccessRegistry(ctx) const remotes = ctx.remote as unknown as SessionRemotes const sessions = new ClientSessions(ctx, remotes) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) diff --git a/packages/api/session-controller/src/client/navigation/access.ts b/packages/api/session-controller/src/client/navigation/access.ts new file mode 100644 index 0000000000..d8260d29fd --- /dev/null +++ b/packages/api/session-controller/src/client/navigation/access.ts @@ -0,0 +1,88 @@ +import { Context, Service } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' + +export type NavigationAccessState = + | { readonly kind: 'allow' } + | { readonly kind: 'blocked'; readonly reason: string } +export interface NavigationDecision { readonly allow: boolean; readonly handled?: boolean } +export interface NavigationAccessProvider { + matchesWorkspace(id: WorkspaceId): boolean + matchesSession(id: SessionId, workspaceId?: WorkspaceId | null): boolean + workspaceState(id: WorkspaceId): NavigationAccessState + sessionState(id: SessionId, workspaceId?: WorkspaceId | null): NavigationAccessState + requestWorkspace(id: WorkspaceId): Promise + requestSession(id: SessionId, workspaceId?: WorkspaceId | null): Promise + subscribe(listener: () => void): () => void +} +export interface NavigationAccess { + readonly revision: number + register(provider: NavigationAccessProvider): () => void + workspaceState(id: WorkspaceId): NavigationAccessState + sessionState(id: SessionId, workspaceId?: WorkspaceId | null): NavigationAccessState + requestWorkspace(id: WorkspaceId): Promise + requestSession(id: SessionId, workspaceId?: WorkspaceId | null): Promise + subscribe(listener: () => void): () => void +} +const ALLOW_STATE: NavigationAccessState = { kind: 'allow' } +const FAILED_STATE: NavigationAccessState = { kind: 'blocked', reason: 'navigation access provider failed' } +const ALLOW_DECISION: NavigationDecision = { allow: true } +const DENY_DECISION: NavigationDecision = { allow: false } +export class NavigationAccessRegistry extends Service implements NavigationAccess { + private readonly providers: Array<{ provider: NavigationAccessProvider; unsubscribe: () => void }> = [] + private readonly listeners = new Set<() => void>() + private revisionValue = 0 + constructor(ctx: Context) { super(ctx, 'navigationAccess') } + register(provider: NavigationAccessProvider): () => void { + const entry = { provider, unsubscribe: provider.subscribe(() => { this.notify() }) } + this.providers.push(entry); this.notify() + return () => { + const index = this.providers.indexOf(entry) + if (index >= 0) this.providers.splice(index, 1) + entry.unsubscribe() + this.notify() + } + } + get revision(): number { return this.revisionValue } + workspaceState(id: WorkspaceId): NavigationAccessState { return this.state('matchesWorkspace', 'workspaceState', id) } + sessionState(id: SessionId, workspaceId?: WorkspaceId | null): NavigationAccessState { + for (const { provider } of this.providers) try { if (provider.matchesSession(id, workspaceId)) { const state = provider.sessionState(id, workspaceId); if (state.kind === 'blocked') return state } } catch { return FAILED_STATE } + return ALLOW_STATE + } + async requestWorkspace(id: WorkspaceId): Promise { return this.request('matchesWorkspace', 'requestWorkspace', id) } + async requestSession(id: SessionId, workspaceId?: WorkspaceId | null): Promise { + for (const { provider } of this.providers) try { + if (provider.matchesSession(id, workspaceId)) { + const decision = await provider.requestSession(id, workspaceId) + if (!decision.allow) return decision + } + } catch { return DENY_DECISION } + return ALLOW_DECISION + } + subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener) } } + private state(match: 'matchesWorkspace', stateName: 'workspaceState', id: WorkspaceId): NavigationAccessState { + for (const { provider } of this.providers) try { + if (provider[match](id)) { + const state = provider[stateName](id) + if (state.kind === 'blocked') return state + } + } catch { return FAILED_STATE } + return ALLOW_STATE + } + private async request( + match: 'matchesWorkspace', requestName: 'requestWorkspace', id: WorkspaceId, + ): Promise { + for (const { provider } of this.providers) try { + if (provider[match](id)) { + const decision = await provider[requestName](id) + if (!decision.allow) return decision + } + } catch { return DENY_DECISION } + return ALLOW_DECISION + } + private notify(): void { + this.revisionValue += 1 + for (const listener of [...this.listeners]) listener() + } +} +declare module '@deepseek-ai/cordis' { interface Context { navigationAccess: NavigationAccess } } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index d1d840c513..b6ff65e565 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -31,6 +31,7 @@ import type { SessionFace } from '../contract/session.ts' import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../scope.ts' import { SessionManager } from './manager.ts' +import type { NavigationAccess } from '../navigation/access.ts' import type { SessionRemotes } from './remotes.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' import type { Session } from './session.ts' @@ -268,7 +269,14 @@ export class ClientSessions implements ISessions { * @param id - listed or addressed session id. */ open(id: SessionId): void { + void this.openAndWait(id) + } + + async openAndWait(id: SessionId): Promise { + const access = this.rootCtx.get('navigationAccess') as NavigationAccess | undefined + if (access !== undefined && !(await access.requestSession(id)).allow) return false this.manager.select(id) + return this.list.getSnapshot().current === id } /** diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 07f88b1a4f..3fc6e93be9 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -106,6 +106,33 @@ async function flush(): Promise { } describe('Session Controller Client apply', () => { + it('guards session navigation through the shared access registry', async () => { + const bench = await mount() + const provider = { + matchesWorkspace: () => false, + matchesSession: (id: SessionId) => id === sid('locked'), + workspaceState: () => ({ kind: 'allow' as const }), + sessionState: () => ({ kind: 'blocked' as const, reason: 'vault locked' }), + requestWorkspace: async () => ({ allow: true }), + requestSession: async () => ({ allow: false, handled: true }), + subscribe: () => () => {}, + } + bench.ctx.navigationAccess.register(provider) + + expect(await bench.sessions.openAndWait(sid('locked'))).toBe(false) + expect(bench.sessions.list.getSnapshot().current).toBeUndefined() + + bench.dispatch('api-session/added', { + sessionId: sid('plain'), + updatedAt: 1, + running: false, + blank: false, + }) + await flush() + expect(await bench.sessions.openAndWait(sid('plain'))).toBe(true) + expect(bench.sessions.list.getSnapshot().current).toBe(sid('plain')) + }) + it('routes Session Remote Events and connection generations into the object layer', async () => { const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected') const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError') diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 4c8527c1ee..8bf4bfa563 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -237,9 +237,11 @@ export function apply(ctx: Context): void { name: 'conversation.session', children: { 'conversation.view': { kind: 'list', scope: 'session' }, + 'conversation.access.denied': { kind: 'single', scope: 'session' }, }, store: conversationStore, inject: (sessionId: SessionId, actions: BoundActions): ConversationSessionInjected => ({ + navigationAccess: ctx.get('navigationAccess'), hooks: { conversationViews }, bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write), openView: (view, focus) => { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 5107c319bf..d882788dd4 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -2,6 +2,7 @@ import type { ReactNode, RefObject } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { NavigationAccess } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { MaybeSnapshotSelectorHook, ObservableSnapshot, SnapshotSelectorHook, @@ -93,6 +94,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** Strict per-Session Conversation body. */ 'conversation.session': { kind: 'single'; scope: 'session' } + 'conversation.access.denied': { kind: 'single'; scope: 'session'; owner: ConversationAccessDeniedOwnerProps } /** Strict per-Session title, actions, and View navigation. */ 'conversation.session.header': { kind: 'single'; scope: 'session' } /** Optional replacement for one Session breadcrumb title. */ @@ -222,6 +224,7 @@ export interface ConversationInjected { /** Business callbacks injected into the strict Session body. */ export interface ConversationSessionInjected { + readonly navigationAccess: NavigationAccess /** Package-owned View roster source bound only for the Conversation body. */ readonly hooks: { readonly conversationViews: ObservableSnapshot } /** Bind input draft persistence to the Session-owned store instance. */ @@ -230,6 +233,11 @@ export interface ConversationSessionInjected { openView: (view: string, focus: string) => void } +export interface ConversationAccessDeniedOwnerProps { + readonly sessionId: SessionId + readonly reason: string +} + /** Business callbacks injected into the strict Session header. */ export interface ConversationSessionHeaderInjected { /** Package-owned View roster source bound only for the Conversation header. */ @@ -334,7 +342,7 @@ export type ConversationStore = ReturnType /** Full props of the strict Session body. */ export type ConversationSessionSlotProps = PropsRuntime<'conversation.session'> - & PropsRenderSlots<'conversation.view'> + & PropsRenderSlots<'conversation.view' | 'conversation.access.denied'> & PropsStore & InjectFace diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index ea7a3fe76f..25c0c2d194 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,9 +1,10 @@ /** Strict per-session header/body content inserted into the resident conversation layout. */ -import { useEffect } from 'react' +import { useEffect, useSyncExternalStore } from 'react' import clsx from 'clsx' import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { NavigationAccess } from '@deepseek-ai/dsh-api-session-controller/client' import type { ConversationSessionHeaderSlotProps, ConversationSessionSlotProps, } from '../contract/slots.ts' @@ -17,6 +18,16 @@ export type ConversationSessionProps = ConversationSessionSlotProps /** Full props composed from the strict session header contract. */ export type ConversationSessionHeaderProps = ConversationSessionHeaderSlotProps +const ALLOW_NAVIGATION: NavigationAccess = { + revision: 0, + register: () => () => {}, + workspaceState: () => ({ kind: 'allow' }), + sessionState: () => ({ kind: 'allow' }), + requestWorkspace: async () => ({ allow: true }), + requestSession: async () => ({ allow: true }), + subscribe: () => () => {}, +} + interface Breadcrumb { readonly id: SessionId readonly displayTitle: string @@ -163,9 +174,15 @@ export function ConversationSessionHeader({ * @returns the active view area, or null while the Session remains blank. */ export function ConversationSession({ - useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions, - renderSlot, bindDraftMirror, openView, + sessionId, useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions, + renderSlot, bindDraftMirror, openView, navigationAccess, }: ConversationSessionProps) { + const accessController = navigationAccess ?? ALLOW_NAVIGATION + useSyncExternalStore( + listener => accessController.subscribe(listener), + () => accessController.revision, + () => accessController.revision, + ) const tabs = useConversationViews(value => value) const selectedId = useStore(s => s.view) const active = resolveActiveView(tabs, selectedId) @@ -184,6 +201,15 @@ export function ConversationSession({ }, [inputActions]) if (session.blank && conversationPhase(session, conversation) === 'blank') return null + const access = accessController.sessionState(sessionId as SessionId) as + { kind: 'allow' } | { kind: 'blocked'; reason: string } + if (access.kind === 'blocked') { + return ( +
+ {renderSlot('conversation.access.denied', { sessionId: sessionId as SessionId, reason: access.reason })} +
+ ) + } return (
{active !== undefined && renderSlot('conversation.view', { diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index df17251c38..b03f2b1526 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -32,6 +32,7 @@ import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { createWorkspaceViewStore } from '../stores.ts' +import type { SessionRowSlotOwnerProps, WorkspaceRowSlotOwnerProps } from '../row-extensions.ts' /** * Owner share of the directory-flow holes: the complete conversation between @@ -57,6 +58,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.hero.workspace.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps } /** Directory-flow hole under the sidebar browsing region (declared by the WorkspaceBrowser entry). */ 'sidebar.workspaces.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps } + 'sidebar.workspaces.workspace.accessory': { kind: 'list'; scope: 'root'; owner: WorkspaceRowSlotOwnerProps } + 'sidebar.workspaces.workspace.action': { kind: 'list'; scope: 'root'; owner: WorkspaceRowSlotOwnerProps } + 'sidebar.workspaces.session.accessory': { kind: 'list'; scope: 'root'; owner: SessionRowSlotOwnerProps } + 'sidebar.workspaces.session.action': { kind: 'list'; scope: 'root'; owner: SessionRowSlotOwnerProps } } } @@ -97,6 +102,7 @@ export type WorkspaceBrowserInjected = { */ hostInfo: HostObservable } + workspaceRows?: import('../row-extensions.ts').WorkspaceRows /** * Start a New Session in a Workspace: reuse-or-create its blank session and * open it; without an explicit workspace, inherit the current Session @@ -148,6 +154,12 @@ export type WorkspaceBrowserInjected = { export type WorkspaceBrowserProps = PropsRuntime<'sidebar.workspaces'> & PropsRenderSlots<'sidebar.workspaces.directoryFlow'> + & PropsRenderSlots< + | 'sidebar.workspaces.workspace.accessory' + | 'sidebar.workspaces.workspace.action' + | 'sidebar.workspaces.session.accessory' + | 'sidebar.workspaces.session.action' + > & PropsStore> & Omit & PropsHooks diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 7c8e1108dc..79d994e81e 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -28,6 +28,7 @@ import { createWorkspaceViewStore } from './stores.ts' import { WorkspaceBrowser } from './rows/WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' import { en, zh, type WorkspaceKey } from './locales.ts' +import { WorkspaceRowsRegistry } from './row-extensions.ts' export type { UiWorkspace } from './navigation.ts' export type { @@ -35,6 +36,11 @@ export type { WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, } from './contract/slots.ts' export type { WorkspaceKey } from './locales.ts' +export { WorkspaceRowsRegistry } from './row-extensions.ts' +export type { + SessionRowPresentation, SessionRowSlotOwnerProps, WorkspaceRowDecorator, + WorkspaceRowPresentation, WorkspaceRows, WorkspaceRowSlotOwnerProps, +} from './row-extensions.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface GlobalStandardProps { @@ -70,6 +76,7 @@ export const inject = [ * @param ctx - client root context. */ export function apply(ctx: Context): void { + const workspaceRows = ctx.get('workspaceRows') ?? new WorkspaceRowsRegistry(ctx) const sessions = ctx.get('sessions') as ISessions const workspaces = ctx.get('workspaces') as IWorkspaces const uiWorkspace = new UiWorkspaceService( @@ -96,6 +103,7 @@ export function apply(ctx: Context): void { } const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') const browserInjected = (): WorkspaceBrowserInjected => ({ + workspaceRows, // Explicit group actions keep their target; unscoped New Session inherits // the current Session Workspace before the recent-Workspace fallback. startSession: (workspaceId) => { uiWorkspace.startSession(workspaceId) }, @@ -138,7 +146,13 @@ export function apply(ctx: Context): void { ctx.slots.inject('sidebar.workspaces', () => ctx.slots.register( { name: 'sidebar.workspaces', - children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } }, + children: { + 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' }, + 'sidebar.workspaces.workspace.accessory': { kind: 'list', scope: 'root' }, + 'sidebar.workspaces.workspace.action': { kind: 'list', scope: 'root' }, + 'sidebar.workspaces.session.accessory': { kind: 'list', scope: 'root' }, + 'sidebar.workspaces.session.action': { kind: 'list', scope: 'root' }, + }, store: createWorkspaceViewStore(), inject: browserInjected, locale: NS, diff --git a/packages/client/ui-workspace/src/client/row-extensions.ts b/packages/client/ui-workspace/src/client/row-extensions.ts new file mode 100644 index 0000000000..8e6a971974 --- /dev/null +++ b/packages/client/ui-workspace/src/client/row-extensions.ts @@ -0,0 +1,83 @@ +import { Context, Service } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' + +export interface WorkspaceRowPresentation { + readonly label: string + readonly detail?: string + readonly ariaLabel: string + readonly concealed: boolean +} + +export interface SessionRowPresentation extends WorkspaceRowPresentation { + readonly workspaceLabel?: string + readonly snippet?: string +} + +export interface WorkspaceRowDecorator { + workspace(id: WorkspaceId, base: WorkspaceRowPresentation): WorkspaceRowPresentation + session(id: SessionId, base: SessionRowPresentation): SessionRowPresentation +} + +export interface WorkspaceRows { + register(decorator: WorkspaceRowDecorator): () => void + workspace(id: WorkspaceId, base: WorkspaceRowPresentation): WorkspaceRowPresentation + session(id: SessionId, base: SessionRowPresentation): SessionRowPresentation + subscribe(listener: () => void): () => void + readonly revision: number +} + +export interface WorkspaceRowSlotOwnerProps { + readonly workspaceId: WorkspaceId + readonly presentation: WorkspaceRowPresentation +} + +export interface SessionRowSlotOwnerProps { + readonly sessionId: SessionId + readonly workspaceId?: WorkspaceId + readonly presentation: SessionRowPresentation +} + +declare module '@deepseek-ai/cordis' { + interface Context { workspaceRows: WorkspaceRows } +} + +export class WorkspaceRowsRegistry extends Service implements WorkspaceRows { + private readonly decorators: WorkspaceRowDecorator[] = [] + private readonly listeners = new Set<() => void>() + private revisionValue = 0 + + constructor(ctx: Context) { + super(ctx, 'workspaceRows') + } + + get revision(): number { return this.revisionValue } + + register(decorator: WorkspaceRowDecorator): () => void { + this.decorators.push(decorator) + this.notify() + return () => { + const index = this.decorators.indexOf(decorator) + if (index >= 0) this.decorators.splice(index, 1) + this.notify() + } + } + + workspace(id: WorkspaceId, base: WorkspaceRowPresentation): WorkspaceRowPresentation { + return this.decorators.reduce((current, decorator) => decorator.workspace(id, current), base) + } + + session(id: SessionId, base: SessionRowPresentation): SessionRowPresentation { + return this.decorators.reduce((current, decorator) => decorator.session(id, current), base) + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + private notify(): void { + this.revisionValue += 1 + for (const listener of [...this.listeners]) listener() + } +} diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 0f917100ff..d703c02562 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -16,6 +16,16 @@ import { import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceBrowserProps } from '../contract/slots.ts' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionRowPresentation, WorkspaceRowPresentation } from '../row-extensions.ts' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' + +type RowRenderSlot = PropsRenderSlots< + | 'sidebar.workspaces.workspace.accessory' + | 'sidebar.workspaces.workspace.action' + | 'sidebar.workspaces.session.accessory' + | 'sidebar.workspaces.session.action' +>['renderSlot'] import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' import css from './Rows.module.css' @@ -109,7 +119,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | * @param props.t - the browser root's locale seat. * @returns the row element. */ -export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, home, t }: { +export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, home, t, presentation, renderSlot }: { group: GroupNode onToggle: () => void onCreate: () => void @@ -120,10 +130,13 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, home, /** Host account home; POSIX home-rooted hover paths display as `~`. */ home?: string | undefined t: RowTranslate + presentation?: WorkspaceRowPresentation + renderSlot?: RowRenderSlot }) { const row = group + if (presentation?.concealed === true) return null // The ungrouped bucket has no workspace title: its label is dictionary copy. - const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label + const label = presentation?.label ?? (row.workspaceId === undefined ? t('group.ungrouped') : row.label) const active = group.expanded && group.containsCurrent const [menuOpen, setMenuOpen] = useState(false) const workspaceMenuItems = [ @@ -155,6 +168,10 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, home, {label} + {presentation !== undefined && renderSlot !== undefined && row.workspaceId !== undefined && renderSlot( + 'sidebar.workspaces.workspace.accessory', + { workspaceId: row.workspaceId, presentation }, + )} {actions !== undefined && ( + {presentation !== undefined && renderSlot !== undefined && row.workspaceId !== undefined && renderSlot( + 'sidebar.workspaces.workspace.action', + { workspaceId: row.workspaceId, presentation }, + )}
) @@ -324,11 +345,13 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; * @param props.t - Workspace-browser translation seat. * @returns the result button. */ -export function SearchResultItem({ result, currentId, onOpen, t }: { +export function SearchResultItem({ result, currentId, onOpen, t, presentation, renderSlot }: { result: SearchResultNode currentId: string | undefined onOpen: (id: SearchResultNode['id']) => void t: RowTranslate + presentation?: SessionRowPresentation + renderSlot?: RowRenderSlot }) { const selected = result.id === currentId const statuses = sessionStatuses(result, t) @@ -347,15 +370,19 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { )} - {result.title} + {presentation?.label ?? result.title} {result.hasActiveSchedule && } - {result.workspace || t('group.ungrouped')} - {result.snippet !== undefined && ( - {result.snippet} + {presentation?.workspaceLabel ?? (result.workspace || t('group.ungrouped'))} + {(presentation?.snippet ?? result.snippet) !== undefined && ( + {presentation?.snippet ?? result.snippet} )} + {presentation !== undefined && renderSlot !== undefined && renderSlot( + 'sidebar.workspaces.session.accessory', + { sessionId: result.id, presentation }, + )} ) } @@ -375,7 +402,10 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { * @param props.t - the browser root's locale seat. * @returns the session row. */ -export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, flat = false, t }: { +export function SessionNodeItem({ + node, currentId, now, onOpen, onRename, onFork, onArchive, drag, flat = false, t, + presentation, workspaceId, renderSlot, +}: { node: SessionNode currentId: string | undefined now: number @@ -391,9 +421,13 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork /** The row is rendered without a parent Workspace header. */ flat?: boolean | undefined t: RowTranslate + presentation?: SessionRowPresentation + workspaceId?: WorkspaceId + renderSlot?: RowRenderSlot }) { const row = node - const title = displayTitle(node, t) + const title = presentation?.label ?? displayTitle(node, t) + if (presentation?.concealed === true) return null const selected = node.id === currentId const statuses = sessionStatuses(node, t) const primaryStatus = statuses[0] @@ -453,6 +487,10 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork )} {title} + {presentation !== undefined && renderSlot !== undefined && renderSlot( + 'sidebar.workspaces.session.accessory', + { sessionId: node.id, workspaceId, presentation }, + )} {row.hasActiveSchedule && } {/* A blank New Session row is a provisional placeholder: nothing has happened in it yet, so a "now" timestamp and the row verbs @@ -486,6 +524,10 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork /> )} + {presentation !== undefined && renderSlot !== undefined && renderSlot( + 'sidebar.workspaces.session.action', + { sessionId: node.id, workspaceId, presentation }, + )} ) return ( diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 9d9dd376e4..8f042cffb7 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -9,7 +9,7 @@ * menu in between; the flow and its error dialog live in WorkspacePicker * (same package — direct composition, no slot between them). */ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import clsx from 'clsx' import { Button, IconCloseFill14, IconPersonalizationOutline16, @@ -21,6 +21,7 @@ import type { import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceBrowserProps } from '../contract/slots.ts' +import type { WorkspaceRows } from '../row-extensions.ts' import type { SessionNode, SessionOrderBy } from '../tree.ts' import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from '../tree.ts' import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx' @@ -28,6 +29,14 @@ import { FLAT_SESSION_ORDER_KEY } from '../stores.ts' import { WorkspacePickFlow } from '../WorkspacePicker.tsx' import css from './WorkspaceBrowser.module.css' +const IDENTITY_WORKSPACE_ROWS: WorkspaceRows = { + revision: 0, + register: () => () => {}, + subscribe: () => () => {}, + workspace: (_id, base) => base, + session: (_id, base) => base, +} + /** * Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — * focus() forces a synchronous layout and would jank the slide. @@ -232,7 +241,7 @@ function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }): type SessionTreeProps = Pick< WorkspaceBrowserProps, - 'useSessions' | 'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession' + 'useSessions' | 'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession' | 'workspaceRows' | 'renderSlot' | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' > & { /** Host account home for POSIX hover-path abbreviation. */ @@ -271,7 +280,9 @@ function SessionTree({ insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t, + workspaceRows, renderSlot, }: SessionTreeProps) { + const rows = workspaceRows ?? IDENTITY_WORKSPACE_ROWS const list = useSessions(s => s) const pendingInteractions = useSessionPendingInteraction(s => s) const current = list.current @@ -497,6 +508,16 @@ function SessionTree({ > { @@ -558,6 +579,13 @@ function SessionTree({ ) { + const extensions = workspaceRows ?? IDENTITY_WORKSPACE_ROWS const list = useSessions(s => s) const pendingInteractions = useSessionPendingInteraction(s => s) const baseRows = useMemo( @@ -677,6 +708,12 @@ function FlatList({ & { +}: Pick & { workspaces: readonly WorkspaceView[] archivedSessionIds: readonly SessionNode['id'][] query: string remote: RemoteSearchState resultLimit: number }) { + const rows = workspaceRows ?? IDENTITY_WORKSPACE_ROWS const list = useSessions(s => s) const pendingInteractions = useSessionPendingInteraction(s => s) const currentRemote = remote.query === query @@ -766,6 +806,14 @@ function SearchResults({ rows.revision) const home = useHostInfo(info => info.home) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) @@ -1207,6 +1258,8 @@ export function WorkspaceBrowser({ useSessions={useSessions} useSessionPendingInteraction={useSessionPendingInteraction} open={open} + workspaceRows={rows} + renderSlot={renderSlot} workspaces={workspaces} archivedSessionIds={archivedSessionIds} query={normalizedQuery} @@ -1220,6 +1273,8 @@ export function WorkspaceBrowser({