Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui

The Client API carrier's `agentPresets` member was the one member of its class
without an `IApiClient[...]` annotation. Inferring it inlined `AgentPresetEntry`
into the emitted declaration by the specifier TS picks — the host `index.ts` —
dragging the whole gateway, and with it the host `Context` merges, into every
Client program importing the carrier. Annotated like its siblings.

`ApiRemoteAgentOptions.setup` now takes the inspected session rather than its
header alone: this layer resolves a resumed session's preset from the LOG,
because a session that switched while blank ran its turns under the newer
composition and the header is written once at creation.

Conflicts:
	apps/web/tests/snapshots/*/*.expected.md
	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
	packages/host/apiproxy/src/api-proxy.ts
	scripts/doc-budgets.manifest.json
This commit is contained in:
Yichen Jiang
2026-08-08 15:00:31 +08:00
649 changed files with 21091 additions and 2838 deletions

View File

@@ -15,6 +15,8 @@ import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import type { IConversation } from './service.ts'
import { ComposerBlockRegistry } from './input/blocks.ts'
import type { ComposerBlock } from './input/blocks.ts'
import { InputHub } from './input/hub.ts'
import { ComposerSubmissionPolicy } from './input/submission-policy.ts'
import { InputBar } from './skeleton/InputBar.tsx'
@@ -54,6 +56,11 @@ const ABSENT_NOTICES = {
getSnapshot: (): InputNotice | null => null,
subscribe: () => () => {},
}
/** No session, therefore nothing to block; same one-identity rule as above. */
const ABSENT_BLOCK = {
getSnapshot: (): ComposerBlock | undefined => undefined,
subscribe: () => () => {},
}
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
const ABSENT_LEXICON = {
getSnapshot: () => EMPTY_LEXICON,
@@ -133,6 +140,12 @@ export function apply(ctx: Context): void {
// ctx.conversation.input by the service below sharing this one instance).
const inputHub = new InputHub(ctx)
// The composer-block registry: a plugin that knows a session cannot send —
// ui-model, when no adapter serves the session's route — raises a block
// here, and the bar reads its own session's store. It cannot flow the other
// way: this package must not import the plugins that would know.
const composerBlocks = new ComposerBlockRegistry()
// Decision 19/20: the input machine feeds every session-scope slot
// component through the standard provide channel — the 'input' hook plus
// the two public actions. Materialization is the shell creation trigger
@@ -167,6 +180,7 @@ export function apply(ctx: Context): void {
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
},
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) },
selectWorkspace: async (workspaceId) => {
const nextId = await workspaces.connectWorkspace(workspaceId)
if (sessionId !== undefined && nextId !== sessionId) {
@@ -304,6 +318,7 @@ export function apply(ctx: Context): void {
children: {
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
@@ -352,7 +367,7 @@ export function apply(ctx: Context): void {
// registers itself as `conversation` and lives on its own child fiber.
// Presentation registrants depend directly on their slot declarations;
// this service remains only where conversation actions are required.
ctx.plugin(ConversationService, { input: inputHub })
ctx.plugin(ConversationService, { input: inputHub, blocks: composerBlocks })
// The bash sample rides the same declaration seam, in third-party posture
// (ToolRow-matching Bash · {description} chrome).

View File

@@ -11,10 +11,11 @@
import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import {
IconThinkOutline14, JsonBlock, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
import { hasContentText } from './chat-flow.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
@@ -40,6 +41,8 @@ export interface AssistantMarkdownProps {
seq?: number | undefined
/** Fork the session through this finalized message's completed turn when eligible. */
onFork?: ((seq: number) => void) | undefined
/** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */
turnTail?: (Pick<PropsRenderSlots<'conversation.chat.turnTail'>, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined
/** The message is not the transcript tail of a completed turn. */
forkUnavailable?: boolean | undefined
/** The owning view's locale seat, passed down as a plain prop. */
@@ -83,7 +86,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, t,
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
@@ -121,6 +124,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
})}
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
</div>
{showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)}
{showActions && (
<MessageIconActions
text={copyText(blocks)}

View File

@@ -335,7 +335,7 @@ function StreamingTail({ useSession, t }: {
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const turnTimings = useSession(s => s.turnTimings)
@@ -600,6 +600,9 @@ export function ChatView({
seq={node.seq}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
turnTail={actionSeqs.has(node.seq)
? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } }
: undefined}
t={t}
/>
)

View File

@@ -103,7 +103,7 @@
color: var(--dsw-alias-label-tertiary);
}
/* File-tool path: same geometry as .summary; hover underline + pointer. */
/* File-tool path: same geometry as .summary, with a persistent link affordance. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
@@ -118,12 +118,16 @@
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
color: var(--dsw-alias-label-secondary);
text-decoration: underline;
text-decoration-color: var(--dsw-alias-label-quaternary);
text-underline-offset: 3px;
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
color: var(--dsw-alias-label-primary);
text-decoration-color: currentColor;
}
/* Error row's collapsed summary: the failure's first line in the error color. */

View File

@@ -3,8 +3,9 @@ import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts'
@@ -46,6 +47,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* registration, and a domain upgrades by registering one row component.
*/
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
/**
* The chat view's turn-tail chain: rendered between a closing assistant
* message's body and its IconActions footer, once per turn (the render
* site elects the closing seq). Entries derive a match from the owner
* currency before mounting, so presentation components never mount only
* to return null; an all-declined chain renders nothing.
*/
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
/**
* The composer takeover chain: entries are selector-routed replacements
* of the default InputBar. Declared by this package's 'conversation'
@@ -156,6 +165,24 @@ export interface ConvViewOwnerProps {
onInspectDone?: () => void
}
/**
* Owner currency of the chat view's turn-tail hole: the finalized snapshot
* and the closing assistant's anchor. Registrants derive their own facts
* from the nodes (the owner never pre-chews a feature's vocabulary), and
* open files through the same opener the tool rows use.
*/
export interface TurnTailOwnerProps {
/** Finalized snapshot nodes in surface order. */
nodes: readonly ConversationNode[]
/** The closing assistant's seq — the anchor the tail renders under. */
seq: number
/**
* Open a filesystem path through the Host (tool-row semantics; the chat
* view resolves relative paths against the session cwd).
*/
openFile: (path: string) => void
}
/**
* Owner share of a per-view toolview slot: the call material the rendering
* view supplies per row. Uniform across views — the trajectory/waterfall
@@ -229,6 +256,12 @@ export interface ConversationInjected {
* When a blank session is already current, carry its draft to the target.
*/
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
/**
* Framework-bound sources. `composerBlock` is this session's block when a
* plugin raised one; the reason is the blocker's own localized copy, which
* the root renders as the inert composer's placeholder.
*/
hooks: { composerBlock: ObservableSnapshot<ComposerBlock | undefined> }
}
/** Business callbacks injected into the strict Session body seat. */
@@ -264,6 +297,14 @@ export interface ConversationSessionHeaderInjected {
export interface ComposerBarOwnerProps {
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
/**
* A block another plugin raised for this session: the bar refuses input and
* shows the blocker's reason as the placeholder, but — unlike `disabled` —
* keeps the model seat live. Every block this contract has is one the user
* clears by choosing a model, so locking that seat too would leave the
* composer telling them to do the one thing it prevents.
*/
blocked?: { readonly reason: string }
/**
* Inert no-workspace state: the bar renders its normal DOM fully disabled
* (textarea, add, send) so the workspace pick transitions in place instead
@@ -362,7 +403,7 @@ export type ConversationSlotProps =
| 'conversation.input.left' | 'conversation.input.right'
| 'conversation.hero.workspace'
>
& ConversationInjected
& InjectFace<ConversationInjected>
& PropsLocale<'conversation'>
/** Full strict-session body props: per-session store, view ring, and draft mirror. */
@@ -486,7 +527,7 @@ export interface ChatViewInjected {
/** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'>
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'>
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
/**

View File

@@ -17,7 +17,7 @@ export type {
ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps,
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, TurnTailOwnerProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -0,0 +1,77 @@
/**
* Composer blocks: the one way another plugin stops a session's input.
*
* The composer cannot read the plugins that would know — the dependency runs
* ui-model → ui-conversation, never back — so a blocker pushes here and the
* bar reads its own session's store. A block carries the localized reason it
* exists, because the plugin that raised it owns that copy; the composer only
* knows how to render an inert textarea with a placeholder, exactly as it
* already does for a session with no workspace.
*
* This is an affordance, not enforcement: the Host refuses a prompt it cannot
* route regardless of what any client disables.
*/
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** Why one session's composer is inert. */
export interface ComposerBlock {
/**
* Localized placeholder replacing the composer's own, owned by the plugin
* that raised the block.
*/
readonly reason: string
}
/** The registry face other plugins reach through `ctx.conversation.blocks`. */
export interface ComposerBlocks {
/**
* Raise or clear this session's block. Idempotent: setting a block equal to
* the current one, or clearing an absent one, notifies nobody.
* @param sessionId - the session whose composer is affected.
* @param block - the block to raise, or undefined to clear it.
*/
set(sessionId: SessionId, block: ComposerBlock | undefined): void
/**
* The store the composer subscribes to for one session. Created on first
* read from either side, so a blocker may raise a block before the session's
* composer mounts and the composer still sees it.
* @param sessionId - the session to observe.
* @returns that session's block store (undefined value = not blocked).
*/
storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined>
/**
* Drop one session's store. The session scope's disposer calls this; a
* blocker never needs to.
* @param sessionId - the session being torn down.
*/
forget(sessionId: SessionId): void
}
/** The per-session composer-block registry (one instance per plugin fiber). */
export class ComposerBlockRegistry implements ComposerBlocks {
private readonly stores = new Map<SessionId, SnapshotStore<ComposerBlock | undefined>>()
/** @inheritdoc */
set(sessionId: SessionId, block: ComposerBlock | undefined): void {
const store = this.storeFor(sessionId)
const current = store.getSnapshot()
if (current?.reason === block?.reason) return
store.set(block)
}
/** @inheritdoc */
storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined> {
const existing = this.stores.get(sessionId)
if (existing !== undefined) return existing
const created = createSnapshotStore<ComposerBlock | undefined>(undefined)
this.stores.set(sessionId, created)
return created
}
/** @inheritdoc */
forget(sessionId: SessionId): void {
this.stores.delete(sessionId)
}
}

View File

@@ -44,7 +44,7 @@ export const zh = {
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
'access.confirm.cancel': '取消',
'access.confirm.enable': '启用 Full access',
'hero.headline': '开始构建吧',
'hero.headline': '探索未知之境',
'hero.preview': '预览版',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
@@ -184,7 +184,7 @@ export const en = {
'access.confirm.acknowledge': 'I understand the risks and want to continue',
'access.confirm.cancel': 'Cancel',
'access.confirm.enable': 'Enable Full access',
'hero.headline': 'Let\'s start building',
'hero.headline': 'Into the Unknown',
'hero.preview': 'Preview',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',

View File

@@ -14,6 +14,7 @@ import type { Context } from 'cordis'
// method) instead of the standalone helper.
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { QueueAction, QueueItemId } from './contract/queue.ts'
import type { ComposerBlocks } from './input/blocks.ts'
import type { InputService } from './input/contract.ts'
/**
@@ -24,6 +25,11 @@ import type { InputService } from './input/contract.ts'
export interface IConversation {
/** The per-session input machine registry (InputService face). */
readonly input: InputService
/**
* The per-session composer-block registry: how a plugin the composer
* cannot import makes a session's input inert with its own reason.
*/
readonly blocks: ComposerBlocks
/**
* Send a prompt into the caller scope's session (queued turn).
* @param text - prompt text, sent verbatim as one text block.
@@ -53,16 +59,20 @@ export interface IConversation {
export class ConversationService extends Service implements IConversation {
/** The per-session input machine registry (InputService face, design §5.2). */
readonly input: InputService
/** The per-session composer-block registry. */
readonly blocks: ComposerBlocks
/**
* @param ctx - owning root context (the plugin apply context; the service
* registers itself and follows that fiber's lifetime).
* @param config - carries the InputService instance constructed by the
* plugin apply (the same InputHub the slot inject factories close over).
* @param config - carries the InputService and composer-block registry
* constructed by the plugin apply (the same instances the slot inject
* factories close over).
*/
constructor(ctx: Context, config: { input: InputService }) {
constructor(ctx: Context, config: { input: InputService; blocks: ComposerBlocks }) {
super(ctx, 'conversation')
this.input = config.input
this.blocks = config.blocks
}
/**

View File

@@ -13,7 +13,7 @@ import css from './ConversationRoot.module.css'
export type ConversationRootProps = ConversationSlotProps
export function ConversationRoot({
sessionId, useSession, useSessions, useWorkspaces, useInput,
sessionId, useSession, useSessions, useWorkspaces, useInput, useComposerBlock,
renderSlot, renderSlotChain, selectWorkspace, t,
}: ConversationRootProps) {
const openState = useSession(s => s.openState)
@@ -24,6 +24,9 @@ export function ConversationRoot({
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
const workspaces = useWorkspaces(s => s)
// A plugin this package cannot import (ui-model) says this session cannot
// send; its reason is already localized by whoever raised it.
const composerBlock = useComposerBlock(block => block)
const [pickerOpen, setPickerOpen] = useState(false)
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
@@ -126,11 +129,20 @@ export function ConversationRoot({
// bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
// not a different tree, so the textarea DOM survives the transition.
const inert = sessionId === undefined || (hero && chipTitle === undefined)
// A raised block is the same inert posture with the blocker's own reason:
// one disabled textarea, never a second tree. The no-workspace state wins
// when both hold — picking a workspace is the earlier prerequisite.
const blocked = !inert && composerBlock !== undefined
const inputBar = renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(inert
? { disabled: true, placeholder: t('placeholder.workspace') }
: hero ? { placeholder: t('placeholder.hero') } : {}),
: blocked
// `blocked`, not `disabled`: the bar refuses input either way, but a
// block keeps the model seat live because choosing a model is how the
// user clears it.
? { blocked: composerBlock, placeholder: composerBlock.reason }
: hero ? { placeholder: t('placeholder.hero') } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),

View File

@@ -24,12 +24,12 @@
}
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. The preview
badge is a product addition outside that source and aligns to the title. */
badge is a product addition outside that source: a mono superscript pill
riding the title's top-right. */
.headline {
display: grid;
grid-template-columns: 34px auto;
grid-template-columns: 34px auto auto;
column-gap: 10px;
row-gap: 4px;
align-items: center;
justify-content: center;
font-size: 26px;
@@ -44,13 +44,17 @@
}
.previewBadge {
grid-row: 2;
grid-column: 2;
justify-self: start;
padding: 0 4px;
border-radius: 4px;
grid-row: 1;
grid-column: 3;
align-self: start;
margin-top: 2px;
margin-left: -3px;
padding: 1px 7px 0;
border: 1px solid var(--dsw-alias-interactive-bg-hover);
border-radius: 24px;
background: var(--dsw-alias-state-business-tertiary);
color: var(--dsw-alias-label-primary);
color: var(--dsw-alias-label-primary-bluish);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
font-weight: 500;

View File

@@ -37,7 +37,8 @@ export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t,
renderSlot, useNotices, useLexicon, useMenuLauncher,
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
useProjection, sessionId, variant, disabled: inert = false, blocked, placeholder,
accessory, overlay, leftItems, rightItems, footer,
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
@@ -86,8 +87,13 @@ export function InputBar({
// inert no-workspace state, or the machine faces absent (no session). The
// transient machine locks (adjudicating pending / submitting) render
// read-only — the draft stays visible and focused, keystrokes drop.
const disabled = removed || inert || !live
const disabled = removed || inert || !live || blocked !== undefined
const locked = disabled
// The model seat is the ONE control a block leaves live: every block this
// contract has is cleared by choosing a model, so locking it too would leave
// the composer asking for the only thing it prevents. The other reasons to
// be disabled do lock it — there is no session to choose a model for.
const modelSeatLocked = removed || inert || !live
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
// Scroll the draft scrollport the minimum that brings `caret` into view — the
@@ -513,7 +519,7 @@ export function InputBar({
<div className={css.trailing}>
{rightItems}
{renderSlot('conversation.input.agentPreset', { locked })}
{renderSlot('conversation.input.model', { locked })}
{renderSlot('conversation.input.model', { locked: modelSeatLocked })}
<ContextMeter useProjection={useProjection} t={t} />
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
<Tooltip label={primaryLabel} side="top" delayMs={500}>