feat(web): rewrite subagent conversations for FIFO activation

This commit is contained in:
Dudu-0223
2026-07-30 23:33:07 +08:00
committed by Tianyi Cui
parent f0ab04273d
commit 8a518e353b
52 changed files with 829 additions and 420 deletions

View File

@@ -40,7 +40,8 @@ interface EffortChoice {
* @returns the trigger and, while open, the two-level menu.
*/
export function ModelSelect(
{ locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
{ locked, available, directory, load, select, t }:
ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
) {
const state = useSyncExternalStore(
fn => directory.subscribe(fn),
@@ -92,7 +93,9 @@ export function ModelSelect(
const busy = state.status === 'selecting'
// Mount-time load resolves the trigger label; every open refreshes.
useEffect(() => { load() }, [load])
useEffect(() => {
if (available) load()
}, [available, load])
useEffect(() => {
if (!open) return
@@ -103,6 +106,8 @@ export function ModelSelect(
return () => { document.removeEventListener('mousedown', closeOutside) }
}, [open])
if (!available) return null
const show = (): void => {
setPane('root')
setOpen(true)

View File

@@ -39,10 +39,12 @@ export class ModelDirectory {
/**
* @param sessions - the session wire face (captured from the plugin's root connection).
* @param sessionId - the owning session.
* @param available - whether this session may use Agent-bound model RPCs.
*/
constructor(
private readonly sessions: Pick<IApiClient['sessions'], 'models' | 'selectModel'>,
private readonly sessionId: SessionId,
private readonly available: () => boolean,
) {}
/**
@@ -51,6 +53,7 @@ export class ModelDirectory {
* @returns the fresh directory value.
*/
async load(): Promise<SessionModels> {
this.assertAvailable()
const generation = ++this.generation
this.store.update((s) => { s.status = 'loading'; s.error = null })
const { result } = await this.sessions.models({ sessionId: this.sessionId })
@@ -80,6 +83,7 @@ export class ModelDirectory {
* @param target - provider, provider-owned model id, and optional adapter-owned effort.
*/
async select(target: ModelTarget): Promise<void> {
this.assertAvailable()
const generation = ++this.generation
this.store.update((s) => { s.status = 'selecting'; s.error = null })
const { result } = await this.sessions.selectModel({
@@ -116,6 +120,7 @@ export class ModelDirectory {
s.status = 'idle'
s.error = null
})
if (!this.available()) return
void this.load().catch(() => { /* the next menu open remains the explicit retry surface */ })
}
@@ -123,4 +128,10 @@ export class ModelDirectory {
dispose(): void {
this.disposed = true
}
private assertAvailable(): void {
if (!this.available()) {
throw new Error('model selection is unavailable for addressed subagent sessions')
}
}
}

View File

@@ -7,7 +7,9 @@
* so the host-reported current target is the single fact both surfaces echo
* — a switch made in either entry is what the other shows next. Failures
* ride each entry's own retry surface (popup shell error/retry; seat menu
* inline error) without forking the state.
* inline error) without forking the state. Addressed subagent sessions expose
* neither entry because those Agent-bound RPCs would activate persisted
* history outside the direct-parent continuation seam.
*/
import type { ModelTarget, SessionModels } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
@@ -119,14 +121,23 @@ export function apply(ctx: ClientContext): void {
ctx.inject(['command', 'models'], (scope: ClientContext) => {
const command = scope.get('command') as CommandServiceContract
const models = scope.models
const sessions = scope.sessions
scope.effect(() => command.register({
name: 'model',
description: t('command.description'),
available: () => true,
available: session => sessions.subagentAddress(session.sessionId) === undefined,
ui: {
kind: 'popupSelect',
options: async session => optionsOf(await models.directoryFor(session.sessionId).load(), t),
options: async (session) => {
if (sessions.subagentAddress(session.sessionId) !== undefined) {
throw new Error('model selection is unavailable for addressed subagent sessions')
}
return optionsOf(await models.directoryFor(session.sessionId).load(), t)
},
onSelect: async (option, session) => {
if (sessions.subagentAddress(session.sessionId) !== undefined) {
throw new Error('model selection is unavailable for addressed subagent sessions')
}
const directory = models.directoryFor(session.sessionId)
const target = targetOf(directory.store.getSnapshot(), option.id)
if (target === undefined) {
@@ -143,15 +154,22 @@ export function apply(ctx: ClientContext): void {
// conversation service's presence is the registration-safe signal.
ctx.inject(['slots', 'conversation', 'models'], (scope: ClientContext) => {
const models = scope.models
const sessions = scope.sessions
scope.effect(() => scope.slots.register({
name: 'conversation.input.model',
locale: NS,
inject: (sessionId): ModelSelectInjected => {
const directory = models.directoryFor(sessionId)
const available = sessions.subagentAddress(sessionId) === undefined
return {
available,
directory: directory.store,
load: () => { directory.load().catch(() => { /* surfaced on the store */ }) },
select: (target: ModelTarget) => directory.select(target).then(() => true, () => false),
load: () => {
if (available) directory.load().catch(() => { /* surfaced on the store */ })
},
select: (target: ModelTarget) => available
? directory.select(target).then(() => true, () => false)
: Promise.resolve(false),
}
},
}, ModelSelect), 'ui-model: composer model seat registration')

View File

@@ -68,7 +68,11 @@ export class ModelService extends Service {
const actx = sessions.scope(sessionId)
if (actx === undefined) throw new Error(`ui-model: session "${String(sessionId)}" resolved no scope`)
const connection = this.ctx.get('connection') as ConnectionHandle
const directory = new ModelDirectory(connection.api.sessions, sessionId)
const directory = new ModelDirectory(
connection.api.sessions,
sessionId,
() => sessions.subagentAddress(sessionId) === undefined,
)
live.directories.set(sessionId, directory)
actx.effect(() => () => {
directory.dispose()

View File

@@ -10,6 +10,8 @@ import type { ModelDirectoryState } from './directory.ts'
/** Injected business face of the composer model seat. */
export interface ModelSelectInjected {
/** Whether this session supports Agent-bound model inspection and selection. */
available: boolean
/** The session's shared directory store (same instance the /model popup reads). */
directory: SnapshotStore<ModelDirectoryState>
/** Refresh the advisory directory (fire-and-forget; errors land on the store). */