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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user