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:
1 parent
b2ea1d1b71
commit
3f1d5dfcfa
8 files changed
+104
-14
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user