feat(web): choose the agent preset on the new-session screen
The composer seat spent nearly all its life disabled: a session's composition is fixed once a turn has run. Move the choice to the new-session screen beside the workspace picker, where it still works, and let the session header report what a running session runs. The hero pick is staged rather than applied — that screen precedes the session it belongs to. It lands when a session becomes current and is still blank, which covers both the session a workspace connect creates and the blank one it reuses; riding `sessions.create` would miss the second. It is spent on first use, matching the workspace picker. Fix the durability the header field claimed but never had: `agentPreset` was declared on `SessionHeader` and dropped by the JSONL header line, the SQLite sessions row, the derived query index, and the cold list projection, so every resumed session came back composed from nothing. Add the web e2e lane that would have caught it — the one lane that mounts the shipped roster, which needed `cordis:group` in the scaffold's Loader builtins, as `mountRootInclude` already registers.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
/* Session-header agent-preset label: static chrome, never a control. */
|
||||
|
||||
.label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 180px;
|
||||
padding: 0 8px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-fill-tsp-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The session header's agent-preset label.
|
||||
*
|
||||
* Read-only by construction: a session's composition is fixed once its
|
||||
* conversation starts, and a header is only worth reading after that. Offering
|
||||
* a control here would promise a switch the host refuses; naming what the
|
||||
* session runs is the honest affordance, and the choice itself lives on the
|
||||
* new-session screen ({@link AgentPresetSeat}).
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the header actions).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { AgentPresetSettingsState } from './settings-store.ts'
|
||||
import css from './AgentPresetLabel.module.css'
|
||||
|
||||
/** Registration-side business face for the header label. */
|
||||
export interface AgentPresetLabelInjected {
|
||||
hooks: {
|
||||
/** Roster snapshot bound by the renderer as useAgentPresets. */
|
||||
agentPresets: SnapshotStore<AgentPresetSettingsState>
|
||||
}
|
||||
/** Read the roster, so the label can show a name rather than an id. */
|
||||
load: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetLabelProps =
|
||||
PropsRuntime<'conversation.session.header.actions'>
|
||||
& PropsLocale<'settings.agentPreset'>
|
||||
& InjectFace<AgentPresetLabelInjected>
|
||||
|
||||
/**
|
||||
* Render this session's agent-preset name beside its title.
|
||||
* @param props - composed slot props.
|
||||
* @returns the label, or null when the session records no preset.
|
||||
*/
|
||||
export function AgentPresetLabel({
|
||||
sessionId, useSessions, useAgentPresets, load, t,
|
||||
}: AgentPresetLabelProps) {
|
||||
const preset = useSessions(state => state.byId[sessionId]?.agentPreset)
|
||||
const options = useAgentPresets(state => state.options)
|
||||
|
||||
useEffect(() => {
|
||||
// Deployments that compose no presets never label anything, so the roster
|
||||
// is only worth a request once a session reports one.
|
||||
if (preset !== undefined) void load()
|
||||
}, [preset, load])
|
||||
|
||||
if (preset === undefined) return null
|
||||
|
||||
const option = options.find(entry => entry.id === preset)
|
||||
return (
|
||||
<span className={css.label} title={option?.description ?? t('headerHint')}>
|
||||
<IconThinkOutline16 className={css.icon} />
|
||||
{option?.name ?? preset}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,29 @@
|
||||
/* Agent-preset seat: a compact selector pill in the composer tool row. */
|
||||
/* Agent-preset chip on the new-session screen, beside the workspace picker.
|
||||
Geometry mirrors HeroShell's .workspace so the two read as one row. */
|
||||
|
||||
.seat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 28px;
|
||||
padding: 0 6px 0 8px;
|
||||
gap: 4px;
|
||||
max-width: min(100%, 240px);
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seat:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-fill-tsp-secondary);
|
||||
.seat:not(:disabled):hover,
|
||||
.seat[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.seat:disabled {
|
||||
@@ -24,11 +31,34 @@
|
||||
color: var(--dsw-alias-label-quaternary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
.seatIcon {
|
||||
flex: none;
|
||||
opacity: 0.6;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.locked {
|
||||
cursor: default;
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Menu rows carry the name over its description: the id alone never said what
|
||||
a preset does, which is why the metadata exists. */
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.itemDesc {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@@ -1,44 +1,50 @@
|
||||
/**
|
||||
* Composer seat for the session's agent preset.
|
||||
* The agent-preset chip on the new-session screen, beside the workspace
|
||||
* picker.
|
||||
*
|
||||
* The switch exists only while the conversation has not started: after the
|
||||
* first turn the session's history was produced under this preset's tools, so
|
||||
* the seat becomes a plain label rather than offering a choice it cannot honor.
|
||||
* It lives here rather than in the composer because the choice is only
|
||||
* available before a conversation starts: once a turn has run, the session's
|
||||
* history was produced under that preset's tools and the host refuses to swap
|
||||
* them. A control that spends most of its life disabled belongs on the screen
|
||||
* where it still works.
|
||||
*
|
||||
* The menu opens on the staged choice, which starts as the deployment default.
|
||||
* Picking stages; the choice reaches a session when one becomes current.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the agentPreset seat).
|
||||
import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the hero seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { AgentPresetSeatState } from './seat-store.ts'
|
||||
import css from './AgentPresetSeat.module.css'
|
||||
|
||||
/** Registration-side business face for the composer seat. */
|
||||
/** Registration-side business face for the hero chip. */
|
||||
export interface AgentPresetSeatInjected {
|
||||
hooks: {
|
||||
/** Seat snapshot bound by the renderer as useAgentPresetSeat. */
|
||||
agentPresetSeat: SnapshotStore<AgentPresetSeatState>
|
||||
}
|
||||
/** Load the roster and this session's state when the seat first renders. */
|
||||
/** Read the roster when the chip first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Switch this session to another preset. */
|
||||
/** Stage one preset for the next session. */
|
||||
select: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetSeatProps =
|
||||
PropsRuntime<'conversation.input.agentPreset'>
|
||||
PropsRuntime<'conversation.hero.agentPreset'>
|
||||
& PropsLocale<'settings.agentPreset'>
|
||||
& InjectFace<AgentPresetSeatInjected>
|
||||
|
||||
/**
|
||||
* Render the session's agent-preset seat.
|
||||
* @param props - composed slot props; `locked` is the composer's own busy state.
|
||||
* @returns the seat, or null when the deployment composes no presets.
|
||||
* Render the new-session agent-preset chip.
|
||||
* @param props - composed slot props.
|
||||
* @returns the chip, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }: AgentPresetSeatProps) {
|
||||
export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) {
|
||||
const state = useAgentPresetSeat(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -46,20 +52,11 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }:
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.switchable) return
|
||||
setOpen(false)
|
||||
}, [state.switchable])
|
||||
|
||||
// Nothing to choose between: the deployment composes no presets and every
|
||||
// session shares the host composition.
|
||||
if (state.options.length === 0 || state.current === '') return null
|
||||
|
||||
// Past the first turn the preset is a fact about this session, not a
|
||||
// control — showing a disabled menu would suggest it could still be changed.
|
||||
if (!state.switchable) {
|
||||
return <span className={`${css.seat} ${css.locked}`} title={t('lockedHint')}>{state.current}</span>
|
||||
}
|
||||
const chosen = state.options.find(option => option.id === state.current)
|
||||
|
||||
return (
|
||||
<Menu
|
||||
@@ -67,14 +64,21 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }:
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={state.options.map(option => ({
|
||||
id: option.id,
|
||||
label: option.trust === 'user' ? `${option.id} · ${t('userTrust')}` : option.id,
|
||||
// Name and description together: the id alone never said what a
|
||||
// preset does, which is the whole reason the metadata exists.
|
||||
label: (
|
||||
<span className={css.item}>
|
||||
<span className={css.itemName}>{option.name ?? option.id}</span>
|
||||
<span className={css.itemDesc}>{option.description ?? t('noDescription')}</span>
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
selectedId={state.current}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
void select(id)
|
||||
}}
|
||||
align="end"
|
||||
align="start"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
@@ -83,10 +87,11 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }:
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={state.error ?? t('seatHint')}
|
||||
disabled={locked || state.busy}
|
||||
disabled={state.busy}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{state.current}
|
||||
<IconThinkOutline16 className={css.seatIcon} />
|
||||
{chosen?.name ?? state.current}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Agent-preset surface plugin, browser half — three surfaces over one roster:
|
||||
* a General-settings row for the default preset, a composer seat for the
|
||||
* session about to start, and a settings section that authors the compositions
|
||||
* themselves.
|
||||
* Agent-preset surface plugin, browser half — four surfaces over one roster:
|
||||
* a General-settings row for the default preset, a chip on the new-session
|
||||
* screen for the session about to start, a read-only label in the session
|
||||
* header, and a settings section that authors the compositions themselves.
|
||||
*
|
||||
* A running session keeps the composition it began with (the host refuses to
|
||||
* adopt an existing session under a different preset), so the General row is
|
||||
* a new-session preference rather than a live switch, and the seat stops
|
||||
* offering a choice once the conversation starts.
|
||||
* adopt an existing session under a different preset). That is what splits
|
||||
* the choice from the display: the General row and the hero chip are both
|
||||
* before-the-fact, while the header only reports what a session already runs.
|
||||
*/
|
||||
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -15,7 +15,9 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetLabel } from './AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx'
|
||||
import { AgentPresetRow } from './AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
|
||||
import { AgentPresetSeat } from './AgentPresetSeat.tsx'
|
||||
@@ -23,14 +25,16 @@ import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
|
||||
import { AgentPresetSection } from './AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx'
|
||||
import { AgentPresetSeatController } from './seat-store.ts'
|
||||
import type { SeatSessionSummary } from './seat-store.ts'
|
||||
import { AgentPresetSectionController } from './section-store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
|
||||
|
||||
export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPresetLabel.tsx'
|
||||
export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
|
||||
export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
|
||||
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
|
||||
export type { AgentPresetSeatState } from './seat-store.ts'
|
||||
export type { AgentPresetSeatState, SeatSessionSummary } from './seat-store.ts'
|
||||
export {
|
||||
draftBlocker, type AgentPresetSectionState, type PresetDraft, type PresetRow,
|
||||
} from './section-store.ts'
|
||||
@@ -74,35 +78,56 @@ export function apply(ctx: ClientContext): void {
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-agent-preset: settings refresh')
|
||||
|
||||
// The composer seat: one controller per session, because the switch and the
|
||||
// "may it still switch" bit are both per-session facts.
|
||||
// The new-session chip and the header label: one controller, because the
|
||||
// staged choice belongs to the flow rather than to any one session.
|
||||
ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => {
|
||||
const api = (scope.get('connection') as ConnectionHandle).api
|
||||
const seats = new Map<SessionId, AgentPresetSeatController>()
|
||||
const seatFor = (sessionId: SessionId): AgentPresetSeatController => {
|
||||
const existing = seats.get(sessionId)
|
||||
if (existing !== undefined) return existing
|
||||
const created = new AgentPresetSeatController(api, sessionId, () => {
|
||||
const summary = scope.sessions.list.getSnapshot().byId[sessionId]
|
||||
return summary === undefined
|
||||
? undefined
|
||||
: { blank: summary.blank, ...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset } }
|
||||
})
|
||||
seats.set(sessionId, created)
|
||||
return created
|
||||
}
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.agentPreset',
|
||||
locale: 'settings.agentPreset',
|
||||
inject: (sessionId: SessionId): AgentPresetSeatInjected => {
|
||||
const seat = seatFor(sessionId)
|
||||
return {
|
||||
hooks: { agentPresetSeat: seat.store },
|
||||
load: () => seat.load(),
|
||||
select: (id: string) => seat.select(id),
|
||||
const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => {
|
||||
const state = scope.sessions.list.getSnapshot()
|
||||
const summary = state.current === undefined ? undefined : state.byId[state.current]
|
||||
return summary === undefined
|
||||
? undefined
|
||||
: {
|
||||
id: summary.id,
|
||||
blank: summary.blank,
|
||||
...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset },
|
||||
}
|
||||
},
|
||||
}, AgentPresetSeat), 'ui-agent-preset: composer seat registration')
|
||||
})
|
||||
|
||||
const seatInjected = (): AgentPresetSeatInjected => ({
|
||||
hooks: { agentPresetSeat: seat.store },
|
||||
load: () => seat.load(),
|
||||
select: (id: string) => seat.select(id),
|
||||
})
|
||||
|
||||
const labelInjected = (): AgentPresetLabelInjected => ({
|
||||
hooks: { agentPresets: controller.store },
|
||||
load: () => controller.load(),
|
||||
})
|
||||
|
||||
scope.effect(() => {
|
||||
// Connecting a workspace either creates a blank session or reuses one,
|
||||
// and either way the chip's pick predates it — so the stage is applied
|
||||
// when the session arrives, not when it was made.
|
||||
const stop = scope.sessions.list.subscribe(() => { void seat.apply() })
|
||||
const chip = scope.slots.register({
|
||||
name: 'conversation.hero.agentPreset',
|
||||
locale: 'settings.agentPreset',
|
||||
inject: seatInjected,
|
||||
}, AgentPresetSeat)
|
||||
const label = scope.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'agent-preset',
|
||||
order: 20,
|
||||
locale: 'settings.agentPreset',
|
||||
inject: labelInjected,
|
||||
}, AgentPresetLabel)
|
||||
return () => {
|
||||
stop()
|
||||
chip()
|
||||
label()
|
||||
}
|
||||
}, 'ui-agent-preset: new-session chip and header label')
|
||||
})
|
||||
|
||||
const sectionInjected = (): AgentPresetSectionInjected => ({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Locale bundles for the agent-preset settings row, composer seat, and management section. */
|
||||
/** Locale bundles for the agent-preset settings row, hero chip, header label, and management section. */
|
||||
|
||||
/** Locale keys these surfaces render. */
|
||||
export type AgentPresetSettingsKey =
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint'
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint'
|
||||
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
|
||||
| 'duplicate' | 'delete' | 'newPreset' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||
| 'displayName' | 'displayNamePlaceholder' | 'displayDescription' | 'displayDescriptionPlaceholder'
|
||||
@@ -18,8 +18,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
loading: 'Loading presets…',
|
||||
error: 'Could not load agent presets.',
|
||||
userTrust: 'Custom',
|
||||
seatHint: 'Agent preset for this session — switchable until you send the first message',
|
||||
lockedHint: 'This session\'s agent preset is fixed once the conversation starts',
|
||||
seatHint: 'Agent preset for the session you are about to start',
|
||||
headerHint: 'The agent preset this session runs, fixed when it started',
|
||||
nav: 'Agent presets',
|
||||
sectionIntro:
|
||||
'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. '
|
||||
@@ -68,8 +68,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
loading: '正在加载预设…',
|
||||
error: '无法加载 Agent 预设。',
|
||||
userTrust: '自定义',
|
||||
seatHint: '本会话的 Agent 预设 —— 发送第一条消息前可切换',
|
||||
lockedHint: '会话开始后,其 Agent 预设即固定',
|
||||
seatHint: '即将开始的这个会话所用的 Agent 预设',
|
||||
headerHint: '本会话运行的 Agent 预设,开始时即固定',
|
||||
nav: 'Agent 预设',
|
||||
sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。内置预设只读;复制一份即可改成自己的。',
|
||||
builtIn: '内置',
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* Composer-seat controller: what one session may switch to, and whether it
|
||||
* still may.
|
||||
* Hero-chip controller: which preset the NEXT session gets.
|
||||
*
|
||||
* A session's composition is fixed once its conversation starts, so the seat
|
||||
* reads the session's own `blank` bit rather than a local guess — the host
|
||||
* enforces the same rule and answers `agent-preset-locked` to a late attempt.
|
||||
* The new-session screen has no session, so a pick is staged rather than
|
||||
* applied. It reaches a session when one becomes current and is still blank —
|
||||
* whether the workspace connect created it or reused an existing blank one,
|
||||
* which is why staging cannot simply ride along on `sessions.create`.
|
||||
*
|
||||
* The stage is forgotten once applied: the next new session starts from the
|
||||
* deployment default again, matching the workspace picker beside it.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -14,33 +17,49 @@ import {
|
||||
import { messageOf } from './settings-store.ts'
|
||||
import type { AgentPresetOption } from './settings-store.ts'
|
||||
|
||||
/** Composer-seat snapshot for one session. */
|
||||
/** Hero-chip snapshot. */
|
||||
export interface AgentPresetSeatState {
|
||||
/** Presets the deployment supplies; empty means the seat renders nothing. */
|
||||
/** Presets the deployment supplies; empty means the chip renders nothing. */
|
||||
options: readonly AgentPresetOption[]
|
||||
/** The preset this session runs, empty until the roster and summary load. */
|
||||
/** The staged choice, empty until the roster loads. */
|
||||
current: string
|
||||
/** False once the conversation has started — the switch is gone for good. */
|
||||
switchable: boolean
|
||||
/** A rejected switch's message, cleared by the next attempt. */
|
||||
/** A rejected apply's message, cleared by the next attempt. */
|
||||
error: string | null
|
||||
busy: boolean
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSeatState = {
|
||||
options: [], current: '', switchable: false, error: null, busy: false,
|
||||
options: [], current: '', error: null, busy: false,
|
||||
}
|
||||
|
||||
/** Reads what one session may switch to and performs the switch. */
|
||||
/** One session's identity and whether it has started. */
|
||||
export interface SeatSessionSummary {
|
||||
/** The session the chip would apply its staged choice to. */
|
||||
id: SessionId
|
||||
/** False once a turn has run — applying is refused from then on. */
|
||||
blank: boolean
|
||||
/** The preset the session already runs, when the summary reports one. */
|
||||
agentPreset?: string
|
||||
}
|
||||
|
||||
/** Stages the next session's preset and applies it when one appears. */
|
||||
export class AgentPresetSeatController {
|
||||
/** Seat snapshot the renderer subscribes to. */
|
||||
/** Chip snapshot the renderer subscribes to. */
|
||||
readonly store: SnapshotStore<AgentPresetSeatState> = createSnapshotStore(INITIAL)
|
||||
|
||||
/**
|
||||
* The deployment default, so a consumed stage can fall back to it without
|
||||
* re-reading the roster.
|
||||
*/
|
||||
private fallback = ''
|
||||
|
||||
/** Set while a pick is waiting for a session; cleared once applied. */
|
||||
private staged: string | undefined
|
||||
|
||||
constructor(
|
||||
private readonly api: IApiClient,
|
||||
private readonly sessionId: SessionId,
|
||||
/** Reads this session's blank bit and recorded preset from the session list. */
|
||||
private readonly summary: () => { blank: boolean; agentPreset?: string } | undefined,
|
||||
private readonly api: Pick<IApiClient, 'agentPresets'>,
|
||||
/** The session the hero is about to hand over to, when there is one. */
|
||||
private readonly currentSession: () => SeatSessionSummary | undefined,
|
||||
) {}
|
||||
|
||||
private set(patch: Partial<AgentPresetSeatState>): void {
|
||||
@@ -48,25 +67,26 @@ export class AgentPresetSeatController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the roster and reconcile with this session's own state.
|
||||
* Read the roster and open the chip on the deployment default.
|
||||
* @returns once the snapshot reflects the host.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const summary = this.summary()
|
||||
try {
|
||||
const response = await this.api.agentPresets.list({})
|
||||
if (!response.result.ok) {
|
||||
this.set({ error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
const presets = response.result.value.presets
|
||||
const { presets } = response.result.value
|
||||
this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
|
||||
this.set({
|
||||
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
|
||||
// The session's recorded preset wins over the roster default: a
|
||||
// resumed session runs what it was created with, not what the
|
||||
// deployment now prefers.
|
||||
current: summary?.agentPreset ?? presets.find(preset => preset.isDefault)?.id ?? '',
|
||||
switchable: summary?.blank ?? false,
|
||||
options: presets.map(preset => ({
|
||||
id: preset.id,
|
||||
trust: preset.trust,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
...preset.description === undefined ? {} : { description: preset.description },
|
||||
})),
|
||||
current: this.staged ?? this.fallback,
|
||||
error: null,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -75,23 +95,48 @@ export class AgentPresetSeatController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch this session to another preset.
|
||||
* @param id - the preset to compose the session's agent from.
|
||||
* @returns once the switch settled; a rejection leaves the previous value.
|
||||
* Stage one preset for the next session, applying it immediately when a
|
||||
* blank session is already current.
|
||||
* @param id - the preset to stage.
|
||||
* @returns once the stage settled, and the apply too when one happened.
|
||||
*/
|
||||
async select(id: string): Promise<void> {
|
||||
const before = this.store.getSnapshot()
|
||||
if (before.busy || id === before.current || !before.switchable) return
|
||||
this.set({ busy: true, error: null, current: id })
|
||||
if (this.store.getSnapshot().busy) return
|
||||
this.staged = id
|
||||
this.set({ current: id, error: null })
|
||||
await this.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the staged choice to the current session, if there is one to take it.
|
||||
*
|
||||
* Called both by `select()` and by whoever observes the current session
|
||||
* changing, because the session may appear either before or after the pick.
|
||||
* @returns once the switch settled, or immediately when there is nothing to do.
|
||||
*/
|
||||
async apply(): Promise<void> {
|
||||
const staged = this.staged
|
||||
const session = this.currentSession()
|
||||
if (staged === undefined || session === undefined) return
|
||||
// A started session's history was produced under its own composition; the
|
||||
// host refuses the swap, so the stage is no longer meaningful.
|
||||
if (!session.blank || session.agentPreset === staged) {
|
||||
this.staged = undefined
|
||||
return
|
||||
}
|
||||
this.set({ busy: true, error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.select({ sessionId: this.sessionId, agentPreset: id })
|
||||
const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged })
|
||||
this.staged = undefined
|
||||
if (!response.result.ok) {
|
||||
this.set({ busy: false, current: before.current, error: response.result.error.message })
|
||||
this.set({ busy: false, error: response.result.error.message, current: this.fallback })
|
||||
return
|
||||
}
|
||||
// Consumed: the next new session opens on the deployment default again.
|
||||
this.set({ busy: false, current: response.result.value.agentPreset })
|
||||
} catch (error) {
|
||||
this.set({ busy: false, current: before.current, error: messageOf(error) })
|
||||
this.staged = undefined
|
||||
this.set({ busy: false, error: messageOf(error), current: this.fallback })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,14 @@ export async function writeDefaultPreset(
|
||||
|
||||
/** One selectable preset. */
|
||||
export interface AgentPresetOption {
|
||||
/** Preset id, written to Settings and shown as the label. */
|
||||
/** Preset id, written to Settings and the label's fallback. */
|
||||
id: string
|
||||
/** Whether the preset ships with the deployment or was authored locally. */
|
||||
trust: 'system' | 'user'
|
||||
/** Display name the preset published, absent when it published none. */
|
||||
name?: string
|
||||
/** One sentence on what the preset is for. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Agent-preset settings-row snapshot. */
|
||||
@@ -109,7 +113,12 @@ export class AgentPresetSettingsController {
|
||||
this.set({
|
||||
status: 'ready',
|
||||
error: null,
|
||||
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
|
||||
options: presets.map(preset => ({
|
||||
id: preset.id,
|
||||
trust: preset.trust,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
...preset.description === undefined ? {} : { description: preset.description },
|
||||
})),
|
||||
// A roster can mark nothing default: settings can name a preset that
|
||||
// was since deleted, and the picker still has to show something.
|
||||
currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id,
|
||||
|
||||
Reference in New Issue
Block a user