feat(web): add nested subagent conversations
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-height: 28px;
|
||||
padding: 3px 2px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger:hover,
|
||||
.trigger:focus-visible {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.trigger svg {
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.triggerOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 5px);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 336px;
|
||||
max-width: min(400px, calc(100vw - 32px));
|
||||
max-height: min(560px, calc(100vh - 140px));
|
||||
padding: 6px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.node {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 7px 8px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row:hover,
|
||||
.row:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.row > :global([data-state]) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.disclosure,
|
||||
.disclosureSpace {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.disclosure {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.disclosure:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.disclosureOpen {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label,
|
||||
.summary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.summary,
|
||||
.time {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: none;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.children {
|
||||
position: relative;
|
||||
margin-left: 15px;
|
||||
padding-left: 11px;
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.children > .node > .row::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: -12px;
|
||||
width: 9px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.notice,
|
||||
.error {
|
||||
padding: 10px 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.refresh {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
363
packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx
Normal file
363
packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
useEffect, useRef, useState, type KeyboardEvent, type MouseEvent,
|
||||
} from 'react'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, SubagentAddress, SubagentCatalogSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import css from './SubagentCatalogAction.module.css'
|
||||
|
||||
type CatalogEntry = SubagentCatalogSnapshot['entries'][number]
|
||||
type Catalogs = SessionListState['subagentsByParent']
|
||||
|
||||
/** Business actions supplied by the slot registration. */
|
||||
export interface SubagentCatalogInjected {
|
||||
openChild(address: SubagentAddress): void
|
||||
refresh(parentSessionId: SessionId): void
|
||||
setCatalogOpen(parentSessionId: SessionId, open: boolean): void
|
||||
}
|
||||
|
||||
/** Full props for the session-header catalog action. */
|
||||
export type SubagentCatalogActionProps =
|
||||
PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected
|
||||
|
||||
interface CatalogRowsProps {
|
||||
parentSessionId: SessionId
|
||||
catalog: SubagentCatalogSnapshot
|
||||
catalogs: Catalogs
|
||||
summaries: Readonly<Record<SessionId, SessionSummary>>
|
||||
expanded: ReadonlySet<SessionId>
|
||||
level: number
|
||||
now: number
|
||||
openChild(address: SubagentAddress): void
|
||||
refresh(parentSessionId: SessionId): void
|
||||
toggleBranch(childSessionId: SessionId): void
|
||||
closeCatalog(): void
|
||||
}
|
||||
|
||||
function diagnosticReason(entry: Extract<CatalogEntry, { kind: 'diagnostic' }>): string {
|
||||
switch (entry.reason) {
|
||||
case 'corrupt': return '会话记录损坏'
|
||||
case 'unsupported': return '不是可继续的子代理'
|
||||
case 'unavailable': return '会话记录暂不可用'
|
||||
}
|
||||
}
|
||||
|
||||
function treeItems(root: HTMLDivElement | null): HTMLElement[] {
|
||||
return root === null
|
||||
? []
|
||||
: Array.from(root.querySelectorAll<HTMLElement>('[role="treeitem"]:not([aria-disabled="true"])'))
|
||||
}
|
||||
|
||||
/** Compact trailing activity time for a catalog row. */
|
||||
function relativeTime(updatedAt: number | undefined, now: number): string | undefined {
|
||||
if (updatedAt === undefined) return undefined
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
const diff = Math.max(0, now - updatedAt)
|
||||
if (diff < minute) return '刚刚'
|
||||
if (diff < hour) return `${Math.floor(diff / minute)}分钟`
|
||||
if (diff < day) return `${Math.floor(diff / hour)}小时`
|
||||
if (diff < 30 * day) return `${Math.floor(diff / day)}天`
|
||||
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月`
|
||||
return `${Math.floor(diff / (365 * day))}年`
|
||||
}
|
||||
|
||||
/** Render one catalog level and recurse only through explicitly expanded rows. */
|
||||
function CatalogRows({
|
||||
parentSessionId, catalog, catalogs, summaries, expanded, level, now,
|
||||
openChild, refresh, toggleBranch, closeCatalog,
|
||||
}: CatalogRowsProps) {
|
||||
return (
|
||||
<>
|
||||
{catalog.state === 'loading' && catalog.entries.length === 0 && (
|
||||
<div className={css.notice}>正在加载子代理…</div>
|
||||
)}
|
||||
{catalog.state === 'error' && (
|
||||
<div className={css.error}>
|
||||
<span>{catalog.error?.message ?? '无法加载子代理'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.refresh}
|
||||
onClick={() => { refresh(parentSessionId) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{catalog.entries.map((entry) => {
|
||||
if (entry.kind === 'diagnostic') {
|
||||
const reason = diagnosticReason(entry)
|
||||
return (
|
||||
<div key={entry.id} className={css.node}>
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-disabled="true"
|
||||
aria-level={level}
|
||||
aria-label={`${entry.id} ${reason}`}
|
||||
className={`${css.row} ${css.disabled}`}
|
||||
title={reason}
|
||||
>
|
||||
<span className={css.disclosureSpace} />
|
||||
<StateDot state="error" />
|
||||
<span className={css.content}>
|
||||
<span className={css.label}>{entry.id}</span>
|
||||
<span className={css.summary}>{reason}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const childCatalog = catalogs[entry.id]
|
||||
const isExpanded = expanded.has(entry.id)
|
||||
const knownLeaf = childCatalog?.state === 'ready' && childCatalog.entries.length === 0
|
||||
const summary = summaries[entry.id]
|
||||
const secondary = summary?.title ?? (entry.activity === 'running' ? '正在处理' : '已完成')
|
||||
const time = relativeTime(summary?.updatedAt, now)
|
||||
|
||||
const open = (): void => {
|
||||
openChild({ parentSessionId, childSessionId: entry.id })
|
||||
closeCatalog()
|
||||
}
|
||||
const handleKey = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
open()
|
||||
} else if (event.key === 'ArrowRight' && !knownLeaf && !isExpanded) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleBranch(entry.id)
|
||||
} else if (event.key === 'ArrowLeft' && isExpanded) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleBranch(entry.id)
|
||||
}
|
||||
}
|
||||
const toggle = (event: MouseEvent<HTMLButtonElement>): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleBranch(entry.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={entry.id} className={css.node}>
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={level}
|
||||
aria-label={[entry.label, secondary, time].filter(value => value !== undefined).join(' ')}
|
||||
{...knownLeaf ? {} : { 'aria-expanded': isExpanded }}
|
||||
className={css.row}
|
||||
onClick={open}
|
||||
onKeyDown={handleKey}
|
||||
>
|
||||
{knownLeaf
|
||||
? <span className={css.disclosureSpace} />
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`}
|
||||
aria-label={`${isExpanded ? '收起' : '展开'} ${entry.label} 的下级子代理`}
|
||||
onClick={toggle}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
)}
|
||||
<StateDot state={entry.activity === 'running' ? 'ongoing' : 'done'} />
|
||||
<span className={css.content}>
|
||||
<span className={css.label}>{entry.label}</span>
|
||||
<span className={css.summary}>{secondary}</span>
|
||||
</span>
|
||||
{time !== undefined && <span className={css.time}>{time}</span>}
|
||||
</div>
|
||||
{isExpanded && childCatalog !== undefined && !knownLeaf && (
|
||||
<div role="group" className={css.children}>
|
||||
<CatalogRows
|
||||
parentSessionId={entry.id}
|
||||
catalog={childCatalog}
|
||||
catalogs={catalogs}
|
||||
summaries={summaries}
|
||||
expanded={expanded}
|
||||
level={level + 1}
|
||||
now={now}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
toggleBranch={toggleBranch}
|
||||
closeCatalog={closeCatalog}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current session's direct catalog and lazily expanded descendants.
|
||||
* @param props - session standard props plus catalog navigation actions.
|
||||
* @returns The action only after a non-empty catalog arrives.
|
||||
*/
|
||||
export function SubagentCatalogAction({
|
||||
sessionId, useSessions, openChild, refresh, setCatalogOpen,
|
||||
}: SubagentCatalogActionProps) {
|
||||
const catalogs = useSessions(state => state.subagentsByParent)
|
||||
const summaries = useSessions(state => state.byId)
|
||||
const catalog = catalogs[sessionId]
|
||||
const [open, setOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<SessionId>>(() => new Set())
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const observedCatalogs = useRef(new Set<SessionId>())
|
||||
const setCatalogOpenRef = useRef(setCatalogOpen)
|
||||
setCatalogOpenRef.current = setCatalogOpen
|
||||
const healthy = catalog?.entries.filter(entry => entry.kind === 'child') ?? []
|
||||
|
||||
const observeCatalog = (parentSessionId: SessionId, next: boolean): void => {
|
||||
if (next) observedCatalogs.current.add(parentSessionId)
|
||||
else observedCatalogs.current.delete(parentSessionId)
|
||||
setCatalogOpen(parentSessionId, next)
|
||||
}
|
||||
|
||||
const closeAllCatalogs = (): void => {
|
||||
for (const parentSessionId of observedCatalogs.current) {
|
||||
setCatalogOpen(parentSessionId, false)
|
||||
}
|
||||
observedCatalogs.current.clear()
|
||||
setExpanded(new Set())
|
||||
}
|
||||
|
||||
const changeOpen = (next: boolean, restoreFocus = false): void => {
|
||||
setOpen(next)
|
||||
if (next) observeCatalog(sessionId, true)
|
||||
else closeAllCatalogs()
|
||||
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
||||
}
|
||||
|
||||
const closeBranch = (root: SessionId): void => {
|
||||
const closing = new Set<SessionId>()
|
||||
const visit = (parentSessionId: SessionId): void => {
|
||||
if (closing.has(parentSessionId) || !expanded.has(parentSessionId)) return
|
||||
closing.add(parentSessionId)
|
||||
const branch = catalogs[parentSessionId]
|
||||
for (const entry of branch?.entries ?? []) {
|
||||
if (entry.kind === 'child') visit(entry.id)
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
for (const parentSessionId of closing) observeCatalog(parentSessionId, false)
|
||||
setExpanded(current => new Set([...current].filter(id => !closing.has(id))))
|
||||
}
|
||||
|
||||
const toggleBranch = (childSessionId: SessionId): void => {
|
||||
if (expanded.has(childSessionId)) {
|
||||
closeBranch(childSessionId)
|
||||
return
|
||||
}
|
||||
setExpanded(current => new Set(current).add(childSessionId))
|
||||
observeCatalog(childSessionId, true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOutside = (event: PointerEvent): void => {
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) {
|
||||
changeOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOutside)
|
||||
return () => { document.removeEventListener('pointerdown', closeOutside) }
|
||||
}, [open])
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const parentSessionId of observedCatalogs.current) {
|
||||
setCatalogOpenRef.current(parentSessionId, false)
|
||||
}
|
||||
observedCatalogs.current.clear()
|
||||
}, [])
|
||||
|
||||
const visible = catalog !== undefined && (catalog.state !== 'ready' || catalog.entries.length > 0)
|
||||
useEffect(() => {
|
||||
if (visible || !open) return
|
||||
setOpen(false)
|
||||
closeAllCatalogs()
|
||||
}, [visible, open])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const focusAt = (index: number): void => {
|
||||
const items = treeItems(rootRef.current)
|
||||
if (items.length === 0) return
|
||||
items[(index + items.length) % items.length]?.focus()
|
||||
}
|
||||
|
||||
const navigate = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const items = treeItems(rootRef.current)
|
||||
const index = items.indexOf(document.activeElement as HTMLElement)
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
changeOpen(false, true)
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
focusAt(0)
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault()
|
||||
focusAt(items.length - 1)
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
focusAt(index + 1)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusAt(index < 0 ? items.length - 1 : index - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.root} ref={rootRef} onKeyDown={navigate}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-haspopup="tree"
|
||||
aria-expanded={open}
|
||||
onClick={() => { changeOpen(!open) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'ArrowDown') return
|
||||
event.preventDefault()
|
||||
if (!open) changeOpen(true)
|
||||
queueMicrotask(() => { focusAt(0) })
|
||||
}}
|
||||
>
|
||||
<span>{healthy.length} 个子代理</span>
|
||||
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
|
||||
</button>
|
||||
{open && catalog !== undefined && (
|
||||
<div className={css.menu} role="tree" aria-label="子代理会话">
|
||||
<CatalogRows
|
||||
parentSessionId={sessionId}
|
||||
catalog={catalog}
|
||||
catalogs={catalogs}
|
||||
summaries={summaries}
|
||||
expanded={expanded}
|
||||
level={1}
|
||||
now={Date.now()}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
toggleBranch={toggleBranch}
|
||||
closeCatalog={() => { changeOpen(false) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.frame {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: 0 24px 20px;
|
||||
min-height: 54px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.frame strong {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-weight: 510;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import css from './SubagentReadOnlyComposer.module.css'
|
||||
|
||||
/** Full chain props after the read-only subagent selector accepts the owner currency. */
|
||||
export type SubagentReadOnlyComposerProps =
|
||||
PropsRuntime<'conversation.composer'> & { matched: ComposerChainProps }
|
||||
|
||||
/**
|
||||
* Explain why the normal composer is unavailable for a parentless child.
|
||||
* @returns A read-only composer replacement.
|
||||
*/
|
||||
export function SubagentReadOnlyComposer() {
|
||||
return (
|
||||
<div className={css.frame} role="status">
|
||||
<strong>此子代理暂时只读</strong>
|
||||
<span>父会话当前不在线,重新打开父会话后即可继续发送消息。</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,18 +9,33 @@
|
||||
* business work (design ledger). No adjudication hooks: subagent
|
||||
* references never enter command adjudication.
|
||||
*/
|
||||
import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ClientContext, SessionId, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentCatalogAction.tsx'
|
||||
import { SubagentReadOnlyComposer } from './SubagentReadOnlyComposer.tsx'
|
||||
|
||||
/** Required services: the slash registry + the session list face the source closes over. */
|
||||
export const inject = ['slash', 'sessions']
|
||||
export type {
|
||||
SubagentCatalogActionProps, SubagentCatalogInjected,
|
||||
} from './SubagentCatalogAction.tsx'
|
||||
export type { SubagentReadOnlyComposerProps } from './SubagentReadOnlyComposer.tsx'
|
||||
|
||||
/** Required services for references, conversation slots, and session navigation. */
|
||||
export const inject = ['slash', 'sessions', 'conversation', 'slots']
|
||||
|
||||
/** Claim the composer only when an addressed child has no live continuation owner. */
|
||||
function selectReadOnlySubagent(owner: ComposerChainProps): ComposerChainProps | null {
|
||||
return owner.subagentReadOnly ? owner : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body: register the '@' subagent source over the root session list.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const sessions = ctx.get('sessions') as SessionsService
|
||||
const sessions = ctx.sessions
|
||||
// Child labels live on the session list (parentId lineage + displayTitle),
|
||||
// not the conversation snapshot — the list store is the zero-RPC candidate feed.
|
||||
const childLabels = (session: ClientSessionContext, query: string): string[] => {
|
||||
@@ -59,4 +74,33 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
const slash = ctx.get('slash') as SlashServiceContract
|
||||
ctx.effect(() => slash.registerSource(source), 'ui-subagent: @ source')
|
||||
|
||||
const catalogActions = (_parentSessionId: SessionId): SubagentCatalogInjected => ({
|
||||
openChild(address: SubagentAddress) {
|
||||
sessions.openSubagent(address)
|
||||
},
|
||||
refresh(parentSessionId: SessionId) {
|
||||
void sessions.refreshSubagents(parentSessionId)
|
||||
},
|
||||
setCatalogOpen(parentSessionId: SessionId, open: boolean) {
|
||||
sessions.setSubagentCatalogOpen(parentSessionId, open)
|
||||
},
|
||||
})
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'subagent-catalog',
|
||||
order: 10,
|
||||
inject: catalogActions,
|
||||
}, SubagentCatalogAction),
|
||||
'ui-subagent: lazy descendant catalog action',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.composer',
|
||||
priority: 10,
|
||||
select: selectReadOnlySubagent,
|
||||
}, SubagentReadOnlyComposer),
|
||||
'ui-subagent: unavailable-parent composer',
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user