Merge pull request #1860 from deepseek-harness/worktree/default-model-persistence

feat: the composer model switch becomes the default for new sessions
This commit is contained in:
imccyu
2026-08-07 19:37:07 +08:00
committed by GitHub
104 changed files with 1743 additions and 264 deletions
@@ -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) {
@@ -352,7 +366,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).
@@ -5,6 +5,7 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
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'
@@ -249,6 +250,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. */
@@ -284,6 +291,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
@@ -382,7 +397,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. */
@@ -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)
}
}
@@ -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
}
/**
@@ -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),
@@ -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
@@ -512,7 +518,7 @@ export function InputBar({
</div>
<div className={css.trailing}>
{rightItems}
{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}>