From 3f1d5dfcfae4e398445f95e4ccd12032b9cd266f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 14 Oct 2026 14:45:32 +0800 Subject: [PATCH] feat(ui-conversation): add the New Session options slot conversation.hero.options seats compact Session options at the right end of the New Session row, after the workspace and agent-preset controls. It is a session-scoped list slot rendered only while a Session exists, so plugins can add choices such as a worktree toggle without displacing the shipped controls. --- docs/subsystems/slots.md | 3 +- docs/subsystems/slots.zh.md | 3 +- .../ui-conversation/src/client/apply.ts | 1 + .../src/client/contract/slots.ts | 3 + .../client/skeleton/ConversationContent.tsx | 1 + .../skeleton/ConversationRoot.module.css | 14 ++++ .../tests/skeleton.client.spec.tsx | 14 +++- .../src/client/slot-catalog.ts | 79 ++++++++++++++++--- 8 files changed, 104 insertions(+), 14 deletions(-) diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 12a8d89b8f..0a08dc4f9a 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -171,7 +171,8 @@ Factory: conversation.content [ConversationContent] ├─ conversation.hero.brand.mark ├─ conversation.hero.workspace │ └─ conversation.hero.workspace.directoryFlow -└─ conversation.hero.agentPreset +├─ conversation.hero.agentPreset +└─ conversation.hero.options Factory: conversation.chat.user.content [UserContent] ├─ conversation.chat.user.images [MessageImages] diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 77f3717f36..372f01773c 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -173,7 +173,8 @@ Factory: conversation.content [ConversationContent] ├─ conversation.hero.brand.mark ├─ conversation.hero.workspace │ └─ conversation.hero.workspace.directoryFlow -└─ conversation.hero.agentPreset +├─ conversation.hero.agentPreset +└─ conversation.hero.options Factory: conversation.chat.user.content [UserContent] ├─ conversation.chat.user.images [MessageImages] diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 3e34d83b4b..e567bd1a50 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -345,6 +345,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { 'conversation.hero.brand.mark': { kind: 'single', scope: 'root' }, 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, 'conversation.hero.agentPreset': { kind: 'single', scope: 'session-maybe' }, + 'conversation.hero.options': { kind: 'list', scope: 'session' }, }, slots: { views: { scope: 'session' }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 9abf5afb1c..a02f5aa662 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -264,6 +264,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.hero.brand.mark': { kind: 'single'; scope: 'root'; owner: HeroBrandMarkOwnerProps } /** Agent-preset control staged for a New Session. */ 'conversation.hero.agentPreset': { kind: 'single'; scope: 'session-maybe'; owner: HeroAgentPresetOwnerProps } + /** Compact options right-aligned in the New Session row after the workspace and preset controls; rendered only with a Session. */ + 'conversation.hero.options': { kind: 'list'; scope: 'session' } /** Full-width entries above the composer card. */ 'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone } /** Floating entries rendered inside the resident composer card. */ @@ -309,6 +311,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.hero.brand.mark': { kind: 'single'; scope: 'root' } 'conversation.hero.workspace': { kind: 'single'; scope: 'root' } 'conversation.hero.agentPreset': { kind: 'single'; scope: 'session-maybe' } + 'conversation.hero.options': { kind: 'list'; scope: 'session' } } inject: ConversationInjected locale: 'conversation' diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index 15bfdd8732..218082ae31 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -130,6 +130,7 @@ export function ConversationContent(props: ConversationContentProps) { onClose: () => { setPickerOpen(false) }, })} {renderSlot('conversation.hero.agentPreset', {})} + {sessionId === undefined ? null :
{renderSlot('conversation.hero.options', {})}
} ) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 3aa0a9d1f9..070cb94a75 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -529,6 +529,20 @@ button.crumb:hover { padding: 0 16px 0 20px; } +/* Plugin options share the row, pushed to its right edge. */ +.heroOptions { + display: flex; + flex: none; + align-items: center; + gap: 2px; + min-width: 0; + margin-left: auto; +} + +.heroOptions:empty { + display: none; +} + /* Hero: the composer sits inside the session scroll body; center there so the tree seat matches active (sticky footer) without a Root remount. */ .root[data-phase='hero'] .scrollBody, diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 00a784ac96..bcd1005a30 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createContext, useContext, type ReactNode } from 'react' -import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render, within } from '@testing-library/react' import type { Context } from '@deepseek-ai/cordis' import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' @@ -689,6 +689,18 @@ describe('ConversationRoot resident composer', () => { // The agent-preset chip sits in the same row, for the same reason: both // choices are only open before the first message. expect(b.slotCalls).toContain('conversation.hero.agentPreset') + expect(b.slotCalls).toContain('conversation.hero.options') + }) + + it('seats the New Session options after the agent-preset chip, and only for a Session', () => { + const b = mount(sessionSnapshotOf({ blank: true })) + const preset = within(b.view.container).getByTestId('view-conversation.hero.agentPreset') + const options = within(b.view.container).getByTestId('view-conversation.hero.options') + expect(options.parentElement?.previousElementSibling).toBe(preset) + + const cold = mount(sessionSnapshotOf(), [], undefined, { sessionId: undefined }) + expect(cold.slotCalls).toContain('conversation.hero.agentPreset') + expect(cold.slotCalls).not.toContain('conversation.hero.options') }) it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => { diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 13795cd4df..671acc8985 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -649,7 +649,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.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:280', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:282', }, { key: 'conversation.composer.dock', @@ -709,7 +709,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:272', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:274', }, { key: 'conversation.header', @@ -851,6 +851,63 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-conversation/src/client/contract/slots.ts:264', }, + { + key: 'conversation.hero.options', + kind: 'list', + scope: 'session', + summary: 'Compact options right-aligned in the New Session row after the workspace and preset controls; rendered only with a Session.', + doc: 'Compact options right-aligned in the New Session row after the workspace and preset controls; rendered only with a Session.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'factory \'conversation.content\' (client-ui-conversation), so it exists while that definition is registered', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.options\', () => ctx.slots.register(\n { name: \'conversation.hero.options\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:268', + }, { key: 'conversation.hero.workspace', kind: 'single', @@ -962,7 +1019,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.input.activity\', () => ctx.slots.register(\n { name: \'conversation.input.activity\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:278', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:280', }, { key: 'conversation.input.attachments', @@ -1006,7 +1063,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.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:282', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:284', }, { key: 'conversation.input.dock', @@ -1073,7 +1130,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:268', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:270', }, { key: 'conversation.input.left', @@ -1130,7 +1187,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:274', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:276', }, { key: 'conversation.input.model', @@ -1172,7 +1229,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.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:296', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:298', }, { key: 'conversation.input.overlay', @@ -1233,7 +1290,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:270', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:272', }, { key: 'conversation.input.permission', @@ -1275,7 +1332,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.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:290', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:292', }, { key: 'conversation.input.plan', @@ -1317,7 +1374,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.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:288', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:290', }, { key: 'conversation.input.right', @@ -1374,7 +1431,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:276', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:278', }, { key: 'conversation.plan-review.actions',