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.
This commit is contained in:
Yichen Jiang committed 2026-10-14 14:45:32 +08:00
1 parent b2ea1d1b71
commit 3f1d5dfcfa
8 files changed
+104 -14

No files matched your search

+2 -1
View File
@@ -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]
+2 -1
View File
@@ -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]
@@ -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' },
@@ -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'
@@ -130,6 +130,7 @@ export function ConversationContent(props: ConversationContentProps) {
onClose: () => { setPickerOpen(false) },
})}
{renderSlot('conversation.hero.agentPreset', {})}
{sessionId === undefined ? null : <div className={css.heroOptions}>{renderSlot('conversation.hero.options', {})}</div>}
</div>
)
@@ -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,
@@ -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)', () => {
@@ -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<WorkspaceSnapshot>',
'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<InputState>',
'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',