Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine

# Conflicts:
#	.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md
#	.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md
#	.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-06-sandbox.md
#	.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
#	.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml
#	docs/architecture.i18n.yaml
#	docs/cookbook/adding-a-tool.i18n.yaml
#	docs/cookbook/extension-cookbook.i18n.yaml
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/session.i18n.yaml
#	docs/core-data-structures/tools.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
#	examples/acp-agent/tests/snapshots/plan-mode-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/plan-mode/session.jsonl
#	examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl
#	packages/context/session-reference/README.md
#	packages/core/agent-loop/tests/agent.spec.ts
#	packages/hooks/hooks-claude/tests/coverage-cases.ts
#	packages/host/runtime/tests/host-runtime.spec.ts
#	packages/llm/llm-retry/tests/retry.spec.ts
#	packages/session-persistence/session-persistence/src/coordinator.ts
#	packages/support/acp-snapshot/README.md
#	packages/support/acp-snapshot/src/normalize.ts
#	packages/ui/acp/acp-feature-support.md
#	packages/ui/acp/src/codec.ts
#	packages/ui/acp/src/index.ts
#	packages/ui/acp/tests/bridge.spec.ts
#	packages/ui/acp/tests/codec.spec.ts
#	packages/ui/acp/tests/config-options.spec.ts
#	packages/ui/acp/tests/dispose.spec.ts
#	packages/ui/acp/tests/edges.spec.ts
#	packages/ui/acp/tests/stream-update.spec.ts
#	packages/ui/acp/tests/turns.spec.ts
This commit is contained in:
_Kerman
2026-07-26 14:05:33 +08:00
1062 changed files with 33621 additions and 30750 deletions

View File

@@ -1,14 +1,4 @@
/**
* Client plugin body: register the conversation/details slot occupants and
* the no-session empty state, contribute the chat entry into the
* 'conversation.view' ring that the conversation registration declares, then
* mount the conversation service (class plugin) and the bash toolview sample.
* Assembly only — components receive everything through props: the framework
* standard kit and store faces arrive automatically from the declarations
* below; the inject factories contribute the plain-data-and-callbacks
* business face (design §5). Tool rows are ordinary keyed-slot registrations
* into 'conversation.chat.toolview' — no dedicated registry exists.
*/
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
@@ -25,8 +15,8 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { EmptyState } from './skeleton/EmptyState.tsx'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots', 'layout', 'sessions']
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
@@ -37,24 +27,18 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
return conversation
}
/**
* Client plugin body.
* @param ctx - client root context.
/** Mounts the conversation plugin.
* @param ctx - Client root context.
*/
export function apply(ctx: Context): void {
const sessions = ctx.sessions
const workspaces = ctx.workspaces
const layout = ctx.layout
const slots = ctx.slots
// Shared store handle, constructed here so its identity lives and dies with
// this fiber (a module-level handle would be a de-facto singleton). The
// conversation, chat-view, and details registrations all declare it; same
// scope key = same instance, so chat-view selection writes and details
// reads meet in one store.
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
// Tab projection over the view ring's ledger (list entries carry id/order/
// label as registration options; the ledger keeps them order-sorted).
const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = []
for (const entry of slots.entries('conversation.view')) {
@@ -103,7 +87,9 @@ export function apply(ctx: Context): void {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
open: (target: SessionId) => { sessions.open(target) },
open: (sessionId) => { sessions.open(sessionId) },
updateSessionPrompt: (text) => { scoped.updatePendingPrompt(text) },
retrySessionPrompt: () => { scoped.retryPendingPrompt() },
}
},
}, ConversationRoot)
@@ -120,13 +106,16 @@ export function apply(ctx: Context): void {
label: 'Chat',
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => ({
openDetails: (target) => {
actions.select(target)
layout.openDetails()
},
loadOlder: () => { void sessions.manager.get(sessionId).loadOlder() },
}),
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
const scoped = scopedConversation(sessions, sessionId)
return {
openDetails: (target) => {
actions.select(target)
layout.openDetails()
},
loadOlder: () => { void scoped.loadOlder() },
}
},
}, ChatView)
// Class-plugin mount (packages/AGENTS.md service form): the service
@@ -150,20 +139,11 @@ export function apply(ctx: Context): void {
slots.register({
name: 'conversation.empty',
children: { 'conversation.empty.workspace': { kind: 'single', scope: 'root' } },
inject: (): EmptyStateInjected => ({
// ctx.get, not ctx.conversation: the service mounts on this plugin's
// own child fiber, so it is not in the inject topology the property
// proxy enforces; get reads the global store and stays loud on a torn
// boot through the optional-chain throw below.
startSession: (opts) => {
const conversation = ctx.get('conversation')
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
return conversation.startSession(opts)
},
createWorkspaceSession: async (name) => {
const id = await sessions.createWorkspace(name)
sessions.open(id)
},
startSession: (workspaceId, prompt) => { workspaces.startSession(workspaceId, prompt) },
updateSessionPrompt: (text) => { sessions.updateIntent(text) },
sendSession: () => { workspaces.sendSession() },
}),
}, EmptyState)
}

View File

@@ -1,9 +1,4 @@
// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284
// tokens · 45.2s · 5 turns · 32 steps"), rendered by ChatView under the flow
// (part of the chat view body — the chrome attachment mechanism retired with
// the view ring). Duration has no data source in P-I (ledger). Subscribes to
// `nodes` only: chunk batches never swap that reference, so the row renders
// zero times during streaming (the RFC performance model's acceptance row).
// Settled-node identity prevents stream-delta updates from rerendering this row.
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'

View File

@@ -1,17 +1,7 @@
/**
* Slot-ring contract for the conversation package: the 'conversation.view'
* slot this package declares (the view ring — one list entry per conversation
* view tab), the chat view's per-tool row hole ('conversation.chat.toolview',
* keyed on the wire tool name), and the composed props shapes its registrants
* mount into the layout-owned slots (conversation / details /
* conversation.empty) plus its own slots. Terminal slot design (§3): full
* component props are the automatic shares — PropsRuntime<K> (framework
* standard kit) & PropsRenderSlots<S> (declared children) & PropsStore<H>
* (declared store's read/write faces) & the injected business face declared
* here.
*/
/** Conversation slot declarations and their composed component props. */
import type { RefObject } from 'react'
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -41,6 +31,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* zero owner changes.
*/
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
/** Shared Workspace picker hole used by the page-local Session Intent hero. */
'conversation.empty.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
}
}
@@ -93,15 +85,9 @@ export type ConvViewProps = PropsRuntime<'conversation.view'>
/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */
export type ChatStore = ReturnType<typeof createChatStore>
/**
* Injected share of the conversation slot: plain data and callbacks only
* (design §5 — hooks are framework-made). The store lines that used to ride
* here live in the declared {@link ChatStore}; ancestry derives from the
* standard useSessions hook in-component; views render through the declared
* 'conversation.view' child slot, with this face projecting the tab strip.
*/
/** Business callbacks injected into the conversation slot. */
export interface ConversationInjected {
/** View tab read face (uSES triple over the 'conversation.view' slot ledger). */
/** Views projected from the `conversation.view` slot ledger. */
views: {
list(): readonly ViewTab[]
subscribe(fn: () => void): () => void
@@ -111,8 +97,12 @@ export interface ConversationInjected {
send(text: string, mode: 'queue' | 'steer'): void
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
stop(): void
/** Navigate to another session (breadcrumb ancestors). */
open(id: SessionId): void
/** Select a real Session through the runtime navigation owner. */
open(sessionId: SessionId): void
/** Update the scoped Session's retained prompt. */
updateSessionPrompt(text: string): void
/** Retry the scoped Session's retained prompt. */
retrySessionPrompt(): void
}
/**
@@ -123,7 +113,6 @@ export interface ConversationInjected {
* with zero owner changes.
*/
export interface ComposerChainProps {
/** The session's live pending waits, in arrival order (snapshot reference). */
interactions: readonly PendingInteraction[]
}
@@ -139,7 +128,6 @@ export type ConversationSlotProps =
export interface ChatViewInjected {
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails(target: SelectionTarget): void
/** Pull one older history page. */
loadOlder(): void
}
@@ -160,16 +148,24 @@ export interface DetailsInjected {
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
/** Injected share of the no-session empty-state slot. */
export interface EmptyStateInjected {
/** The create → navigate → first-send chain, in one service call. */
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
/**
* Create a workspace folder under the host cwd, mint a session there, and
* open it (Create-new modal success path).
*/
createWorkspaceSession(name: string): Promise<void>
/** Owner share common to the empty hero's Workspace picker. */
export interface EmptyWorkspaceOwnerProps {
open: boolean
anchorRef?: RefObject<HTMLElement>
onPick(workspaceId: WorkspaceId): void
onClose(): void
}
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
export type EmptyStateSlotProps = PropsRuntime<'conversation.empty'> & EmptyStateInjected
/** Runtime-owned actions injected into the empty-state occupant. */
export interface EmptyStateInjected {
/** Replace the current Session intent, optionally preserving a prompt while retargeting. */
startSession(workspaceId?: WorkspaceId, prompt?: string): void
/** Update the current Session intent's controlled prompt. */
updateSessionPrompt(text: string): void
/** Materialize and send the current Session intent. */
sendSession(): void
}
/** Full empty-state component props: runtime projections, picker child slot, and injected actions. */
export type EmptyStateSlotProps =
PropsRuntime<'conversation.empty'> & PropsRenderSlots<'conversation.empty.workspace'> & EmptyStateInjected

View File

@@ -1,14 +1,4 @@
/**
* Shared conversation contract primitives: the view tab projection (slot
* entries in 'conversation.view' surface as tabs), the chat store state
* shared through the declared store, and the selection primitives every
* domain consumes. Shared face between the skeleton domain (tab strip +
* view outlet) and the chat domain; domain implementation files import this,
* never each other. The view ring itself IS the 'conversation.view' slot
* (contract in slots.ts) — the package-local view registry is retired, and
* so is the hand-threaded translate channel (framework-level per-slot i18n
* injection is the planned replacement).
*/
/** Shared conversation view, selection, and store-state contracts. */
/** Tool call identity as carried on the wire (branded upstream in connection). */
export type CallId = string
@@ -23,11 +13,8 @@ export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: C
export interface ViewTab { id: string; label: string }
/**
* Chat store state (slot terminal design §4): the per-session store shared by
* the conversation, chat-view, and details registrations. `createChatStore`
* implements this shape. `view` may carry a stale persisted id after a view
* plugin unloads — the slot ledger is the runtime validator (unknown ids fall
* back to the first registered view).
* Per-session state shared by conversation, chat-view, and details slots.
* Unknown persisted view ids fall back to the first registered view.
*/
export interface ChatStoreState {
/** Details-linkage channel (conversation writes, details reads). */

View File

@@ -1,12 +1,7 @@
/**
* Conversation domain plugin, browser half: skeleton (header/tabs/composer),
* the 'conversation.view' slot ring (chat entry here; other plugins
* contribute view tabs through ctx.slots), the chat view's keyed
* 'conversation.chat.toolview' row hole, scope-addressed ConversationService,
* minimal details panel. Contract: api-contracts v3 section 7. Thin shell:
* type surfaces live in contract/, assembly in apply.ts; the implementation
* domains (skeleton/chat) never import each other — contract/ is their only
* shared face.
* Browser conversation plugin. `contract/` is the shared type boundary
* between the independently implemented skeleton and chat domains; `apply.ts`
* owns their slot assembly.
*/
import type { ConversationService } from './service.ts'
@@ -20,7 +15,7 @@ export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
EmptyStateInjected, EmptyStateSlotProps, EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -1,17 +1,11 @@
/**
* ConversationService implementation: scope-addressed send/cancel and the
* empty-state startSession chain. Contract: api-contracts v3 section 7.
* Selection/draft state moved to the declared chat store (slot terminal
* design §4); the view registry moved to the 'conversation.view' slot (slot
* ledger owns registration, ordering, and disposal) — what remains is the
* send/stop orchestration face.
* Scope-addressed conversation send, cancel, history, and retained-prompt orchestration.
*
* Scope addressing rides the cordis Service tracker: property access through
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
* read the session tag with scopeOf (same mechanism as the host tool
* registry). Mutable state lives in plain objects reached by one property
* read — field assignment through the tracker's shadow proxy is off-limits,
* as are `#` hard-private fields.
* read the session tag with `scopeOf`. Mutable state must remain reachable
* through one property read; assignment through the tracker proxy and `#`
* private fields bypass that rebinding.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
@@ -50,37 +44,30 @@ export class ConversationService extends Service {
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
}
/** Pull one older history page for the scoped Session. */
async loadOlder(): Promise<void> {
await this.scopedSession('loadOlder').loadOlder()
}
/**
* Empty-state first-send chain (root-context method; does not read scope):
* create the session, navigate to it, then send through the new scope.
* The create → open ordering is safe: the manager merges the new summary
* synchronously before create() resolves, so the list store is projected by
* the time open() validates against it (manager notification batching is
* microtask-based; SessionsService projects on the same flush that create
* awaited through the RPC round trip).
* @param opts - project directory, prompt text, and send mode.
* Update the scoped Session's retained pending prompt.
* @param text - exact controlled-input value to retain.
*/
async startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> {
const sessions = this.requireSessions()
const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd })
// The manager notifier flushes per microtask; one await guarantees the
// list-store projection landed before sessions.open validates against it.
await Promise.resolve()
sessions.open(id)
const scoped = sessions.scope(id)
if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`)
// ctx.get, not scoped.conversation: property access walks the fiber
// topology (a scope fiber never injects services), while get reads the
// global store and still binds this service to the scoped ctx.
const scopedConversation = scoped.get('conversation')
if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope')
await scopedConversation.send(opts.text, opts.mode)
updatePendingPrompt(text: string): void {
this.scopedSession('updatePendingPrompt').updatePendingPrompt(text)
}
/** Retry the scoped Session's retained pending prompt. */
retryPendingPrompt(): void {
this.scopedSession('retryPendingPrompt').retryPendingPrompt()
}
/** Resolve the caller scope's Session or throw on root contexts. */
private scopedSession(op: string): Session {
const id = this.scopeId(op)
return this.requireSessions().manager.get(id)
const binding = this.requireSessions().binding(id)
if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`)
return binding.session
}
/** Read the caller's session scope tag via the sessions service; root contexts fail loud. */

View File

@@ -15,6 +15,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d
import type { ConversationSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import { EmptyHero, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import css from './ConversationRoot.module.css'
/** Full props = the automatic shares & injected share — composed by reference
@@ -37,8 +38,8 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
}
export function ConversationRoot({
sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain,
views, send, stop, open,
sessionId, useSession, useSessions, useWorkspaces, useStore, actions, renderSlot, renderSlotChain,
views, send, stop, open, updateSessionPrompt, retrySessionPrompt,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
@@ -48,16 +49,60 @@ export function ConversationRoot({
const active = tabs.find(v => v.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const draft = useStore(s => s.draft)
const running = useSession(s => s.running)
const pendingPrompt = useSession(s => s.pendingPrompt ?? undefined)
const storedDraft = useStore(s => s.draft)
const draft = pendingPrompt?.text ?? storedDraft
const sessionRunning = useSession(s => s.running)
const running = sessionRunning || pendingPrompt?.phase === 'sending'
const removed = useSession(s => s.removed)
const promptError = useSession(s => s.promptError)
const turns = useSession(s => countTurns(s))
const pending = useSession(s => s.pending)
const openState = useSession(s => s.openState)
const composerPhase = useSession(s => s.composerPhase)
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const workspaceTitle = useWorkspaces(state =>
state.items.find(workspace => workspace.sessionIds.includes(sessionId))?.title)
const error: InputBarError | null = pendingPrompt?.error !== undefined
? {
op: pendingPrompt.retry === 'connect' ? 'session' : 'send',
message: pendingPrompt.retry === 'connect'
? `Workspace attach failed: ${pendingPrompt.error}`
: `Message send failed: ${pendingPrompt.error}`,
}
: promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
const status = pendingPrompt?.phase === 'sending'
? pendingPrompt.retry === 'connect' ? 'Attaching session to workspace…' : 'Sending message…'
: undefined
const setDraft = (text: string): void => {
if (pendingPrompt === undefined) actions.setDraft(text)
else updateSessionPrompt(text)
}
const submit = (mode: 'queue' | 'steer'): void => {
if (pendingPrompt === undefined) send(draft, mode)
else retrySessionPrompt()
}
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
// Blank-session guidance: phase-derived (the runtime snapshot owns the
// predicate — see ComposerPhase). Only `blank` renders the hero; `engaging`
// and `active` fall through to the conversation view, so an in-flight
// first send never bounces back here. Gated on the OPEN window: phase has
// no jurisdiction over loading/error frames (ChatView renders those).
if (openState === 'open' && composerPhase === 'blank') {
return (
<EmptyHero
workspaceRow={<WorkspaceChip label={workspaceTitle ?? workspaceLabel(cwd ?? '')} locked />}
draft={draft}
disabled={removed || pendingPrompt?.phase === 'sending'}
error={error}
{...(status === undefined ? {} : { status })}
onDraftChange={setDraft}
onSend={submit}
/>
)
}
// The default composer doubles as the chain's all-decline fallback: a
// pending wait with no registered takeover must still leave the input usable.
@@ -67,9 +112,10 @@ export function ConversationRoot({
running={running}
disabled={removed}
error={error}
{...(status === undefined ? {} : { status })}
variant="composer"
onDraftChange={actions.setDraft}
onSend={(mode) => { send(draft, mode) }}
onDraftChange={setDraft}
onSend={submit}
onStop={stop}
/>
)
@@ -78,7 +124,7 @@ export function ConversationRoot({
<div className={css.root}>
<header className={css.header}>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="会话层级">
<nav className={css.crumbs} aria-label="Session hierarchy">
{ancestry.map((s, i) => {
const last = i === ancestry.length - 1
return (

View File

@@ -0,0 +1,149 @@
// EmptyHero: the shared NEW SESSION hero (fish headline + glow + workspace
// row + hero InputBar), extracted from EmptyState so the bound guidance
// state (a current session with zero messages, ConversationRoot) renders the
// same layout without the picker wiring. Hosts own the workspace-row content
// and the send wiring; modals ride `children` after the stack.
import { useId } from 'react'
import type { ReactNode, RefObject } from 'react'
import {
FishLogo, IconChevronDownOutline14, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './EmptyState.module.css'
/**
* Basename label for the workspace chip / menu rows (the shared derivation);
* empty → the design's "New Workspace" placeholder copy; separator-only
* paths echo the raw cwd.
* @param cwd - workspace directory path ('' for none).
* @returns chip label.
*/
export function workspaceLabel(cwd: string): string {
if (cwd === '') return 'New Workspace'
const base = workspaceTitleOf(cwd)
return base !== '' ? base : cwd
}
/**
* The workspace chip (folder + label + chevron). Locked form (bound guidance
* state): no chevron, no menu affordance, clicks disabled — the bound
* session's cwd is final.
* @param props.label - chip label (see {@link workspaceLabel}).
* @param props.locked - read-only echo form.
* @param props.menuOpen - menu expansion echo (interactive form only).
* @param props.onClick - menu toggle (interactive form only).
* @returns the chip button element.
*/
export function WorkspaceChip({ buttonRef, label, locked = false, menuOpen = false, onClick }: {
buttonRef?: RefObject<HTMLButtonElement>
label: string
locked?: boolean
menuOpen?: boolean
onClick?: () => void
}) {
return (
<button
ref={buttonRef}
type="button"
className={css.workspace}
aria-label={locked ? 'Current workspace' : 'Choose workspace'}
{...(locked ? {} : { 'aria-haspopup': 'menu' as const, 'aria-expanded': menuOpen })}
disabled={locked}
onClick={onClick}
>
<IconFolderOpen16 className={css.folder} size={16} />
<span className={css.workspaceLabel}>{label}</span>
{!locked && <IconChevronDownOutline14 className={css.chevron} size={12} />}
</button>
)
}
/** Hero-card props: both hosts supply the workspace row and their send wiring. */
export interface EmptyHeroProps {
/** Workspace-row content (Menu-wrapped chip in EmptyState; bare locked chip in guidance). */
workspaceRow: ReactNode
draft: string
disabled: boolean
/** Composer placeholder override (EmptyState's pick-a-workspace hint); defaults to the hero copy. */
placeholder?: string
error: InputBarError | null
status?: string
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
/** Overlay content after the stack (EmptyState's modals). */
children?: ReactNode
}
/**
* Render the hero card.
* @param props - see {@link EmptyHeroProps}.
* @returns the centered hero element tree.
*/
export function EmptyHero({
workspaceRow,
draft,
disabled,
placeholder,
error,
status,
onDraftChange,
onSend,
children,
}: EmptyHeroProps) {
// Stable filter id so multiple hero mounts do not collide in the DOM.
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
return (
<div className={css.root}>
<div className={css.stack}>
<div className={css.headline}>
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<FishLogo size={34} className={css.fish} />
Let&apos;s start building
</div>
<div className={css.body}>
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
tracks the card (glow asset 1051 vs design card 776) so blur
scales in userSpace with it. */}
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
<defs>
<filter
id={glowFilterId}
x="0"
y="0"
width="1051"
height="468"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
</filter>
</defs>
<g filter={`url(#${glowFilterId})`}>
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
</g>
</svg>
<div className={css.workspaceRow}>{workspaceRow}</div>
<InputBar
draft={draft}
running={false}
disabled={disabled}
error={error}
{...(status === undefined ? {} : { status })}
variant="hero"
placeholder={placeholder ?? 'Describe what you want to build'}
onDraftChange={onDraftChange}
onSend={onSend}
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
onStop={() => {}}
/>
</div>
</div>
{children}
</div>
)
}

View File

@@ -100,11 +100,17 @@
cursor: pointer;
}
.workspace:hover,
.workspace:not(:disabled):hover,
.workspace[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Locked form (bound guidance state): a static echo — no hover feedback, no
pointer affordance; label keeps full contrast. */
.workspace:disabled {
cursor: default;
}
.folder {
flex: none;
color: var(--dsw-alias-label-primary);
@@ -121,22 +127,21 @@
color: var(--dsw-alias-label-caption);
}
/* Workspace menu width tracks the longest basename in the Figma frame. */
.workspaceMenu :global([role='menu']) {
min-width: 240px;
}
/* Dialog field (figma 451:18655 Input): h44, r22, px 14, caption placeholder. */
/* Dialog field: 44 tall on the modal's 332 content column, r22, hairline
border, pad 14/7, 14/22 wt400 primary text, caption placeholder. Focus
keeps the resting border (design shows no focus ring). */
.modalInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 0 14px;
padding: 7px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: transparent;
font-size: 14px;
line-height: 24px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
@@ -144,10 +149,6 @@
color: var(--dsw-alias-label-caption);
}
.modalInput:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.modalInput:disabled {
color: var(--dsw-alias-label-dimmed);
}

View File

@@ -1,305 +1,77 @@
// EmptyState (figma NEW SESSION screen): centered hero — fish + title,
// workspace picker row (MenuDropdown 122:9481 + New Workspace submenu
// 419:16920 + Dialog 451:18655), then the SAME InputBar the resident
// composer uses (empty→content is a position move, never a swap). Project
// options derive in-component from useSessions; Create new runs
// createWorkspaceSession (host mkdir + session.create + open).
import { useId, useMemo, useState } from 'react'
import {
Button,
FishLogo,
IconChevronDownOutline14,
IconFolderClose16,
IconFolderOpen16,
IconPlusOutline16,
Menu,
Modal,
type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
/** Page-local Session Intent hero. */
import { useRef, useState } from 'react'
import type { EmptyStateSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './EmptyState.module.css'
import { EmptyHero, WorkspaceChip } from './EmptyHero.tsx'
/** Menu id for "New Workspace" (opens submenu; not a cwd). */
const NEW_WORKSPACE = '::new-workspace'
/** Submenu: path modal (figma 451:18655 copy). */
const USE_EXISTING = '::use-existing'
/** Submenu: create-workspace modal → mkdir + default session. */
const CREATE_NEW = '::create-new'
/** Which full-page dialog is open (null = none). */
type ModalKind = 'path' | 'create' | null
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
/** Full props composed from runtime projections, injected actions, and the declared picker slot. */
export type EmptyStateProps = EmptyStateSlotProps
/** Deduped cwd set in list order (pure derivation over the sessions list). */
function deriveCwds(state: SessionListState): readonly string[] {
const seen = new Set<string>()
for (const id of state.ids) {
const cwd = state.byId[id]?.cwd
if (cwd !== undefined && cwd !== '') seen.add(cwd)
}
return [...seen]
}
export function EmptyState({
useSessions,
useWorkspaces,
startSession,
updateSessionPrompt,
sendSession,
renderSlot,
}: EmptyStateProps) {
const intent = useSessions(state => state.intent)
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
const [pickerOpen, setPickerOpen] = useState(false)
const pickerAnchor = useRef<HTMLButtonElement>(null)
if (intent === undefined) return null
const workspaceId = intent.target.kind === 'workspace' ? intent.target.workspaceId : undefined
const workspace = workspaceId === undefined
? undefined
: workspaces.find(item => item.workspaceId === workspaceId)
const workspaceLabel = intent.target.kind === 'workspace-intent'
? workspaceSnapshot.intent?.name ?? 'Workspace unavailable'
: workspace?.title ?? 'Workspace unavailable'
const workspaceIntent = workspaceSnapshot.intent
const busy = intent.phase === 'connecting' || workspaceIntent?.phase === 'creating'
const status = workspaceIntent?.phase === 'creating'
? 'Creating workspace…'
: intent.phase === 'connecting'
? 'Creating session…'
: workspaceSnapshot.phase === 'pending'
? 'Loading workspaces…'
: undefined
const error: InputBarError | null = workspaceIntent?.error !== undefined
? { op: 'workspace', message: `Workspace creation failed: ${workspaceIntent.error}` }
: intent.error === undefined
? null
: { op: 'session', message: `Session creation failed: ${intent.error.message}` }
/** Basename for the workspace chip / menu row; empty → the design's "New Workspace" label. */
function workspaceLabel(cwd: string): string {
if (cwd === '') return 'New Workspace'
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
return base !== undefined && base !== '' ? base : cwd
}
export function EmptyState({ useSessions, startSession, createWorkspaceSession }: EmptyStateProps) {
const list = useSessions(s => s)
const cwds = useMemo(() => deriveCwds(list), [list])
// Local viewing state: the empty state owns no session, so its draft is
// ephemeral by design (drafts are keyed by session id; there is none yet).
const [draft, setDraft] = useState('')
const [cwd, setCwd] = useState('')
const [menuOpen, setMenuOpen] = useState(false)
const [modalKind, setModalKind] = useState<ModalKind>(null)
const [pathDraft, setPathDraft] = useState('')
const [workspaceName, setWorkspaceName] = useState('New WorkSpace')
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [sending, setSending] = useState(false)
const [error, setError] = useState<InputBarError | null>(null)
// Stable filter id so multiple EmptyState mounts do not collide in the DOM.
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
const submit = (mode: 'queue' | 'steer'): void => {
const text = draft.trim()
/* v8 ignore next -- defensive: InputBar disables send while empty. */
if (text === '' || sending) return
setSending(true)
setError(null)
const chosen = cwd.trim()
startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
.catch((reason: unknown) => {
// The empty state survives failure with the draft intact (no session
// exists to carry promptError; this is the only local error surface).
setError({ op: 'send', message: reason instanceof Error ? reason.message : String(reason) })
setSending(false)
})
// Success needs no cleanup: the session selection swaps this slot out for the session body.
}
const items: MenuEntry[] = [
...cwds.map(c => ({
id: c,
label: workspaceLabel(c),
icon: <IconFolderClose16 size={16} />,
})),
...(cwds.length > 0 ? [{ type: 'separator' as const, id: 'sep-new' }] : []),
{
id: NEW_WORKSPACE,
label: 'New Workspace',
icon: <IconPlusOutline16 size={16} />,
submenu: [
{ id: USE_EXISTING, label: 'Use a existing folder' },
{ id: CREATE_NEW, label: 'Create new' },
],
},
]
const closeModal = (): void => {
if (creating) return
setModalKind(null)
setModalError(null)
}
const openPathModal = (): void => {
setPathDraft(cwd)
setModalError(null)
setModalKind('path')
}
const openCreateModal = (): void => {
setWorkspaceName('New WorkSpace')
setModalError(null)
setModalKind('create')
}
const confirmPath = (): void => {
const next = pathDraft.trim()
if (next === '') return
setCwd(next)
setModalKind(null)
}
const confirmCreate = (): void => {
if (creating) return
setCreating(true)
setModalError(null)
createWorkspaceSession(workspaceName)
.catch((reason: unknown) => {
setModalError(reason instanceof Error ? reason.message : String(reason))
setCreating(false)
})
// Success swaps this slot out for the new session body — no local cleanup.
}
const modalBusy = creating
const isPath = modalKind === 'path'
const isCreate = modalKind === 'create'
const workspaceRow = (
<>
<WorkspaceChip
buttonRef={pickerAnchor}
label={workspaceLabel}
menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }}
/>
{renderSlot('conversation.empty.workspace', {
open: pickerOpen,
anchorRef: pickerAnchor,
onPick: (workspaceId) => {
setPickerOpen(false)
startSession(workspaceId, intent.prompt)
},
onClose: () => { setPickerOpen(false) },
})}
</>
)
return (
<div className={css.root}>
<div className={css.stack}>
<div className={css.headline}>
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<FishLogo size={34} className={css.fish} />
Let&apos;s start building
</div>
<div className={css.body}>
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
tracks the card (glow asset 1051 vs design card 776) so blur
scales in userSpace with it. */}
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
<defs>
<filter
id={glowFilterId}
x="0"
y="0"
width="1051"
height="468"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
</filter>
</defs>
<g filter={`url(#${glowFilterId})`}>
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
</g>
</svg>
<div className={css.workspaceRow}>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
{...(cwd !== '' ? { selectedId: cwd } : {})}
items={items}
side="top"
className={css.workspaceMenu!}
onSelect={(id) => {
if (id === USE_EXISTING) {
setMenuOpen(false)
openPathModal()
return
}
if (id === CREATE_NEW) {
setMenuOpen(false)
openCreateModal()
return
}
setCwd(id)
setMenuOpen(false)
}}
anchor={(
<button
type="button"
className={css.workspace}
aria-label="项目目录"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => { setMenuOpen(!menuOpen) }}
>
<IconFolderOpen16 className={css.folder} size={16} />
<span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span>
<IconChevronDownOutline14 className={css.chevron} size={12} />
</button>
)}
/>
</div>
<InputBar
draft={draft}
running={false}
disabled={sending}
error={error}
variant="hero"
placeholder="Message to run task, plan and build, enter for / commands"
onDraftChange={setDraft}
onSend={submit}
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
onStop={() => {}}
/>
</div>
</div>
<Modal
open={isPath}
onClose={closeModal}
title="Enter an existing folder path"
footer={(
<>
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={pathDraft.trim() === ''}
onClick={confirmPath}
>
Open Folder
</Button>
</>
)}
>
<input
className={css.modalInput}
value={pathDraft}
aria-label="Folder path"
autoFocus
placeholder="ex. User/Documents/Harness/Space"
onChange={(e) => { setPathDraft(e.target.value) }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirmPath()
}
}}
/>
</Modal>
<Modal
open={isCreate}
onClose={closeModal}
title="Create new workspace"
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={modalBusy} onClick={closeModal}>
Cancel
</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={modalBusy || workspaceName.trim() === ''}
onClick={confirmCreate}
>
Create
</Button>
</>
)}
>
<input
className={css.modalInput}
value={workspaceName}
aria-label="Workspace name"
autoFocus
disabled={modalBusy}
onChange={(e) => { setWorkspaceName(e.target.value) }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirmCreate()
}
}}
/>
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
</Modal>
</div>
<EmptyHero
workspaceRow={workspaceRow}
draft={intent.prompt}
disabled={busy}
{...(status === undefined ? {} : { status })}
error={error}
onDraftChange={updateSessionPrompt}
onSend={() => { sendSession() }}
/>
)
}

View File

@@ -19,18 +19,27 @@
padding: 0;
}
.error {
.error,
.status {
width: 100%;
max-width: 800px;
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 8px;
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 18px;
}
.status {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.error {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
}
.card {
display: flex;
flex-direction: column;

View File

@@ -1,12 +1,5 @@
// InputBar: the one composer input (figma Input_Bottom). The same component
// serves the empty state (variant='hero': centered launch card) and the
// resident composer (variant='composer') — the empty→content transition is a
// position move of this component, never a swap (layout ruling). Running
// LOCKS the input: textarea disabled with the draft visible, stop is the only
// action; the turn ending re-enables and refocuses.
//
// Bottom chrome (attach / Plan / Read-only / model) is visual-only for now —
// local native <select> state, no host wiring.
// Shared empty-state and resident composer. Running retains the draft, locks
// the textarea, and exposes only Stop. Bottom controls are local visual state.
import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
@@ -16,7 +9,7 @@ import css from './InputBar.module.css'
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
export interface InputBarError {
op: 'send' | 'stop'
op: 'workspace' | 'session' | 'send' | 'stop'
message: string
}
@@ -25,14 +18,17 @@ export interface InputBarProps {
running: boolean
disabled: boolean
error: InputBarError | null
/** Observable async phase for browser fixtures and assistive technology. */
status?: string
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
placeholder?: string
/** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */
accessory?: ReactNode
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
onAdd?: () => void
addLabel?: string
}
interface SelectOption {
@@ -56,7 +52,8 @@ const MODEL_OPTIONS: readonly SelectOption[] = [
]
export function InputBar({
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
draft, running, disabled, error, status, variant, placeholder, accessory,
onDraftChange, onSend, onStop, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
@@ -107,7 +104,7 @@ export function InputBar({
inputRef.current?.focus()
}
const primaryLabel = running ? '停止' : '发送'
const primaryLabel = running ? 'Stop generating' : 'Send message'
const onPrimary = (): void => {
if (running) {
onStop()
@@ -138,11 +135,8 @@ export function InputBar({
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{error !== null && (
<div className={css.error}>
{error.op === 'stop' ? '停止失败' : '发送失败'}:{error.message}
</div>
)}
{status !== undefined && <div className={css.status} role="status">{status}</div>}
{error !== null && <div className={css.error} role="alert">{error.message}</div>}
<div className={css.card}>
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
@@ -154,7 +148,7 @@ export function InputBar({
className={css.input}
value={draft}
disabled={locked}
placeholder={placeholder ?? (disabled ? '会话不可用' : running ? '回复生成中,可停止后再输入' : '输入消息,Enter 发送,Shift+Enter 换行')}
placeholder={placeholder ?? (disabled ? 'Session unavailable' : running ? 'Generating a response…' : 'Message the agent')}
rows={2}
onChange={(e) => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
@@ -168,10 +162,11 @@ export function InputBar({
<button
type="button"
className={css.add}
aria-label="添加"
title="添加"
aria-label={addLabel}
title={addLabel}
disabled={locked}
onMouseDown={keepFocus}
onClick={onAdd}
>
<IconPlusOutline16 size={14} />
</button>
@@ -186,7 +181,7 @@ export function InputBar({
type="button"
className={clsx(css.primary, running && css.stopping)}
aria-label={primaryLabel}
title={running ? '停止本轮' : '发送(Enter)'}
title={primaryLabel}
disabled={!running && (empty || disabled)}
onMouseDown={keepFocus}
onClick={onPrimary}

View File

@@ -1,21 +1,11 @@
/**
* Chat store factory (slot terminal design §4): selection + draft + active
* view for one session, shared by the conversation and details registrations
* (apply constructs one handle and passes it to both). Session-scope
* derivation: both mount slots are scope=session, so the framework creates
* one instance per session; the persist key is scope-suffixed by the
* framework, aligning with the previous per-session draft persistence.
*
* Module exports the factory only — a module-level handle would pin identity
* in the module cache (a de-facto singleton surviving plugin reloads).
* Per-session chat store shared by conversation and details registrations.
* The plugin creates its handle at apply time so identity follows the fiber.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
/**
* Annotation twin of the actions literal below (the export needs a declared
* return type); drift fails assignability at the defineStore call.
*/
/** Declared action shape used to give the exported factory a stable return type. */
type ChatActions = {
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
setDraft: (draft: ChatStoreState, text: string) => void
@@ -25,18 +15,11 @@ type ChatActions = {
}
/**
* Declare the per-session chat store. `selection` is the details-linkage
* channel (conversation writes, details reads); `draft` is the composer text
* (persisted so it survives session switches and reloads); `view` is the
* active conversation view id (a 'conversation.view' entry id — store seat is
* the cross-remount survival channel, null falls back to the first view).
* @returns the store handle (spec + identity + factory in one value).
* Declares the per-session chat state and write surface.
* @returns the store handle.
*/
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
return defineStore({
// Anchored to the contract shape: consumers read the store through
// PropsStore<ChatStore>'s SnapshotSelectorHook<ChatStoreState>, so init
// and the contract cannot drift.
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
persist: 'dsh.conversation.chat',
actions: {

View File

@@ -1,10 +1,4 @@
/**
* Conversation plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader (load and lifecycle
* follow the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Contract: api-contracts
* v3 sections 0.3 and 7.
*/
/** Host loader entry for the browser-only conversation plugin. */
/** Host plugin body — no host-side behavior for the conversation plugin. */
/** Provides no host-side behavior. */
export function apply(): void {}