DSH / Atlas
2026-07-25implementedarchitecture

Web input state machine, composer slots, and the slash pipeline (ui-conversation input / ui-input-trigger)

Web 输入状态机、composer slot 与 slash 流水线(ui-conversation input / ui-input-trigger)

Two composers, each a law unto itself: hero (EmptyState, the controlled chain writing straight into the Session) and the in-conversation InputBar (a plain controlled textarea) — behavior, draft ownership, and send path all inconsistent. To bring the three trigger families — `/` commands, skill references, `@` references — onto the input surface, these had to be answered: - How the three trigger families layer, and wh

English

Problem

Two composers, each a law unto itself: hero (EmptyState, the controlled chain writing straight into the Session) and the in-conversation InputBar (a plain controlled textarea) — behavior, draft ownership, and send path all inconsistent. To bring the three trigger families — / commands, skill references, @ references — onto the input surface, these had to be answered:

  • How the three trigger families layer, and who holds knowledge of "commands" versus who stays zero-knowledge;
  • How the input box expresses "command mode" — derived from the draft text or explicit state? What do backspace, enter, space, and pasting a whole line each mean;
  • Submission is an asynchronous transaction (an RPC round trip) — how are stale-result backwash, session switching, and React concurrent replay defended;
  • How reference chips are represented on a plain textarea, and who owns undo / clipboard / paste matching / model serialization;
  • How cross-plugin input rewrites (menu backfill, reference insertion, token consumption) achieve dependency inversion;
  • Which React shells must be reused across no session → blank session, and which strict-session input bodies may be replaced.

Hard constraints: components mount through slots only; presentation artifacts never enter the session log; the keyboard path is IME-safe throughout.

Decision

The input state machine (InputMachine)

A pure state machine, events in / effects out, clock injected. Four phases (plain / adjudicating / claimed / submitting). Command mode is never derived from the draft; the pick paths establish it explicitly at discrete moments; the claim is watched by draft.startsWith(token), with a backspace break releasing automatically; the claim shape is {token, hint?} (hint feeds ghost text).

The event surface (dispatch(ev) is the single write entry; one transaction per event):

  • draft-changed {draft, editRange?} — the textarea's full draft; editRange narrows the occurrence-shift computation, defaulting to a shared prefix/suffix scan.
  • newline {selection} — the Ctrl+Enter line break (not via the browser's execCommand: under self-managed undo a browser write forks two histories).
  • begin-command {claim, span} / insert-ref {reference, span} / consume-token {guard} — the machine side of the three bail events; span CAS = draftRev equality.
  • set-invalid {invalidIds} — the style bit for owner-resolution results (not a transaction).
  • undo / redo — the self-managed transaction log (a ring of 100; single-character typing merges within injected-clock windows; a successful submit clears the log).
  • paste-begin {text, selection, components?, generation?} — the paste plus hot-snapshot synchronously matched components in one transaction (one Undo returns to before the paste); opens a PasteMatchAttempt.
  • paste-upgrade {attemptId, span, reference} — an asynchronous match upgrade as its own transaction (Undo in two steps); the attempt stays current, and insertedRange shrinks with each upgrade.
  • invalidate-paste — attempt-ending gestures observed at the DOM layer (caret/selection operations and the like).
  • enter {mode} / adjudicated / adjudication-failed / submit-settled / release — the submit-transaction plane: a SubmitAttempt (seq + AbortSignal) blocks backwash; success commits and clears the draft; failure rolls back under the drift guard (the enter-time snapshot is backfilled only while the live draft still equals it; if the user has typed again, only a notice fires).

The effect surface (executed by the shell): adjudicate (calls InputTriggerController.adjudicate), begin-submit (the claim.submit transaction), default-sink (ordinary messages, hub-orchestrated), notice.

The occurrence table and the chip's three projections:

  • Each reference occupies one U+FFFC in the draft; a table entry is {occurrenceId, source, ref, offset, label, clipboardText, invalid?}; same-named chips stay independent through occurrenceId.
  • Every edit updates the draft and the table in one transaction: ranges shift; a deletion/replacement intersecting a placeholder acts on the whole chip.
  • The single-character placeholder makes keyboard atomicity mostly hold natively (the caret has no interior position; Backspace / arrow keys / Shift extension natively take the whole chip); a mouse click on a chip goes backdrop hit → whole-chip setSelectionRange.
  • The visual projection = label: the backdrop renders the chip at the placeholder offset (the textarea glyph is invisible), with invalid taking the invalid style.
  • The clipboard/persistence projection = clipboardText: copy/cut expands placeholders inside the selection; the draft-persistence mirror writes the same projection (the chat store always holds plain text; the refresh seed semantics = select-all copy → reopen → paste, with chips degrading to text across a refresh).
  • The model projection = generated per chip at submit through the source's codec.serialize (owned by the submit attempt's signal and stale guard; a missing owner / failure / cancel means no send, never a downgrade to /name).

Cross-plugin input rewrites: three scoped bail events

The contract is declared in ui-input-trigger (the bottom of the dependency chain); producers dispatch via sctx.bail(sctx, ...), and the only consuming side is the three listeners the hub hangs on the sctx when building the shell; returning true ⟺ the machine passed the phase and CAS guards and actually rewrote (emitting the event ≠ a successful modification; whether Space gets preventDefault follows the return value):

  • slash/input-begin-command {claim, span} — backfill of the command claim adjudicated from a menu pick / Space (dispatched by the InputTriggerController).
  • slash/input-insert-reference {reference, span} — reference chip insertion (dispatched by the InputTriggerController).
  • slash/input-consume-token {guard: span | bare-token} — consuming the command token after business success (dispatched by the downstream command surfaces).

Calls that stay un-evented (registry registration → explicit call → await): Input's own draft/submit, asynchronous Enter adjudication, the reference serializer, the asynchronous paste matcher. @mode bail has entered the JSDoc parser and the cordis catalog gate (scripts/jsdoc.ts).

The slash pipeline (ui-input-trigger: a root InputTriggerService + a per-session InputTriggerController)

A trigger/menu/pick pipeline with zero knowledge of "commands":

  • The service holds only the source registry (InputTriggerSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}; (trigger,name) unique; the optional order sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and sessionOf(sctx). Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects).
  • The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). toggleSource(name, syntheticHit) is the chrome-launch path: it seeds only that registered source over the caller's textarea selection and publishes launcher = name until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same onPick chain. A dismiss() verb backs MenuView's injected onDismiss (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the slash.menu locale namespace and clamps its height to the viewport space above the composer via ui-primitives' useAnchoredMaxHeight); at each session scope's birth it runs warm(projection) once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
  • Trigger-detection word boundaries (user@host and URL / never trigger) and the guard tiers (plain: / everywhere + @ inline / claimed: / suppressed, @ live / frozen: none) are the frozen pure core.

hub / facade: the resident shell and the strict-session input body

  • The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional ctx.get() dependencies: without ui-input-trigger or the command surfaces, input still sends and receives normally — graceful degradation.
  • Each materialized Session has exactly one SessionInputShell (the facade), created and torn down with the session scope; with no session, no input machine is built. ConversationRoot is itself the session-maybe resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. It always owns the same scrollport and composer seat; separate strict-session header and body outlets fill those fixed regions after a Session appears.
  • The composer bar is one session-maybe slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, disabled owner prop), and once connectWorkspace returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; ConversationRoot, the Hero, and the layout skeleton hold throughout.
  • ConversationRoot's Hero criterion is sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true)): a summary-proven blank Session remains Hero in every open state, while an unproven Session settles during loading. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted.
  • Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through session.prompt with mode:'queue' (the Web UI has no steer entry; host-wire mode:'steer' remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists.
  • When the blank Hero re-picks the Workspace, the shell calls connectWorkspace; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current.
  • The Notifier's two-bit contract: dirty (snapshot freshness, clearable by an ensureFresh pull) and notifyPending (notification debt, cleared only by a flush) are mutually independent — a pull must not swallow a push, and object-layer push subscribers (watchTransaction) depend on this guarantee.

Plain-text references: text outcomes and lexicon decoration

skill/@subagent references skip the placeholder + occurrence identity chain — the plain-text-reference decision: a pick inserts the literal /name @name text straight into the draft, with the chip visual purely derived:

  • PickOutcome gains a {text} arm; the new scoped bail event slash/input-insert-text {text, span} (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
  • Sources get an optional lexicon?(session) hook: a synchronous hot-snapshot name roster, with undefined = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional subscribeLexicon?(session, listener) hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its lexicon snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast.
  • decorations.scanTextRefs: a word-boundary scan of the draft (/name, @name at line start / after whitespace; x/name never hits) against the roster; a hit gets the .textRef mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan.
  • Sending is the literal text (no more <skill> serialization); on the bubble side MessageItem decorates both shapes (the legacy <skill> tag + plain-text tokens).
  • The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Decoration reactivity: InputBar subscribes to the shell's lexicon source (uSES), so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render.

Per-session provide contributions and the private keyboard surface

  • ui-conversation (the hub doubling as a contributor) supplies through sessions.provide the 'input' hook (machine state + the queue overlay) plus the inputActions prop (setDraft/submit, stable void callbacks).
  • The public/private boundary: the public provide carries only React-vocabulary members; the keyboard/DOM command surface (track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror — synchronous return values, disposer semantics) is InputBar-exclusive, passed privately in-package through the InputBar entry's own inject, never leaving the plugin boundary.

The slot system

conversation is itself session-maybe; its session content and the composer input slots are strict session, while the Hero Workspace picker stays root. The root registration renders the header outlet above its resident scrollport and the body outlet inside it, before the resident composer seat. The child slots are all declared by ui-conversation's conversation registration:

  • conversation.session.header (single) — strict-session breadcrumb, view tabs, and header actions above the resident scrollport.
  • conversation.session (single) — the strict-session view ring and draft mirror inside the resident scrollport. Header and body share the same session-scoped chat store; each is rebuilt when the session id switches.
  • conversation.composer.bar (single) — the slot for the InputBar itself: the InputBar is a true slot entry (self-registered into its own slot) and the content of the composer chain's fallback; it is not a chain entry — the chain's single election would unmount it on a takeover, breaking textarea DOM survival.
  • conversation.input.overlay — the floating-overlay anchor inside the input card; registrants' inject resolves each one's own per-session controller by the slot sessionId.
  • conversation.input.dock — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by order.
  • conversation.composer.dock — the stats band on the composer's top edge.
  • conversation.input.left / conversation.input.right — the tool-row left and right regions.
  • conversation.input.plan / conversation.input.model (single) — the tool row's two named control seats; the bar passes only locked (owner props), each stays empty until its owning plugin registers, no placeholder fallback. The plan seat stays empty while inactive because the shared Command source owns entry; an effective plan target renders the warn-state Plan × status button, whose only action is /plan off.
  • conversation.hero.workspace (root scope) — the Workspace picker shared by the no-session and blank Hero; a pick reuses or creates the target blank session through connectWorkspace, moving the draft where necessary before switching current.

Testing discipline

The state machine's entire behavior is covered by pure-JS unit tests (event sequences in, asserting state and effects, zero browser DOM); the interaction matrix is projection-tested row by row. This requirement is precisely what forced the pure-core + service-shell layering.

Alternatives considered

RejectedOne-line reason
An ActiveCommand intermediate state / a registerMode mode registry / deriving command mode from the draftClaims are established explicitly by the pick paths — no table, no derivation
Direct bindTarget/bindDraft object wiringReverse coupling plus root-singleton cross-session mispairing; scoped bail events preserve dependency inversion with structurally correct routing
A unified slash/input-apply, or eventing everythingThree independent payloads cover the cross-plugin rewrites; asynchronous paths stay registry-based explicit calls
contenteditable / a rich-text treePoor compatibility; textarea + U+FFFC + the occurrence table covers the full interaction contract
Dual draft persistence {text, occurrences}The mirror writing the clipboard projection adds zero new concepts; chip degradation across refresh is acceptable
The native textarea undo stackUnreliable under controlled + programmatic writes; the paste two-step undo semantics can only be self-managed
The InputBar receiving a 16-member wiring-callback bundleThe consumption matrix proved 11 members InputBar-exclusive and 1 a dead member; the standard-kit channel lets components fetch their own, with the keyboard surface passed privately in-package
Space adjudication also claiming execute-kind commandsThe misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only
A generic tokenPattern decoration mechanismStructured occurrence records replace pattern scanning
A placeholder select resident in the tool rowNamed seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth
An always-visible Plan on/off toggleThe shared Command source already owns entry; a second entry point turns a status seat into redundant mode chrome
A second plus-menu component/controller, or an Add/File group above CommandIt would duplicate async candidates, keyboard highlight, focus retention, and pick state; the plus control is only a source-filtered launcher for the existing MenuView, and this scope has no file capability
All references through U+FFFC chips (the line the plain-text-reference decision replaced)Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity

Consequences

  • One resident conversation shell carries no-session/blank/active: no session → blank preserves ConversationRoot, Hero, the root-scoped Workspace picker, scrollport, composer seat, InputBar, and textarea; only the strict header and body outlets gain content. The same blank session → engaging/active also keeps the InputBar and textarea. EmptyState and the controlled intent chain (sessions.updateIntent/updatePendingPrompt/workspaces.sendSession) are deleted along with their last consumer.
  • The input surface's zero knowledge of commands plus optional dependencies: pure input works without the command packages; @ references and skill references get free reuse of the same menu/pick pipeline. The cost is that space/enter adjudication is a per-source polling protocol whose answer semantics (sync/async, the meaning of undefined) are a frozen contract.
  • Transactionalized submission (attempt seq + the drift guard) makes the three defect classes — stale-result backwash, session switching, concurrent replay — structurally impossible, pinned by the matrix tests.
  • Known gaps: chip fidelity across refresh (paste matching is reusable for it) has no workstream yet; the subagent reference's model representation awaits its business workstream.

中文

问题

两个各自为政的 composer:hero(EmptyState,受控链直写会话)与会话内 InputBar(普通受控 textarea),行为、draft 所有权、发送路径全不一致。要让 / 命令、skill 引用、@ 引用三类触发进入输入面,必须回答:

  • 三类触发如何分层,谁对「命令」有知识、谁零知识;
  • 输入框如何表达「命令态」——从 draft 文本推导还是显式状态?退格、回车、空格、整行粘贴各是什么语义;
  • 提交是异步事务(RPC 往返)——晚到结果回灌、会话切换、React concurrent 重放如何防御;
  • 引用 chip 在纯 textarea 上如何表示,undo/剪贴板/粘贴匹配/模型序列化各归谁;
  • 跨插件的输入改写(菜单回填、引用插入、token 消费)如何做到依赖倒置;
  • 无会话 → blank 会话时哪些 React 外壳必须复用,哪些严格会话输入体允许替换。

硬约束:组件一律经 slots 挂载;呈现产物不进会话日志;键盘路径全程 IME 安全。

决策

输入状态机(InputMachine

纯状态机,事件进/效果出,注入时钟。四相 phase(plain / adjudicating / claimed / submitting)。命令态永不从 draft 推导,由 pick 路径在离散时刻显式建立;claim 由 draft.startsWith(token) 看护、退格破坏自动 release;claim 形状 {token, hint?}(hint 供 ghost text)。

事件面(dispatch(ev) 单写入口,每个事件一个 transaction):

  • draft-changed {draft, editRange?}——textarea 全量草稿;editRange 缩小 occurrence 平移计算,缺省前后缀共扫。
  • newline {selection}——Ctrl+Enter 换行(不经浏览器 execCommand:自管 undo 下浏览器写入会分叉双历史)。
  • begin-command {claim, span} / insert-ref {reference, span} / consume-token {guard}——三个 bail 事件的机器侧;span CAS = draftRev 相等。
  • set-invalid {invalidIds}——owner resolution 结果的样式位(非 transaction)。
  • undo / redo——自管 transaction log(容量为 100 的环形缓冲区;单字符打字按注入时钟窗合并;提交成功清 log)。
  • paste-begin {text, selection, components?, generation?}——粘贴 + 热快照同步匹配组件同 transaction(Undo 一次回粘贴前);打开 PasteMatchAttempt。
  • paste-upgrade {attemptId, span, reference}——异步匹配升级为独立 transaction(Undo 两段);attempt 保持 current,insertedRange 随升级收缩。
  • invalidate-paste——DOM 层观察到的 attempt 终结手势(caret/selection 操作等)。
  • enter {mode} / adjudicated / adjudication-failed / submit-settled / release——提交事务平面:SubmitAttempt(seq + AbortSignal)防回灌,成功 commit 清稿,失败带漂移守卫 rollback(回车时快照仅当 live draft 仍等于它才回填;用户已再输入则只发 notice)。

效果面(shell 执行):adjudicate(调 InputTriggerController.adjudicate)、begin-submit(claim.submit 事务)、default-sink(普通消息,hub 编排)、notice

occurrence 表与 chip 三投影:

  • 每颗引用在 draft 中占一个 U+FFFC;表项 {occurrenceId, source, ref, offset, label, clipboardText, invalid?};同名 chip 因 occurrenceId 独立。
  • 一切编辑同 transaction 更新 draft 与表:区间平移;与占位符相交的删除/替换作用于整颗。
  • 单字符占位使键盘原子性大半原生成立(caret 无内部位;Backspace/方向键/Shift 扩选原生即整颗);鼠标点 chip 由 backdrop 命中 → 整颗 setSelectionRange。
  • 视觉投影 = label:backdrop 在占位符 offset 渲染 chip(textarea 字形不可见),invalid 走失效样式。
  • 剪贴板/持久化投影 = clipboardText:copy/cut 把选区内占位符展开;draft 持久化 mirror 写同一投影(chat store 里永远是普通文本,刷新 seed 语义 = 全选复制→重开→粘贴,chip 跨刷新降级为文本)。
  • 模型投影 = submit 时经 source codec.serialize 逐颗生成(归 submit attempt 的 signal 与陈旧守卫;owner 缺失/失败/取消则不发送,不降级为 /name)。

跨插件输入改写:三个 scoped bail 事件

约定声明在 ui-input-trigger(依赖最底层),生产者经 sctx.bail(sctx, ...) 派发,唯一消费侧是 hub 建 shell 时挂在 sctx 上的三个 listener;返回 true ⟺ 机器过 phase + CAS 守卫并实际改写(发出事件 ≠ 修改成功,Space 是否 preventDefault 以返回值为准):

  • slash/input-begin-command {claim, span}——菜单 pick / Space 裁决出的命令 claim 回填(InputTriggerController 派发)。
  • slash/input-insert-reference {reference, span}——引用 chip 插入(InputTriggerController 派发)。
  • slash/input-consume-token {guard: span | bare-token}——业务成功后消费命令 token(下游命令面派发)。

不事件化的调用(注册表登记 → 显式调用 → await):Input 自身的 draft/submit、Enter 异步裁决、reference serializer、异步 paste matcher。@mode bail 已入 JSDoc parser 与 cordis catalog 门禁(scripts/jsdoc.ts)。

slash 流水线(ui-input-trigger:root InputTriggerService + 每会话 InputTriggerController

对「命令」零知识的触发/菜单/pick 流水线:

  • 服务只有 source 注册表(InputTriggerSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?};(trigger,name) 唯一;可选 order 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 sessionOf(sctx)。实现 match 钩子即参与空格/回车裁决的声明;流水线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。
  • controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、每会话 menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。toggleSource(name, syntheticHit) 是 chrome launcher 路径:它基于调用方的 textarea selection,只 seed 对应的已注册 source,并发布 launcher = name 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 onPick 链。dismiss() 动词支撑 MenuView 注入的 onDismiss(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 slash.menu locale 命名空间本地化组标题,并经 ui-primitives 的 useAnchoredMaxHeight 把高度收敛到 composer 上方的视口空间);每个会话作用域出生时对 source roster 做一次 warm(projection),projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
  • 触发检测词边界(user@host、URL / 永不触发)、守卫分档(plain:/ 到处 + @ 行内 / claimed:/ 抑制、@ 活 / frozen:全无)为冻结纯核。

hub / facade:常驻外壳与严格会话输入体

  • hub(trigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 ctx.get() 依赖:无 ui-input-trigger/命令面时输入正常收发,优雅降级。
  • 每个实体会话只有一个 SessionInputShell(facade),随会话作用域创建和拆除;无会话时不造 input machine。ConversationRoot 自身是 session-maybe 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。它始终拥有同一个 scrollport 与 composer seat;会话出现后,彼此独立的严格会话 header 和 body outlet 只填入这些固定区域。
  • composer bar 是一个无条件渲染的 session-maybe slot entry:无会话时同一个 InputBar 以惰性态渲染(machine face 缺席、disabled owner prop),connectWorkspace 返回 blank 会话后同一实例转为 live——textarea DOM 在无会话 → blank 切换及其后每次 phase 翻转中都不重建;ConversationRoot、Hero 与布局骨架全程保持。
  • ConversationRoot 的 Hero 判据是 sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true)):summary 已证实为空的会话在任何 open state 下都保持 Hero,未经证实的会话则在 loading 期间进入 settling。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在提示词成功受理后翻 false。
  • 发送统一在 hub defaultSink:乐观清稿后只走 session.prompt 且固定 mode:'queue'(Web UI 无 steer 入口;host 线缆上的 mode:'steer' 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。
  • blank Hero 改选 Workspace 时,外壳调用 connectWorkspace;目标会话不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank 会话留存但不再 current。
  • Notifier 双位约定:dirty(快照新鲜度,ensureFresh 拉取可清)与 notifyPending(通知欠账,只有 flush 清)各自独立——拉取不得吞推送,对象层推订阅者(watchTransaction)依赖这一保证。

纯文本引用:text outcome 与 lexicon 装饰

skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引用决策:pick 直接把 /name @name 原文插进 draft,chip 视觉纯派生:

  • PickOutcome 增 {text} arm;新 scoped bail 事件 slash/input-insert-text {text, span}(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。
  • source 可选 lexicon?(session) 钩子:同步热快照名录,undefined = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 subscribeLexicon?(session, listener) 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 lexicon 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。
  • decorations.scanTextRefs:词边界扫描 draft(行首/空白后的 /name@namex/name 永不命中)对照名录,命中即 .textRef mark(backdrop 纯 range 高亮,同 hlToken);编辑破坏匹配形状下次扫描自然消失。
  • 发送即原文(不再 <skill> 序列化);气泡侧 MessageItem 双形状装饰(legacy <skill> 标签 + 纯文本 token)。
  • 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。装饰响应性:InputBar 以 uSES 订阅 shell 的 lexicon source,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。

每会话供数贡献与键盘私面

  • ui-conversation(hub 兼贡献者)经 sessions.provide'input' hook(机器状态 + queue overlay)+ inputActions prop(setDraft/submit,稳定 void 回调)。
  • 公私分界:公共 provide 只放 React 语汇成员;键盘/DOM 命令面(track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror——同步返回值、disposer 语义)是 InputBar 独占,走 InputBar entry 自己的 inject 包内私递,不出插件边界。

slot 体系

conversation 本身是 session-maybe;其会话内容与 composer 输入 slot 严格限定为会话,Hero Workspace picker 保持 root。root 注册把 header outlet 渲染在常驻 scrollport 上方,把 body outlet 渲染在其内部、常驻 composer seat 之前。子 slot 均由 ui-conversation 的 conversation 注册声明:

  • conversation.session.header(single)——常驻 scrollport 上方严格会话的 breadcrumb、view tab 与 header action。
  • conversation.session(single)——常驻 scrollport 内严格会话的 view ring 与 draft mirror。header 和 body 共享同一个会话作用域 chat store;会话 id 切换时各自重建。
  • conversation.composer.bar(single)——InputBar 本体的 slot:InputBar 是真 slot entry(自有 slot 自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。
  • conversation.input.overlay——输入卡内浮层锚点;注册者 inject 按 slot sessionId 解析各自每会话 controller。
  • conversation.input.dock——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。
  • conversation.composer.dock——composer 上沿统计带。
  • conversation.input.left / conversation.input.right——工具行左右区。
  • conversation.input.plan / conversation.input.model(single)——工具行两具名控制位;bar 只传 locked(owner props),空到 owning 插件注册为止,无占位 fallback。plan seat 未激活时保持为空,因为入口归共享 Command source 所有;有效 plan 目标会渲染 warn 状态的 Plan × 状态按钮,其唯一动作是 /plan off
  • conversation.hero.workspace(root scope)——无会话 / blank Hero 共用的 Workspace picker;pick 经 connectWorkspace 复用或创建目标 blank 会话,必要时搬运 draft 后切 current。

测试纪律

状态机全部行为由纯 JS 单测覆盖(事件序列进、断言状态与效果,零浏览器 DOM);交互矩阵逐行投影测试。这一要求正是纯核 + 服务壳分层的成因。

曾考虑的替代方案

弃案一行理由
ActiveCommand 中间态 / registerMode 模式注册表 / 从 draft 推导命令态claim 由 pick 路径显式建立——无表、无推导
bindTarget/bindDraft 对象直连反向耦合 + root 单例跨会话误配;scoped bail 事件保依赖倒置且路由结构性正确
统一 slash/input-apply 或全事件化三个独立 payload 覆盖跨插件改写;异步链路保持基于注册表的显式调用
contenteditable / 富文本树兼容性差;textarea + U+FFFC + occurrence 表覆盖全部交互约定
draft 双持久化 {text, occurrences}mirror 写剪贴板投影零新概念;chip 跨刷新降级可接受
原生 textarea undo 栈受控 + 程序化写入下不可靠;粘贴两段 undo 语义只能自管
InputBar 收 16 员 wiring 回调包消费矩阵实证 11 员 InputBar 独占、1 员死成员;标准件通道让组件自取,键盘面包内私递
空格裁决也认领即执行型命令误触发防线:空格后整行是普通提示词;不可逆副作用只留显式入口
通用 tokenPattern 装饰机制结构化 occurrence 记录取代模式扫描
占位 select 常驻工具行具名 slot 在注册前保持为空;占位件与真实现冲突时是两个真源
始终可见的 Plan 开/关切换入口已归共享 Command source 所有;第二个入口会把状态 seat 变成冗余的 mode chrome
第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力
引用一律走 U+FFFC chip(纯文本引用决策所取代的旧线)纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景

后果

  • 一个常驻 conversation 外壳承接无会话/blank/active:无会话 → blank 保持 ConversationRoot、Hero、root scope Workspace picker、scrollport、composer seat、InputBar 与 textarea;只有严格会话 header 和 body outlet 开始承载内容。同一 blank 会话 → engaging/active 也保持 InputBar 与 textarea。EmptyState 与受控 intent 链(sessions.updateIntent/updatePendingPrompt/workspaces.sendSession)随最后消费方一并删除。
  • 输入面对命令零知识 + 可选依赖:无命令包时纯输入可用;@ 引用与 skill 引用免费复用同一菜单/pick 流水线。代价是空格/回车裁决是逐 source 轮询协议,其应答语义(同步/异步、undefined 含义)为冻结约定。
  • 提交事务化(attempt seq + 漂移守卫)使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能,由矩阵测试钉住。
  • 已知欠账:chip 跨刷新保真(可复用粘贴匹配)未立项;subagent 引用的模型表示待业务立项。