Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # apps/cli/README.md # apps/cli/src/web.ts # apps/web/tests/smoke-fixture.e2e.ts # docs/architecture.i18n.yaml # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/client/connection/src/client/api.ts # packages/client/connection/src/client/fixture.ts # packages/client/runtime/README.md # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/service.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/src/client/stores.ts # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton-branches.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/package.json # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/sessions.schema.ts # packages/host/runtime/package.json # packages/host/runtime/src/boot.ts # packages/host/runtime/tests/host-runtime.spec.ts # packages/host/runtime/tsconfig.json # packages/host/webserver/README.md # packages/host/webserver/src/index.ts # packages/host/webserver/tests/webserver.spec.ts # packages/llm/llm-pi-ai/tests/convert.spec.ts # packages/ui/acp/src/codec.ts # packages/ui/acp/tests/codec.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -2,13 +2,15 @@
|
||||
|
||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
|
||||
|
||||
The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService.
|
||||
|
||||
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction.
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain).
|
||||
Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging.
|
||||
|
||||
Image drafts keep only ordered runtime ids in that store. `ConversationService` owns the corresponding browser `File` and object URLs, applies the latest host capability and upload-limit snapshot before allocation, and releases draft URLs on removal or send plus historical URLs when their rendered session unmounts. Paste and drop share the same validation path; mixed clipboard text remains native textarea input.
|
||||
|
||||
|
||||
@@ -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')) {
|
||||
@@ -122,7 +106,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)
|
||||
@@ -142,12 +128,13 @@ export function apply(ctx: Context): void {
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
|
||||
const conversation = ctx.get('conversation')
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
const scoped = scopedConversation(sessions, sessionId)
|
||||
return {
|
||||
openDetails: (target) => {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
loadOlder: () => { void sessions.manager.get(sessionId).loadOlder() },
|
||||
loadOlder: () => { void scoped.loadOlder() },
|
||||
loadImage: attachment => conversation.resolveImage(sessionId, attachment),
|
||||
}
|
||||
},
|
||||
@@ -174,24 +161,24 @@ 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; resolve lazily so a torn boot remains fail-loud when
|
||||
// an already-mounted empty state invokes one of these callbacks.
|
||||
// The service lives on this plugin's child fiber; resolve lazily from
|
||||
// the root store so an incomplete boot still fails at first use.
|
||||
const conversation = (): ConversationService => {
|
||||
const service = ctx.get('conversation')
|
||||
if (service === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
return service
|
||||
}
|
||||
return {
|
||||
startSession: (workspaceId, prompt) => { workspaces.startSession(workspaceId, prompt) },
|
||||
updateSessionPrompt: (text) => { sessions.updateIntent(text) },
|
||||
createDraftImages: (files, current) => conversation().createDraftImages(files, current, true),
|
||||
releaseDraftImage: (id) => { conversation().releaseDraftImage(id) },
|
||||
releaseDraftImages: (attachments) => { conversation().releaseDraftImages(attachments) },
|
||||
startSession: opts => conversation().startSession(opts),
|
||||
createWorkspaceSession: async (name) => {
|
||||
const id = await sessions.createWorkspace(name)
|
||||
sessions.open(id)
|
||||
sendSession: async (images) => {
|
||||
await conversation().prepareIntentImages(images.map(image => image.file))
|
||||
workspaces.sendSession()
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
/**
|
||||
* 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 { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
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'
|
||||
|
||||
@@ -50,6 +40,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,15 +94,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
|
||||
@@ -128,8 +114,12 @@ export interface ConversationInjected {
|
||||
send(text: string, images: readonly ComposerAttachment[], 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,7 +130,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[]
|
||||
}
|
||||
|
||||
@@ -156,7 +145,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
|
||||
/** Resolve a session-authorized historical image for inline display. */
|
||||
loadImage(attachment: ImageAttachmentRef): Promise<string>
|
||||
@@ -179,31 +167,30 @@ 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. */
|
||||
/** Owner share common to the empty hero's Workspace picker. */
|
||||
export interface EmptyWorkspaceOwnerProps {
|
||||
open: boolean
|
||||
anchorRef?: RefObject<HTMLElement>
|
||||
onPick(workspaceId: WorkspaceId): void
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** Create service-owned image previews after host-capability preflight. */
|
||||
createDraftImages(files: readonly File[], current: readonly ComposerAttachment[]): readonly ComposerAttachment[]
|
||||
/** Release one service-owned image preview. */
|
||||
releaseDraftImage(id: string): void
|
||||
/** Release all service-owned image previews held by the empty state. */
|
||||
releaseDraftImages(attachments: readonly ComposerAttachment[]): void
|
||||
/**
|
||||
* The create → first-send → navigate chain, in one service call. Navigation
|
||||
* happens only after the send is accepted, so a failure leaves the empty
|
||||
* state and its draft mounted.
|
||||
*/
|
||||
startSession(opts: {
|
||||
cwd?: string
|
||||
text: string
|
||||
images?: readonly File[]
|
||||
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>
|
||||
/** Materialize and send the current Session intent with its browser-owned images. */
|
||||
sendSession(images: readonly ComposerAttachment[]): Promise<void>
|
||||
}
|
||||
|
||||
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
|
||||
export type EmptyStateSlotProps = PropsRuntime<'conversation.empty'> & EmptyStateInjected
|
||||
/** Full empty-state component props: runtime projections, picker child slot, and injected actions. */
|
||||
export type EmptyStateSlotProps =
|
||||
PropsRuntime<'conversation.empty'> & PropsRenderSlots<'conversation.empty.workspace'> & EmptyStateInjected
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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, ComposerAttachment, ComposerChainProps,
|
||||
ConversationInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps,
|
||||
DetailsInjected, DetailsSlotProps, EmptyStateInjected, EmptyStateSlotProps,
|
||||
DetailsInjected, DetailsSlotProps, EmptyStateInjected, EmptyStateSlotProps, EmptyWorkspaceOwnerProps,
|
||||
ToolRowOwnerProps, ToolRowProps,
|
||||
} from './contract/slots.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
@@ -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'
|
||||
@@ -156,8 +150,9 @@ export class ConversationService extends Service {
|
||||
const cached = this.imageUrls.get(key)
|
||||
if (cached !== undefined) return cached.pending
|
||||
const generation = this.imageGenerations.get(sessionId) ?? 0
|
||||
const pending = this.requireSessions().manager.get(sessionId)
|
||||
.readAttachment(attachment.attachmentId)
|
||||
const session = this.requireSessions().binding(sessionId)?.session
|
||||
if (session === undefined) return Promise.reject(new Error(`conversation.resolveImage: unknown session "${sessionId}"`))
|
||||
const pending = session.readAttachment(attachment.attachmentId)
|
||||
.then((result) => {
|
||||
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
||||
if (typeof URL.createObjectURL !== 'function') {
|
||||
@@ -207,44 +202,42 @@ 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, send through the new scope, and navigate only after
|
||||
* the send is accepted. Navigation is the publication point — opening
|
||||
* earlier would unmount the empty state (releasing its draft previews)
|
||||
* while the send can still fail, leaving the failure with no surface and
|
||||
* the user with a lost draft; on rejection here the still-mounted empty
|
||||
* state keeps the draft and shows the error locally.
|
||||
* @param opts - project directory, prompt text, images, and send mode.
|
||||
* Copy browser-owned images into the current Session Intent before its
|
||||
* workspace/session materialization starts.
|
||||
* @param images - temporary files selected in the empty-state composer.
|
||||
*/
|
||||
async startSession(opts: {
|
||||
cwd?: string
|
||||
text: string
|
||||
images?: readonly File[]
|
||||
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
|
||||
// (the manager merges the new summary synchronously before create()
|
||||
// resolves; batching is microtask-based).
|
||||
await Promise.resolve()
|
||||
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, opts.images ?? [])
|
||||
sessions.open(id)
|
||||
async prepareIntentImages(images: readonly File[]): Promise<void> {
|
||||
this.validateImages(images, [], true)
|
||||
const session = this.requireSessions().intent()
|
||||
if (session === undefined) throw new Error('conversation.prepareIntentImages: no active Session intent')
|
||||
session.updatePendingImages(await this.serializeImages(images))
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the scoped Session's retained pending prompt.
|
||||
* @param text - exact controlled-input value to retain.
|
||||
*/
|
||||
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. */
|
||||
@@ -271,6 +264,7 @@ export class ConversationService extends Service {
|
||||
current: readonly ComposerAttachment[],
|
||||
checkDefaultModel = false,
|
||||
): void {
|
||||
if (files.length === 0 && current.length === 0) return
|
||||
const description = this.requireSessions().hostDescription()
|
||||
const modalities = description?.activeModel?.inputModalities
|
||||
if (checkDefaultModel && modalities !== undefined && !modalities.includes('image')) {
|
||||
@@ -296,6 +290,16 @@ export class ConversationService extends Service {
|
||||
throw new Error('图片总大小超过单条消息限制')
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert browser files to the prompt wire's canonical base64 image parts. */
|
||||
private serializeImages(images: readonly File[]): Promise<Parameters<Session['updatePendingImages']>[0]> {
|
||||
return Promise.all(images.map(async file => ({
|
||||
type: 'image' as const,
|
||||
mediaType: imageMediaType(file.type),
|
||||
data: bytesToBase64(new Uint8Array(await file.arrayBuffer())),
|
||||
...(file.name === '' ? {} : { name: file.name }),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
function imageMediaType(value: string): ImageMediaType {
|
||||
|
||||
@@ -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,9 +38,9 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
|
||||
}
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain,
|
||||
sessionId, useSession, useSessions, useWorkspaces, useStore, actions, renderSlot, renderSlotChain,
|
||||
views, addImages, removeImage, draftImages, releaseSessionImages,
|
||||
send, stop, open,
|
||||
send, stop, open, updateSessionPrompt, retrySessionPrompt,
|
||||
}: ConversationRootProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const tabs = views.list()
|
||||
@@ -49,14 +50,43 @@ 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 pendingPrompt = useSession(s => s.pendingPrompt ?? undefined)
|
||||
const storedDraft = useStore(s => s.draft)
|
||||
const draft = pendingPrompt?.text ?? storedDraft
|
||||
const imageIds = useStore(s => s.imageIds)
|
||||
const attachments = useMemo(() => draftImages(imageIds), [draftImages, imageIds])
|
||||
const running = useSession(s => s.running)
|
||||
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, attachments, mode)
|
||||
else retrySessionPrompt()
|
||||
}
|
||||
|
||||
// Browser File/object-URL values are runtime-only. A reload may rehydrate
|
||||
// ids whose objects no longer exist; prune those ids after the first render.
|
||||
@@ -70,9 +100,27 @@ export function ConversationRoot({
|
||||
releaseSessionImages(sessionId)
|
||||
}, [releaseSessionImages, sessionId])
|
||||
|
||||
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}
|
||||
attachments={attachments}
|
||||
disabled={removed || pendingPrompt?.phase === 'sending'}
|
||||
error={error}
|
||||
{...(status === undefined ? {} : { status })}
|
||||
onDraftChange={setDraft}
|
||||
onAddImages={files => addImages(files, attachments)}
|
||||
onRemoveAttachment={removeImage}
|
||||
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.
|
||||
@@ -83,11 +131,12 @@ export function ConversationRoot({
|
||||
running={running}
|
||||
disabled={removed}
|
||||
error={error}
|
||||
{...(status === undefined ? {} : { status })}
|
||||
variant="composer"
|
||||
onDraftChange={actions.setDraft}
|
||||
onDraftChange={setDraft}
|
||||
onAddImages={files => addImages(files, attachments)}
|
||||
onRemoveAttachment={removeImage}
|
||||
onSend={(mode) => { send(draft, attachments, mode) }}
|
||||
onSend={submit}
|
||||
onStop={stop}
|
||||
/>
|
||||
)
|
||||
@@ -96,7 +145,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 (
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// 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 type { ComposerAttachment } from '../contract/slots.ts'
|
||||
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
|
||||
attachments?: readonly ComposerAttachment[]
|
||||
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
|
||||
onAddImages?: (files: readonly File[]) => string | null
|
||||
onRemoveAttachment?: (id: 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,
|
||||
attachments = [],
|
||||
disabled,
|
||||
placeholder,
|
||||
error,
|
||||
status,
|
||||
onDraftChange,
|
||||
onAddImages,
|
||||
onRemoveAttachment,
|
||||
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'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}
|
||||
attachments={attachments}
|
||||
running={false}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
{...(status === undefined ? {} : { status })}
|
||||
variant="hero"
|
||||
placeholder={placeholder ?? 'Describe what you want to build'}
|
||||
onDraftChange={onDraftChange}
|
||||
{...(onAddImages === undefined ? {} : { onAddImages })}
|
||||
{...(onRemoveAttachment === undefined ? {} : { onRemoveAttachment })}
|
||||
onSend={onSend}
|
||||
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
|
||||
onStop={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,342 +1,124 @@
|
||||
// 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 { useEffect, useId, useMemo, useRef, 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 { useEffect, useRef, useState } from 'react'
|
||||
import type { ComposerAttachment, 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]
|
||||
}
|
||||
|
||||
/** 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,
|
||||
useWorkspaces,
|
||||
startSession,
|
||||
updateSessionPrompt,
|
||||
createDraftImages,
|
||||
releaseDraftImage,
|
||||
releaseDraftImages,
|
||||
startSession,
|
||||
createWorkspaceSession,
|
||||
sendSession,
|
||||
renderSlot,
|
||||
}: 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 intent = useSessions(state => state.intent)
|
||||
const workspaceSnapshot = useWorkspaces(state => state)
|
||||
const workspaces = workspaceSnapshot.items
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [attachments, setAttachments] = useState<readonly ComposerAttachment[]>([])
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [sendError, setSendError] = useState<string | null>(null)
|
||||
const attachmentsRef = useRef(attachments)
|
||||
attachmentsRef.current = attachments
|
||||
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 === '' && attachments.length === 0) || sending) return
|
||||
setSending(true)
|
||||
setError(null)
|
||||
const chosen = cwd.trim()
|
||||
startSession({
|
||||
text,
|
||||
...(attachments.length === 0 ? {} : { images: attachments.map(item => item.file) }),
|
||||
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 pickerAnchor = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => () => {
|
||||
releaseDraftImages(attachmentsRef.current)
|
||||
}, [releaseDraftImages])
|
||||
|
||||
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 = preparing || 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 = sendError !== null
|
||||
? { op: 'send', message: sendError }
|
||||
: workspaceIntent?.error !== undefined
|
||||
? { op: 'workspace', message: `Workspace creation failed: ${workspaceIntent.error}` }
|
||||
: intent.error === undefined
|
||||
? null
|
||||
: { op: 'session', message: `Session creation failed: ${intent.error.message}` }
|
||||
|
||||
const addImages = (files: readonly File[]): string | null => {
|
||||
setSendError(null)
|
||||
try {
|
||||
const added = createDraftImages(files, attachments)
|
||||
setAttachments(current => [...current, ...added])
|
||||
setAttachments(current => [...current, ...createDraftImages(files, current)])
|
||||
return null
|
||||
} catch (reason: unknown) {
|
||||
return reason instanceof Error ? reason.message : String(reason)
|
||||
} catch (error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
const removeImage = (id: string): void => {
|
||||
releaseDraftImage(id)
|
||||
setAttachments(current => current.filter(item => item.id !== id))
|
||||
setAttachments(current => current.filter(attachment => attachment.id !== id))
|
||||
}
|
||||
|
||||
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 submit = (): void => {
|
||||
if (preparing) return
|
||||
setPreparing(true)
|
||||
setSendError(null)
|
||||
void sendSession(attachments).then(() => {
|
||||
releaseDraftImages(attachments)
|
||||
setAttachments([])
|
||||
}).catch((error: unknown) => {
|
||||
setSendError(error instanceof Error ? error.message : String(error))
|
||||
}).finally(() => {
|
||||
setPreparing(false)
|
||||
})
|
||||
}
|
||||
|
||||
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'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}
|
||||
attachments={attachments}
|
||||
running={false}
|
||||
disabled={sending}
|
||||
error={error}
|
||||
variant="hero"
|
||||
placeholder="Message to run task, plan and build, enter for / commands"
|
||||
onDraftChange={setDraft}
|
||||
onAddImages={addImages}
|
||||
onRemoveAttachment={removeImage}
|
||||
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}
|
||||
attachments={attachments}
|
||||
disabled={busy}
|
||||
{...(status === undefined ? {} : { status })}
|
||||
error={error}
|
||||
onDraftChange={updateSessionPrompt}
|
||||
onAddImages={addImages}
|
||||
onRemoveAttachment={removeImage}
|
||||
onSend={submit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -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 { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { ChangeEvent, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
@@ -18,7 +11,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
|
||||
}
|
||||
|
||||
@@ -28,16 +21,19 @@ 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
|
||||
onAddImages?: (files: readonly File[]) => string | null
|
||||
onRemoveAttachment?: (id: string) => void
|
||||
onSend: (mode: 'queue' | 'steer') => void
|
||||
onStop: () => void
|
||||
onAdd?: () => void
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
@@ -61,8 +57,9 @@ const MODEL_OPTIONS: readonly SelectOption[] = [
|
||||
]
|
||||
|
||||
export function InputBar({
|
||||
draft, attachments = [], running, disabled, error, variant, placeholder, accessory,
|
||||
draft, attachments = [], running, disabled, error, status, variant, placeholder, accessory,
|
||||
onDraftChange, onAddImages = () => null, onRemoveAttachment = () => {}, onSend, onStop,
|
||||
onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const empty = draft.trim() === '' && attachments.length === 0
|
||||
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
|
||||
@@ -161,7 +158,7 @@ export function InputBar({
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const primaryLabel = running ? '停止' : '发送'
|
||||
const primaryLabel = running ? 'Stop generating' : 'Send message'
|
||||
const onPrimary = (): void => {
|
||||
if (running) {
|
||||
onStop()
|
||||
@@ -192,11 +189,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>}
|
||||
{dropError !== null && <div className={css.error}>{dropError}</div>}
|
||||
<div
|
||||
className={clsx(css.card, dragActive && css.dragActive)}
|
||||
@@ -241,7 +235,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) => {
|
||||
setDropError(null)
|
||||
@@ -259,10 +253,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>
|
||||
@@ -277,7 +272,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}
|
||||
|
||||
@@ -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
|
||||
@@ -28,12 +18,8 @@ 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({
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// shape: the conversation surface (views triple, send choreography incl.
|
||||
// optimistic clear + failure restore THROUGH the declared store actions,
|
||||
// openDetails = select action + layout orchestration, sessions.open
|
||||
// navigation), the injectless-but-closeDetails details surface, and the
|
||||
// one-callback empty surface. Complements chat-apply.spec.tsx (registration)
|
||||
// navigation), and the closeDetails details surface. Complements
|
||||
// chat-apply.spec.tsx (registration)
|
||||
// and selection-survival.spec.ts (store axis). History opening is NOT an
|
||||
// inject concern anymore — the runtime sessions service opens on watch
|
||||
// (sessions-service.spec.ts owns that behavior).
|
||||
@@ -14,9 +14,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { ConversationService, apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -53,13 +55,17 @@ async function bench() {
|
||||
ids: [ROOT],
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
current: ROOT,
|
||||
} as SessionListState)
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const sessionFake = {
|
||||
open: vi.fn(() => Promise.resolve()),
|
||||
loadOlder: vi.fn(() => Promise.resolve()),
|
||||
updatePendingPrompt: vi.fn(),
|
||||
updatePendingImages: vi.fn(),
|
||||
retryPendingPrompt: vi.fn(),
|
||||
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
readAttachment: vi.fn(() => Promise.reject(new Error('attachment response not configured'))),
|
||||
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
}
|
||||
@@ -74,16 +80,25 @@ async function bench() {
|
||||
}
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
manager: { get: () => sessionFake },
|
||||
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
|
||||
scope: (id: SessionId) => mint(id),
|
||||
cell: () => undefined,
|
||||
scopeOf,
|
||||
hostDescription: () => undefined,
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
createWorkspace: vi.fn(() => Promise.resolve(ROOT)),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
intent: () => sessionFake,
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
const workspaceStore = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const workspacesFake = {
|
||||
list: workspaceStore,
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
}
|
||||
ctx.provide('workspaces', workspacesFake)
|
||||
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
ctx.provide('layout', layoutFake)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
@@ -126,20 +141,25 @@ async function bench() {
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
return { ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, sessionFake, sessionsFake, layoutFake, mint }
|
||||
const emptySurface = () => {
|
||||
const entry = entryOf('conversation.empty')
|
||||
return (entry.inject as unknown as () => EmptyStateInjected)()
|
||||
}
|
||||
return {
|
||||
ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, emptySurface,
|
||||
sessionFake, sessionsFake, workspacesFake, layoutFake, mint,
|
||||
}
|
||||
}
|
||||
|
||||
describe('conversation slot inject surface', () => {
|
||||
it('assembles the thin surface side-effect-free, navigates via sessions.open', async () => {
|
||||
it('assembles the thin surface side-effect-free', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
// Assembly has no session side effects: opening the event window belongs
|
||||
// to the runtime watch path, not the inject factory.
|
||||
expect(b.sessionFake.open).not.toHaveBeenCalled()
|
||||
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
|
||||
injected.open(ROOT)
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
// loadOlder moved to the chat view entry's face (the ring rider).
|
||||
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
@@ -205,6 +225,17 @@ describe('conversation slot inject surface', () => {
|
||||
expect(conv.instance).toBe(instance)
|
||||
})
|
||||
|
||||
it('routes navigation through SessionsService and the retained prompt through the scoped Session', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
injected.open(ROOT)
|
||||
injected.updateSessionPrompt('revised')
|
||||
injected.retrySessionPrompt()
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
expect(b.sessionFake.updatePendingPrompt).toHaveBeenCalledWith('revised')
|
||||
expect(b.sessionFake.retryPendingPrompt).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
@@ -228,7 +259,7 @@ describe('conversation slot inject surface', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('details and empty inject surfaces', () => {
|
||||
describe('details inject surface', () => {
|
||||
it('details injects the one layout callback; selection rides the shared store instead', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('details')
|
||||
@@ -242,35 +273,18 @@ describe('details and empty inject surfaces', () => {
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('empty injects draft-image lifecycle, startSession, and createWorkspaceSession without a store', async () => {
|
||||
it('empty state injects the runtime intent actions and remains storeless', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('conversation.empty')
|
||||
expect(entry.store).toBeUndefined()
|
||||
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
|
||||
expect(Object.keys(injected).sort()).toEqual([
|
||||
'createDraftImages',
|
||||
'createWorkspaceSession',
|
||||
'releaseDraftImage',
|
||||
'releaseDraftImages',
|
||||
'startSession',
|
||||
])
|
||||
await injected.startSession({ text: 'go', mode: 'queue' })
|
||||
expect(b.sessionsFake.create).toHaveBeenCalled()
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
|
||||
b.sessionsFake.open.mockClear()
|
||||
await injected.createWorkspaceSession('Fresh')
|
||||
expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh')
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
})
|
||||
|
||||
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
|
||||
const b = await bench()
|
||||
const injected = (b.entryOf('conversation.empty').inject as unknown as () => EmptyStateInjected)()
|
||||
// Tear the service's own fiber (registry keyed by the class): the slot
|
||||
// entries survive, so the gesture-time read hits the loud branch.
|
||||
b.ctx.registry.delete(ConversationService)
|
||||
await vi.waitFor(() => { expect(b.ctx.get('conversation')).toBeUndefined() })
|
||||
expect(() => injected.startSession({ text: 'go', mode: 'queue' })).toThrow(/conversation service unavailable/)
|
||||
const injected = b.emptySurface()
|
||||
injected.startSession(undefined, 'fresh')
|
||||
injected.startSession('workspace-1' as never, 'retargeted')
|
||||
injected.updateSessionPrompt('typed')
|
||||
await injected.sendSession([])
|
||||
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(1, undefined, 'fresh')
|
||||
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(2, 'workspace-1', 'retargeted')
|
||||
expect(b.sessionsFake.updateIntent).toHaveBeenCalledWith('typed')
|
||||
expect(b.workspacesFake.sendSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,16 +30,23 @@ async function bench() {
|
||||
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 },
|
||||
},
|
||||
current: undefined,
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
manager: { get: vi.fn() },
|
||||
binding: vi.fn(),
|
||||
scope: () => undefined,
|
||||
cell: () => undefined,
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
ctx.provide('workspaces', {
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
|
||||
@@ -84,7 +91,7 @@ describe('apply wiring', () => {
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
|
||||
})
|
||||
|
||||
it('occupies the three slots + the ring; session entries share one store handle, empty declares none', async () => {
|
||||
it('occupies the three slots + the ring; session entries share one store handle, empty injects runtime actions', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
const conversation = renderEntryOf(b.slots, 'conversation')
|
||||
|
||||
@@ -27,8 +27,8 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,8 @@ describe('bash sample row', () => {
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* createChatStore unit account (slot terminal design §4): the declared
|
||||
* actions write set, persist round-trip through the scope-suffixed key, and
|
||||
* factory purity (every create() is an independent instance; the factory
|
||||
* itself holds no singleton state).
|
||||
*/
|
||||
/** Chat-store actions, scoped persistence, and instance isolation. */
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -40,8 +40,8 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
|
||||
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
@@ -65,9 +65,11 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', running: false, updatedAt: 1 } },
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
} as SessionListState)
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
// Identity-stable cell: the renderer caches hooks per source and inject
|
||||
// results per cell, both by object identity.
|
||||
const cell = { sessionId: SID, session }
|
||||
@@ -75,11 +77,20 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
manager: { get: () => ({ loadOlder: vi.fn() }) },
|
||||
binding: (id: SessionId) => ({ sessionId: id, session: { loadOlder: vi.fn() } }),
|
||||
scope: () => ({ get: () => scoped }),
|
||||
cell: (id: string) => (id === SID ? cell : undefined),
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
@@ -146,8 +157,6 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
|
||||
it('a duplicate key registration fails loud at load', async () => {
|
||||
const b = await bench([])
|
||||
// The bash sample already holds the 'bash' key (later-wins retired with
|
||||
// the ring — the keyed ledger throws instead).
|
||||
expect(() => b.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'bash' },
|
||||
() => null,
|
||||
@@ -184,12 +193,23 @@ describe('registrant load-order seam', () => {
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined } as SessionListState),
|
||||
manager: { get: vi.fn() },
|
||||
list: createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
|
||||
}),
|
||||
binding: () => undefined,
|
||||
scope: () => undefined,
|
||||
cell: () => undefined,
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -29,8 +29,8 @@ const SID = 's1' as SessionId
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +69,18 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
|
||||
})
|
||||
|
||||
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */
|
||||
/** Empty sessions-list hook for the global standard-kit seat. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined } as SessionListState)
|
||||
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -95,6 +103,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(source),
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
|
||||
@@ -87,8 +87,10 @@ describe('tails', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
byId: { [sid]: { id: sid, title: 'r', running: false, updatedAt: 0 } },
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
const props = {
|
||||
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
// Final branch tails for the coverage gate, terminal slot form:
|
||||
// AssistantMarkdown non-final reasoning, StatsLine usage-less node,
|
||||
// DetailsPanel titleless selection. (The old cwd WeakMap-cache account
|
||||
// retired with the mechanism — derivation lives in EmptyState now, covered
|
||||
// by the skeleton specs.)
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
@@ -24,8 +19,8 @@ const SID = 's1' as SessionId
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
@@ -70,12 +65,17 @@ describe('render branch tails', () => {
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined } as SessionListState)
|
||||
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Test-local selector-hook binder: the engine carries no hook since the store
|
||||
* migration (runtime is React-free); the renderer binds in production, specs
|
||||
* bind here. Delegates to web-react's bindSnapshotSelector SOURCE (same
|
||||
* with-selector uSES shim as production, so selector-level render economics —
|
||||
* a top-level snapshot swap with an unchanged slice does NOT re-render — hold
|
||||
* in Profiler-count specs). Source-relative import: the package dependency
|
||||
* edge to web-react is gone (store migration §7); tests reach the sibling
|
||||
* package the same way they reach their own src internals.
|
||||
*/
|
||||
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
|
||||
|
||||
/** Minimal observable source (engine stores and scripted fakes both satisfy it). */
|
||||
export interface HookSource<T> {
|
||||
getSnapshot(): T
|
||||
subscribe(fn: () => void): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a selector hook over a snapshot source.
|
||||
* @param src - the source.
|
||||
* @returns a SnapshotSelectorHook-shaped hook.
|
||||
*/
|
||||
export function hookOf<T>(src: HookSource<T>) {
|
||||
return bindSnapshotSelector<T>(src)
|
||||
}
|
||||
@@ -19,9 +19,9 @@ function setup(over?: Partial<InputBarProps>) {
|
||||
}
|
||||
const view = render(<InputBar {...props} />)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
// aria-label (not role name): title also contains 发送/停止 and would double-match.
|
||||
// aria-label (not role name): title carries the same label and would double-match.
|
||||
const button = view.container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${over?.running === true ? '停止' : '发送'}"]`,
|
||||
`button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`,
|
||||
)!
|
||||
return { view, textarea, button, props }
|
||||
}
|
||||
@@ -80,7 +80,7 @@ describe('running lock and primary button', () => {
|
||||
it('running locks the textarea and turns the primary into stop', () => {
|
||||
const { textarea, button, props } = setup({ running: true })
|
||||
expect(textarea.disabled).toBe(true)
|
||||
expect(button.getAttribute('aria-label')).toBe('停止')
|
||||
expect(button.getAttribute('aria-label')).toBe('Stop generating')
|
||||
fireEvent.click(button)
|
||||
expect(props.onStop).toHaveBeenCalledTimes(1)
|
||||
expect(props.onSend).not.toHaveBeenCalled()
|
||||
@@ -100,30 +100,30 @@ describe('running lock and primary button', () => {
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
textarea.blur()
|
||||
fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!)
|
||||
fireEvent.mouseDown(view.container.querySelector('button[aria-label="Send message"]')!)
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
})
|
||||
|
||||
it('disabled state shows the unavailable placeholder; typing forwards drafts', () => {
|
||||
const { textarea } = setup({ disabled: true, draft: '' })
|
||||
expect(textarea.placeholder).toBe('会话不可用')
|
||||
expect(textarea.placeholder).toBe('Session unavailable')
|
||||
const live = setup({ draft: '' })
|
||||
expect(live.textarea.placeholder).toContain('Enter 发送')
|
||||
expect(live.textarea.placeholder).toBe('Message the agent')
|
||||
fireEvent.change(live.textarea, { target: { value: 'typed' } })
|
||||
expect(live.props.onDraftChange).toHaveBeenCalledWith('typed')
|
||||
const runningPh = setup({ running: true, draft: '' })
|
||||
expect(runningPh.textarea.placeholder).toContain('停止')
|
||||
const custom = setup({ placeholder: '自定义' })
|
||||
expect(custom.textarea.placeholder).toBe('自定义')
|
||||
expect(runningPh.textarea.placeholder).toBe('Generating a response…')
|
||||
const custom = setup({ placeholder: 'Custom placeholder' })
|
||||
expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error strip and variants', () => {
|
||||
it('renders send and stop failure copy', () => {
|
||||
const send = setup({ error: { op: 'send', message: 'boom' } })
|
||||
expect(send.view.getByText(/发送失败:boom/)).toBeTruthy()
|
||||
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom')
|
||||
const stop = setup({ error: { op: 'stop', message: 'halt' } })
|
||||
expect(stop.view.getByText(/停止失败:halt/)).toBeTruthy()
|
||||
expect(stop.view.container.querySelector('[role="alert"]')?.textContent).toBe('halt')
|
||||
})
|
||||
|
||||
it('hero variant adds the hero class and accessory row renders', () => {
|
||||
@@ -212,7 +212,7 @@ describe('image draft rail', () => {
|
||||
const { view, textarea, props } = setup({
|
||||
draft: '', attachments: [attachment], onRemoveAttachment,
|
||||
})
|
||||
const send = view.getByRole('button', { name: '发送' }) as HTMLButtonElement
|
||||
const send = view.getByRole('button', { name: 'Send message' }) as HTMLButtonElement
|
||||
expect(send.disabled).toBe(false)
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(props.onSend).toHaveBeenCalledWith('queue')
|
||||
@@ -230,7 +230,7 @@ describe('image draft rail', () => {
|
||||
describe('placeholder chrome', () => {
|
||||
it('renders attach / Plan / Read-only / model controls', () => {
|
||||
const { view } = setup()
|
||||
expect(view.getByLabelText('添加')).toBeTruthy()
|
||||
expect(view.getByLabelText('Add attachment')).toBeTruthy()
|
||||
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan')
|
||||
expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
|
||||
expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high')
|
||||
@@ -256,7 +256,7 @@ describe('placeholder chrome', () => {
|
||||
|
||||
it('running locks the chrome selects and attach control', () => {
|
||||
const { view } = setup({ running: true })
|
||||
expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,38 +1,35 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Selection survival across the store seat (terminal design §4): the chat
|
||||
* store now carries what the per-scope selection account used to — this pins
|
||||
* the same behavior contract in the new mechanism. Drives the REAL
|
||||
* SlotsService store axis with the shared createChatStore handle (the exact
|
||||
* apply.ts shape: one handle, two session-slot registrations): same session's
|
||||
* two slots resolve one instance (conversation writes, details reads);
|
||||
* sessions are isolated; a session's death buries its instance AND its
|
||||
* persisted draft; a list refresh does not touch instance identity.
|
||||
* Exercises selection persistence through the real SlotsService store axis;
|
||||
* component stubs cannot prove per-session identity or disposal.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
// The runtime package's programmable fake lives in its tests; import through
|
||||
// the src path (same pattern the runtime specs use — test-support material).
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
api: FakeApiClient
|
||||
sessions: SessionsService
|
||||
slots: SlotsService
|
||||
chat: ReturnType<typeof createChatStore>
|
||||
}
|
||||
|
||||
function bench(): Bench {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
|
||||
}),
|
||||
cell: () => undefined,
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
})
|
||||
// Service self-registers as ctx 'slots' (cordis Service constructor).
|
||||
const slots = new SlotsService(ctx)
|
||||
const chat = createChatStore()
|
||||
@@ -49,22 +46,7 @@ function bench(): Bench {
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
slots.register({ name: 'conversation', store: chat }, () => null)
|
||||
slots.register({ name: 'details', store: chat }, () => null)
|
||||
return { ctx, api, sessions, slots, chat }
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
// Manager notifier + store batching are microtask-based.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[]): void {
|
||||
b.api.onList = () => Promise.resolve(ok({
|
||||
items: rows.map(r => ({
|
||||
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
})),
|
||||
}) as never)
|
||||
return { slots, chat }
|
||||
}
|
||||
|
||||
/** Resolve the store instance the renderer would hand a slot's component for a session. */
|
||||
@@ -94,11 +76,8 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('selection survives on the store seat', () => {
|
||||
it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
|
||||
it('one session, two slots: conversation writes, details reads the SAME instance', () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
|
||||
const conv = storeFor(b, 'conversation', sid('s1'))
|
||||
const details = storeFor(b, 'details', sid('s1'))
|
||||
@@ -108,11 +87,8 @@ describe('selection survives on the store seat', () => {
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('sessions are isolated: s2 selection never bleeds into s1', async () => {
|
||||
it('sessions are isolated: s2 selection never bleeds into s1', () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }, { id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
|
||||
const one = storeFor(b, 'conversation', sid('s1'))
|
||||
const two = storeFor(b, 'conversation', sid('s2'))
|
||||
@@ -123,25 +99,17 @@ describe('selection survives on the store seat', () => {
|
||||
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
|
||||
})
|
||||
|
||||
it('a display-title-upgrading list refresh keeps instance identity and the selection value', async () => {
|
||||
it('a list-projection update keeps instance identity and the selection value', () => {
|
||||
const b = bench()
|
||||
// First-send shape: client-side create inserts the row without cwd (title = bare id).
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
|
||||
const id = await b.sessions.create({})
|
||||
await flush()
|
||||
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' })
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
|
||||
const id = sid('s1')
|
||||
const projection = createSnapshotStore({ displayTitle: 's1' })
|
||||
|
||||
const store = storeFor(b, 'conversation', id)
|
||||
store.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
store.actions.setDraft('half-typed')
|
||||
|
||||
// The late list refresh lands (host knows the cwd → better fallback label).
|
||||
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' })
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
|
||||
projection.set({ displayTitle: 'proj-a' })
|
||||
expect(projection.getSnapshot().displayTitle).toBe('proj-a')
|
||||
|
||||
const after = storeFor(b, 'conversation', id)
|
||||
expect(after).toBe(store)
|
||||
@@ -149,32 +117,20 @@ describe('selection survives on the store seat', () => {
|
||||
expect(after.store.getSnapshot().draft).toBe('half-typed')
|
||||
})
|
||||
|
||||
it('session death buries the instance and its persisted draft', async () => {
|
||||
it('session death buries the instance and its persisted draft', () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }, { id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
|
||||
// Mint the scope (store prune rides the scope-teardown axis: no scope,
|
||||
// no teardown — the real page always resolves the binding to render).
|
||||
b.sessions.binding(sid('s1'))
|
||||
const doomed = storeFor(b, 'conversation', sid('s1'))
|
||||
doomed.actions.setDraft('to be buried')
|
||||
doomed.actions.select({ turnSeq: 1 })
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
|
||||
|
||||
// Watch elsewhere so s1's scope teardown is not deferred, then remove it.
|
||||
b.sessions.binding(sid('s2'))
|
||||
feed(b, [{ id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
// SessionsService calls this public slot lifecycle seam when the scope dies.
|
||||
b.slots.pruneStoreScope(sid('s1'))
|
||||
|
||||
// Persisted residue is gone with the session...
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
|
||||
// ...and a re-created same-id session starts from a FRESH instance.
|
||||
feed(b, [{ id: 's1' }, { id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
const reborn = storeFor(b, 'conversation', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', imageIds: [], view: null })
|
||||
|
||||
@@ -1,330 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ConversationService orchestration half after the store-seat slimming:
|
||||
* scope-addressed send/cancel (result folding, root throw), the startSession
|
||||
* chain (create → scoped send → sessions.open), and the service-unavailable
|
||||
* loud failures. Selection/draft state left this service for the declared
|
||||
* chat store (chat-store.spec.ts / selection-survival.spec.ts); the view
|
||||
* registry left for the 'conversation.view' slot (views-type-chain.spec.tsx).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
/** Recover the module-private scope tag through the public seam (same probe as apply-inject.spec). */
|
||||
const sid = (id: string) => id as SessionId
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const recorded: (string | symbol)[] = []
|
||||
const spy = new Proxy(new Context(), {
|
||||
get(target, prop, receiver): unknown {
|
||||
recorded.push(prop)
|
||||
return Reflect.get(target, prop, receiver)
|
||||
const reads: (string | symbol)[] = []
|
||||
const proxy = new Proxy(new Context(), {
|
||||
get(target, property, receiver): unknown {
|
||||
reads.push(property)
|
||||
return Reflect.get(target, property, receiver)
|
||||
},
|
||||
})
|
||||
void scopeOf(spy)
|
||||
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
|
||||
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
|
||||
return symbol
|
||||
void scopeOf(proxy)
|
||||
return reads.find((value): value is symbol => typeof value === 'symbol')!
|
||||
})()
|
||||
|
||||
interface SessionDouble {
|
||||
prompt: ReturnType<typeof vi.fn>
|
||||
cancel: ReturnType<typeof vi.fn>
|
||||
readAttachment: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function bench(opts?: {
|
||||
sessions?: boolean
|
||||
description?: ReturnType<SessionsService['hostDescription']>
|
||||
}) {
|
||||
async function bench(withSessions = true) {
|
||||
const ctx = new Context()
|
||||
const sessionDoubles = new Map<SessionId, SessionDouble>()
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
const mint = (id: SessionId): Context => {
|
||||
let scoped = scopes.get(id)
|
||||
if (scoped === undefined) {
|
||||
const fiber = ctx.plugin(() => {})
|
||||
scoped = fiber.ctx.extend({ [SCOPE_TAG]: id })
|
||||
scopes.set(id, scoped)
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
const createMock = vi.fn(() => Promise.resolve(sid('new-1')))
|
||||
const openMock = vi.fn()
|
||||
const sessionsFake = {
|
||||
manager: {
|
||||
get: (id: SessionId) => {
|
||||
let s = sessionDoubles.get(id)
|
||||
if (s === undefined) {
|
||||
s = {
|
||||
prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
readAttachment: vi.fn(() => Promise.reject(new Error('attachment response not configured'))),
|
||||
}
|
||||
sessionDoubles.set(id, s)
|
||||
}
|
||||
return s
|
||||
},
|
||||
},
|
||||
create: createMock,
|
||||
open: openMock,
|
||||
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
|
||||
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const loadOlder = vi.fn(() => Promise.resolve())
|
||||
const updatePendingPrompt = vi.fn()
|
||||
const retryPendingPrompt = vi.fn()
|
||||
const sessions = {
|
||||
binding: (sessionId: SessionId) => ({
|
||||
sessionId, session: { prompt, cancel, loadOlder, updatePendingPrompt, retryPendingPrompt },
|
||||
}),
|
||||
scopeOf,
|
||||
hostDescription: () => opts?.description,
|
||||
} as unknown as SessionsService
|
||||
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
|
||||
// Class-plugin mount — the same form apply.ts uses in production.
|
||||
const fiber = ctx.plugin(ConversationService)
|
||||
await fiber.await()
|
||||
const svc = ctx.get('conversation') as ConversationService
|
||||
const scopedSvc = (id: SessionId) => mint(id).get('conversation') as ConversationService
|
||||
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, openMock }
|
||||
if (withSessions) ctx.provide('sessions', sessions)
|
||||
await ctx.plugin(ConversationService).await()
|
||||
const root = ctx.get('conversation') as ConversationService
|
||||
const scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: sid('s1') }).get('conversation') as ConversationService
|
||||
return { root, scoped, prompt, cancel, loadOlder, updatePendingPrompt, retryPendingPrompt }
|
||||
}
|
||||
|
||||
describe('send / cancel', () => {
|
||||
it('sends one text block through the scoped session with the mode', async () => {
|
||||
describe('ConversationService', () => {
|
||||
it('routes ordinary and retained-prompt operations through the public Session binding', async () => {
|
||||
const b = await bench()
|
||||
await b.scopedSvc(sid('s1')).send('hello', 'steer')
|
||||
expect(b.sessionDoubles.get(sid('s1'))!.prompt).toHaveBeenCalledWith(
|
||||
[{ type: 'text', text: 'hello' }], 'steer')
|
||||
await b.scoped.send('hello', 'steer')
|
||||
await b.scoped.cancel()
|
||||
await b.scoped.loadOlder()
|
||||
b.scoped.updatePendingPrompt('revised')
|
||||
b.scoped.retryPendingPrompt()
|
||||
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
expect(b.loadOlder).toHaveBeenCalledOnce()
|
||||
expect(b.updatePendingPrompt).toHaveBeenCalledWith('revised')
|
||||
expect(b.retryPendingPrompt).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('folds business failure into a thrown error carrying code and message', async () => {
|
||||
it('folds Session business failures into callback rejections', async () => {
|
||||
const b = await bench()
|
||||
const s = b.scopedSvc(sid('s1'))
|
||||
// Materialize the double first (manager.get is the lazy mint point).
|
||||
b.sessionsFake.manager.get(sid('s1'))
|
||||
const double = b.sessionDoubles.get(sid('s1'))!
|
||||
double.prompt.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'busy' } })
|
||||
await expect(s.send('x', 'queue')).rejects.toThrow(/send failed: agent-busy: busy/)
|
||||
b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never)
|
||||
await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy')
|
||||
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
|
||||
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
|
||||
})
|
||||
|
||||
it('uploads temporary browser files as base64 image parts at the send boundary', async () => {
|
||||
it('fails loudly from the root scope or without SessionsService', async () => {
|
||||
const b = await bench()
|
||||
const file = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
|
||||
Object.defineProperty(file, 'arrayBuffer', {
|
||||
value: () => Promise.resolve(Uint8Array.of(1, 2, 3).buffer),
|
||||
})
|
||||
await b.scopedSvc(sid('s1')).send('describe', 'queue', [file])
|
||||
expect(b.sessionDoubles.get(sid('s1'))!.prompt).toHaveBeenCalledWith([
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AQID', name: 'pixel.png' },
|
||||
{ type: 'text', text: 'describe' },
|
||||
], 'queue')
|
||||
})
|
||||
|
||||
it('rejects unsupported browser media before prompting the session', async () => {
|
||||
const b = await bench()
|
||||
const file = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
|
||||
Object.defineProperty(file, 'arrayBuffer', {
|
||||
value: () => Promise.resolve(Uint8Array.of(1).buffer),
|
||||
})
|
||||
await expect(b.scopedSvc(sid('s1')).send('', 'queue', [file]))
|
||||
.rejects.toThrow(/不支持的图片格式/)
|
||||
expect(b.sessionDoubles.get(sid('s1'))?.prompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancel resolves on ok and throws the folded business error', async () => {
|
||||
const b = await bench()
|
||||
const s = b.scopedSvc(sid('s1'))
|
||||
await s.cancel()
|
||||
const double = b.sessionDoubles.get(sid('s1'))!
|
||||
expect(double.cancel).toHaveBeenCalledTimes(1)
|
||||
double.cancel.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'nope' } })
|
||||
await expect(s.cancel()).rejects.toThrow(/cancel failed: internal: nope/)
|
||||
})
|
||||
|
||||
it('root-context send and cancel throw the addressing hint', async () => {
|
||||
const b = await bench()
|
||||
await expect(b.svc.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
|
||||
await expect(b.svc.cancel()).rejects.toThrow(/requires a session scope/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('image admission and URL lifecycle', () => {
|
||||
const description: NonNullable<ReturnType<SessionsService['hostDescription']>> = {
|
||||
version: '0',
|
||||
cwd: '/f',
|
||||
attachedSessions: 0,
|
||||
activeModel: {
|
||||
provider: 'anthropic',
|
||||
id: 'claude-opus-4-8',
|
||||
name: 'Opus',
|
||||
inputModalities: ['text', 'image'],
|
||||
outputModalities: ['text'],
|
||||
},
|
||||
imageLimits: {
|
||||
maxImageBytes: 3,
|
||||
maxImagesPerMessage: 2,
|
||||
maxMessageImageBytes: 4,
|
||||
maxImagePixels: 100,
|
||||
mediaTypes: ['image/png'],
|
||||
},
|
||||
}
|
||||
|
||||
it('preflights host limits before allocating previews and releases draft URLs', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:draft')
|
||||
const revokeObjectURL = vi.fn()
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||
const b = await bench({ description })
|
||||
const first = new File([Uint8Array.of(1, 2, 3)], 'first.png', { type: 'image/png' })
|
||||
const second = new File([Uint8Array.of(4, 5)], 'second.png', { type: 'image/png' })
|
||||
|
||||
const attachments = b.svc.createDraftImages([first])
|
||||
expect(attachments[0]).toMatchObject({
|
||||
kind: 'image',
|
||||
file: first,
|
||||
previewUrl: 'blob:draft',
|
||||
})
|
||||
expect(() => b.svc.createDraftImages([second], attachments)).toThrow(/总大小/)
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||
|
||||
b.svc.releaseDraftImages(attachments)
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:draft')
|
||||
})
|
||||
|
||||
it('rejects unsupported model capability, media type, count, and per-image bytes', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:unexpected')
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL: vi.fn() })
|
||||
const textOnly = await bench({
|
||||
description: {
|
||||
...description,
|
||||
activeModel: { ...description.activeModel!, inputModalities: ['text'] },
|
||||
},
|
||||
})
|
||||
const png = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
expect(() => textOnly.svc.createDraftImages([png], [], true))
|
||||
.toThrow(/当前模型不支持图片/)
|
||||
|
||||
const b = await bench({ description })
|
||||
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
|
||||
expect(() => b.svc.createDraftImages([video])).toThrow(/不支持的图片格式/)
|
||||
const large = new File([Uint8Array.of(1, 2, 3, 4)], 'large.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
expect(() => b.svc.createDraftImages([large])).toThrow(/单张大小限制/)
|
||||
const existing = b.svc.createDraftImages([png, png])
|
||||
expect(() => b.svc.createDraftImages([png], existing)).toThrow(/最多添加 2 张/)
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('deduplicates historical loads and revokes their URLs when the session scope ends', async () => {
|
||||
const createObjectURL = vi.fn()
|
||||
.mockReturnValueOnce('blob:history-1')
|
||||
.mockReturnValueOnce('blob:history-2')
|
||||
const revokeObjectURL = vi.fn()
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||
const b = await bench()
|
||||
const ref: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
b.sessionsFake.manager.get(sid('s1'))
|
||||
const session = b.sessionDoubles.get(sid('s1'))!
|
||||
session.readAttachment.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { attachment: ref, data: [1] },
|
||||
})
|
||||
|
||||
await expect(Promise.all([
|
||||
b.svc.resolveImage(sid('s1'), ref),
|
||||
b.svc.resolveImage(sid('s1'), ref),
|
||||
])).resolves.toEqual(['blob:history-1', 'blob:history-1'])
|
||||
expect(session.readAttachment).toHaveBeenCalledTimes(1)
|
||||
|
||||
b.svc.releaseSessionImages(sid('s1'))
|
||||
await vi.waitFor(() => {
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-1')
|
||||
})
|
||||
await expect(b.svc.resolveImage(sid('s1'), ref)).resolves.toBe('blob:history-2')
|
||||
expect(session.readAttachment).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('revokes a historical URL whose load completes after its session scope was released', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:late')
|
||||
const revokeObjectURL = vi.fn()
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||
const b = await bench()
|
||||
const ref: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
const response = Promise.withResolvers<{
|
||||
ok: true
|
||||
value: { attachment: ImageAttachmentRef; data: number[] }
|
||||
}>()
|
||||
b.sessionsFake.manager.get(sid('s1'))
|
||||
b.sessionDoubles.get(sid('s1'))!.readAttachment.mockReturnValue(response.promise)
|
||||
|
||||
const pending = b.svc.resolveImage(sid('s1'), ref)
|
||||
b.svc.releaseSessionImages(sid('s1'))
|
||||
response.resolve({ ok: true, value: { attachment: ref, data: [1] } })
|
||||
|
||||
await expect(pending).rejects.toThrow(/scope was released/)
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:late')
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSession chain', () => {
|
||||
it('creates, sends through the new scope, then navigates through sessions.open', async () => {
|
||||
const b = await bench()
|
||||
await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' })
|
||||
expect(b.createMock).toHaveBeenCalledWith({ cwd: '/proj' })
|
||||
expect(b.openMock).toHaveBeenCalledWith(sid('new-1'))
|
||||
const prompt = b.sessionDoubles.get(sid('new-1'))!.prompt
|
||||
expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: 'first' }], 'queue')
|
||||
// Navigation is the publication point: it must not precede send acceptance.
|
||||
expect(b.openMock.mock.invocationCallOrder[0]!).toBeGreaterThan(prompt.mock.invocationCallOrder[0]!)
|
||||
})
|
||||
|
||||
it('does not navigate when the first send is rejected (empty state keeps the draft)', async () => {
|
||||
const b = await bench()
|
||||
const doomed = b.sessionsFake.manager.get(sid('new-1')) as unknown as SessionDouble
|
||||
doomed.prompt.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'nope' } })
|
||||
await expect(b.svc.startSession({ text: 'first', mode: 'queue' })).rejects.toThrow(/agent-busy/)
|
||||
expect(b.openMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('omits cwd from create when not chosen', async () => {
|
||||
const b = await bench()
|
||||
await b.svc.startSession({ text: 't', mode: 'steer' })
|
||||
expect(b.createMock).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it('fails loud when the created session resolves no scope', async () => {
|
||||
const b = await bench()
|
||||
;(b.sessionsFake.create as ReturnType<typeof vi.fn>).mockResolvedValue(sid('ghost'))
|
||||
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/resolved no scope/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('service-unavailable loud failures', () => {
|
||||
it('throws when sessions is missing', async () => {
|
||||
const b = await bench({ sessions: false })
|
||||
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/sessions service unavailable/)
|
||||
})
|
||||
|
||||
it('startSession fails loud when the new scope cannot resolve conversation', async () => {
|
||||
const b = await bench()
|
||||
// A scope minted outside the service tree: scoped.get('conversation') finds nothing.
|
||||
const foreign = new Context()
|
||||
const foreignScope = foreign.plugin(() => {}).ctx.extend({})
|
||||
;(b.sessionsFake.scope as unknown) = () => foreignScope
|
||||
await expect(b.svc.startSession({ text: 't', mode: 'queue' }))
|
||||
.rejects.toThrow(/conversation service unavailable through the new scope/)
|
||||
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
|
||||
const missing = await bench(false)
|
||||
await expect(missing.root.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
// Skeleton branch tails for the coverage gate (complements skeleton.spec.tsx
|
||||
// acceptance flows), four-share props form: breadcrumb ancestry derivation +
|
||||
// error strip in ConversationRoot, DetailsPanel non-JSON args / non-text
|
||||
// result blocks / error-only results over the shared store, EmptyState
|
||||
// failure surface and path-modal confirm with in-component cwd derivation.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { hookOf } from './hook.ts'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ConversationRoot, type ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
/** Fallback-only chain stub (no takeover registered in these benches). */
|
||||
const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
|
||||
(_key, _owner, opts) => opts?.fallback ?? null
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
function sessionSource(over?: Partial<ConversationSnapshot>) {
|
||||
const snap = { ...snapshotBase(), ...over }
|
||||
return {
|
||||
getSnapshot: () => snap,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Sessions-list stub over a snapshot store (the standard useSessions hook shape). */
|
||||
function listHook(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
|
||||
const store = createSnapshotStore<SessionListState>({
|
||||
ids: rows.map(r => r.id as SessionId),
|
||||
byId: Object.fromEntries(rows.map(r => [r.id, {
|
||||
id: r.id as SessionId, title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}),
|
||||
}])),
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
return hookOf(store)
|
||||
}
|
||||
|
||||
describe('ConversationRoot branches', () => {
|
||||
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
|
||||
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
|
||||
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
|
||||
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
|
||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
|
||||
function rootProps(over?: {
|
||||
rows?: { id: string; title: string; parentId?: string }[]
|
||||
snapshot?: Partial<ConversationSnapshot>
|
||||
}) {
|
||||
const open = vi.fn()
|
||||
const chat = createChatStore().create()
|
||||
const view = render(
|
||||
<ConversationRoot
|
||||
sessionId={SID}
|
||||
useSession={hookOf(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={listHook(over?.rows ?? [])}
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={stubRenderSlot}
|
||||
renderSlotChain={fallbackRenderSlotChain}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
|
||||
addImages={vi.fn(() => null)}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
releaseSessionImages={vi.fn()}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
open={open}
|
||||
/>,
|
||||
)
|
||||
return { view, open, chat }
|
||||
}
|
||||
|
||||
it('derives the ancestry breadcrumb from the sessions list and navigates on ancestor click', () => {
|
||||
const { view, open } = rootProps({
|
||||
rows: [{ id: 'root-1', title: 'Workspace' }, { id: 's1', title: 'Current', parentId: 'root-1' }],
|
||||
})
|
||||
expect(view.getByText('Workspace')).toBeTruthy()
|
||||
expect(view.getByText('/')).toBeTruthy()
|
||||
fireEvent.click(view.getByText('Workspace'))
|
||||
expect(open).toHaveBeenCalledWith('root-1' as SessionId)
|
||||
// The last crumb is the current session: disabled, no navigation.
|
||||
fireEvent.click(view.getByText('Current'))
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a broken parent link stops the ancestry walk at the known chain', () => {
|
||||
const { view } = rootProps({
|
||||
rows: [{ id: 's1', title: 'Orphan', parentId: 'vanished' }],
|
||||
})
|
||||
// The walk keeps s1 itself and stops where the parent is unknown.
|
||||
expect(view.getByText('Orphan')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the raw session id without ancestry and counts user turns', () => {
|
||||
const { view } = rootProps({
|
||||
snapshot: { nodes: [{ kind: 'user', seq: 1 } as never, { kind: 'assistant', seq: 2 } as never] },
|
||||
})
|
||||
expect(view.getByText(SID)).toBeTruthy()
|
||||
expect(view.getByText(/1 turns/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('surfaces promptError through the composer error strip', () => {
|
||||
const { view } = rootProps({
|
||||
snapshot: { promptError: { op: 'stop', error: { message: 'halt', code: 'internal' } } as never },
|
||||
})
|
||||
expect(view.getByText(/停止失败:halt(internal)/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('an unknown stored view id falls back to the first registered view', () => {
|
||||
const { chat } = rootProps({})
|
||||
cleanup()
|
||||
chat.actions.setView('gone')
|
||||
const view = render(
|
||||
<ConversationRoot
|
||||
sessionId={SID}
|
||||
useSession={hookOf(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={listHook([])}
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={stubRenderSlot}
|
||||
renderSlotChain={fallbackRenderSlotChain}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
|
||||
addImages={vi.fn(() => null)}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
releaseSessionImages={vi.fn()}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
open={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByTestId('view-body')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel branches', () => {
|
||||
function panel(selection: SelectionTarget | null, snapshot?: Partial<ConversationSnapshot>) {
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
return render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={hookOf(sessionSource(snapshot)) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={listHook([])}
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
it('shows non-JSON args verbatim (streaming fragment path)', () => {
|
||||
const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, {
|
||||
runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, time: 1_000, callView: null }],
|
||||
})
|
||||
expect(view.getByText('{"cmd": tru')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a selection without callId renders the empty hint (selector null arm)', () => {
|
||||
const view = panel({ turnSeq: 2 })
|
||||
expect(view.getByText(/点击消息流中的工具行查看详情/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('snapshot updates re-run the material selector through the shallow equality arm', () => {
|
||||
let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, time: 1_000, callView: null }] } as ConversationSnapshot
|
||||
const subs = new Set<() => void>()
|
||||
const source = {
|
||||
getSnapshot: () => snap,
|
||||
subscribe: (fn: () => void) => {
|
||||
subs.add(fn)
|
||||
return () => subs.delete(fn)
|
||||
},
|
||||
}
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 1, callId: 'c9' })
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={listHook([])}
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
||||
// Top-level swap with identical material members: the eq arm short-circuits.
|
||||
snap = { ...snap }
|
||||
for (const fn of [...subs]) fn()
|
||||
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('windowless call material: no name/args fallback to callId, mixed node walk skips non-matches', () => {
|
||||
// A tool-result whose call head fell outside the window (call === null),
|
||||
// preceded by non-matching nodes so the walk exercises both filter arms.
|
||||
const view = panel({ turnSeq: 1, callId: 'c8' }, {
|
||||
nodes: [
|
||||
{ kind: 'user', seq: 1, content: [], source: null } as never,
|
||||
{ kind: 'tool-result', seq: 2, callId: 'other', call: { name: 'x', argsRaw: '{}' }, content: [], isError: false, callView: null, resultView: null } as never,
|
||||
{ kind: 'tool-result', seq: 3, callId: 'c8', call: null, content: [], isError: false, callView: null, resultView: null } as never,
|
||||
],
|
||||
})
|
||||
expect(view.getByText('c8')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('stringifies non-text result blocks and renders error-only results', () => {
|
||||
const withBlocks = panel({ turnSeq: 1, callId: 'c2' }, {
|
||||
nodes: [{
|
||||
kind: 'tool-result', seq: 3, callId: 'c2', call: { name: 'read', argsRaw: '{}' },
|
||||
content: [{ type: 'image', data: 'x' } as never],
|
||||
isError: false, callView: null, resultView: null,
|
||||
} as never],
|
||||
})
|
||||
expect(withBlocks.getByText(/"type": "image"/)).toBeTruthy()
|
||||
const errorOnly = panel({ turnSeq: 1, callId: 'c3' }, {
|
||||
nodes: [{
|
||||
kind: 'tool-result', seq: 4, callId: 'c3', call: { name: 'bash', argsRaw: '{}' },
|
||||
content: [], isError: true, error: { name: 'ToolError', code: 'timeout' },
|
||||
callView: null, resultView: null,
|
||||
} as never],
|
||||
})
|
||||
expect(errorOnly.getByText(/ToolError: timeout/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('EmptyState branches', () => {
|
||||
const noopCreate = () => Promise.resolve()
|
||||
|
||||
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
|
||||
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
|
||||
const view = render(
|
||||
<EmptyState
|
||||
useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={startSession}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.change(textarea, { target: { value: 'first task' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
await waitFor(() => expect(view.getByText(/发送失败:create down/)).toBeTruthy())
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe('first task')
|
||||
})
|
||||
|
||||
it('non-Error rejection reasons stringify into the error strip', async () => {
|
||||
const startSession = vi.fn(() => Promise.reject('plain-string'))
|
||||
const view = render(
|
||||
<EmptyState
|
||||
useSessions={listHook([])}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={startSession}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.change(textarea, { target: { value: 'go' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy())
|
||||
})
|
||||
|
||||
it('cwd derivation skips blank cwds; menu picks, path modal confirms, submits the typed path', async () => {
|
||||
const startSession = vi.fn(() => Promise.resolve())
|
||||
const view = render(
|
||||
<EmptyState
|
||||
useSessions={listHook([
|
||||
{ id: 'a', title: 'a', cwd: '/proj' },
|
||||
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
|
||||
])}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={startSession}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
|
||||
expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
|
||||
.toEqual(['proj', 'New Workspace'])
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'proj' }))
|
||||
expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj')
|
||||
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
|
||||
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' }))
|
||||
const custom = view.getByLabelText('Folder path')
|
||||
fireEvent.change(custom, { target: { value: '/typed/dir' } })
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open Folder' }))
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.change(textarea, { target: { value: 'task' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' }))
|
||||
})
|
||||
|
||||
it('Create modal surfaces inject failures inline', async () => {
|
||||
const createWorkspaceSession = vi.fn(() => Promise.reject(new Error('mkdir blocked')))
|
||||
const view = render(
|
||||
<EmptyState
|
||||
useSessions={listHook([])}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={() => Promise.resolve()}
|
||||
createWorkspaceSession={createWorkspaceSession}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
|
||||
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Create new' }))
|
||||
fireEvent.click(view.getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(view.getByRole('alert').textContent).toContain('mkdir blocked'))
|
||||
})
|
||||
})
|
||||
@@ -1,398 +1,212 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Skeleton acceptance over the four-share props form: empty-state transition
|
||||
* (same InputBar component in hero position, startSession submit, in-component
|
||||
* cwd derivation), ConversationRoot view switching through the store's view
|
||||
* field, DetailsPanel selection through the shared store. Components stay
|
||||
* pure — the framework shares are stubbed (useSession/useSessions), the store
|
||||
* share is a REAL createChatStore().create() instance (same construction path
|
||||
* as production), injected callbacks are spies.
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { EmptyStateProps } from '../src/client/skeleton/EmptyState.tsx'
|
||||
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
// jsdom normally provides localStorage; some host Node builds surface it as undefined.
|
||||
globalThis.localStorage?.clear()
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const SID = sid('s1')
|
||||
|
||||
function workspace(id = 'w1'): WorkspaceView {
|
||||
return {
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title: id, sessionIds: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
type SessionIntent = NonNullable<SessionListState['intent']>
|
||||
type WorkspaceIntent = NonNullable<WorkspaceListState['intent']>
|
||||
|
||||
const workspaceState = (
|
||||
items: readonly WorkspaceView[], workspaceIntent?: WorkspaceIntent,
|
||||
): WorkspaceListState => ({
|
||||
items, intent: workspaceIntent, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
|
||||
/** Minimal conversation snapshot slice the skeleton reads. */
|
||||
interface FakeSnapshot {
|
||||
nodes: readonly {
|
||||
kind: string
|
||||
seq?: number
|
||||
time?: number
|
||||
callId?: string
|
||||
call?: { name: string; argsRaw: string } | null
|
||||
callTime?: number | null
|
||||
content?: readonly { type: string; text?: string }[]
|
||||
isError?: boolean
|
||||
callView?: null
|
||||
resultView?: null
|
||||
}[]
|
||||
runningCalls: readonly {
|
||||
callId: string
|
||||
name: string
|
||||
argsRaw: string
|
||||
turn?: number
|
||||
step?: number
|
||||
time?: number
|
||||
callView?: null
|
||||
}[]
|
||||
running: boolean
|
||||
removed: boolean
|
||||
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
|
||||
pending: readonly PendingInteraction[]
|
||||
function mountEmpty(
|
||||
intent: SessionIntent,
|
||||
items: readonly WorkspaceView[] = [],
|
||||
localWorkspace?: WorkspaceIntent,
|
||||
) {
|
||||
const updateSessionPrompt = vi.fn()
|
||||
const sendSession = vi.fn(() => Promise.resolve())
|
||||
const startSession = vi.fn()
|
||||
let pickerOwner: unknown
|
||||
const sessionState: SessionListState = {
|
||||
ids: [], byId: {}, current: intent.sessionId, intent, phase: 'ready',
|
||||
}
|
||||
const workspaceIntent = intent.target.kind === 'workspace-intent'
|
||||
? localWorkspace ?? { name: 'workspace', phase: 'ready' as const }
|
||||
: undefined
|
||||
const view = render(
|
||||
<EmptyState
|
||||
useSessions={hook(sessionState)}
|
||||
useWorkspaces={hook(workspaceState(items, workspaceIntent))}
|
||||
updateSessionPrompt={updateSessionPrompt}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
sendSession={sendSession}
|
||||
startSession={startSession}
|
||||
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as EmptyStateProps['renderSlot']}
|
||||
/>,
|
||||
)
|
||||
return { view, updateSessionPrompt, sendSession, startSession, pickerOwner: () => pickerOwner }
|
||||
}
|
||||
|
||||
function fakeSession(init: Partial<FakeSnapshot> = {}) {
|
||||
const store = createSnapshotStore<FakeSnapshot>({
|
||||
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
|
||||
})
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
|
||||
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
|
||||
function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
|
||||
const store = createSnapshotStore<SessionListState>({
|
||||
ids: rows.map(r => sid(r.id)),
|
||||
byId: Object.fromEntries(rows.map(r => [r.id, {
|
||||
id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
|
||||
}])),
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
return { store, useSessions: bindSnapshotSelector(store) }
|
||||
}
|
||||
|
||||
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
|
||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
|
||||
|
||||
describe('EmptyState', () => {
|
||||
const noopCreate = () => Promise.resolve()
|
||||
/** Required draft-image lifecycle props for tests not exercising images. */
|
||||
const noopImages = {
|
||||
createDraftImages: () => [],
|
||||
releaseDraftImage: () => {},
|
||||
releaseDraftImages: () => {},
|
||||
it('reads the Workspace and Session intents from runtime projections', () => {
|
||||
const b = mountEmpty({
|
||||
sessionId: sid('local-1'), target: { kind: 'workspace-intent' },
|
||||
prompt: 'draft', phase: 'ready',
|
||||
})
|
||||
expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('workspace')
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Add attachment' }))
|
||||
expect((b.pickerOwner() as { open: boolean }).open).toBe(false)
|
||||
fireEvent.change(b.view.getByPlaceholderText('Describe what you want to build'), { target: { value: 'build it' } })
|
||||
expect(b.updateSessionPrompt).toHaveBeenCalledWith('build it')
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Send message' }))
|
||||
expect(b.sendSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses useWorkspaces for the selected label and preserves the prompt when retargeting', () => {
|
||||
const first = workspace('first')
|
||||
const b = mountEmpty({
|
||||
sessionId: sid('local-2'), target: { kind: 'workspace', workspaceId: first.workspaceId },
|
||||
prompt: 'keep me', phase: 'ready',
|
||||
}, [first])
|
||||
expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('first')
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
|
||||
owner.onPick(wid('second'))
|
||||
expect(b.startSession).toHaveBeenCalledWith(wid('second'), 'keep me')
|
||||
})
|
||||
|
||||
it('exposes materialization phase and failure text', () => {
|
||||
const creating = mountEmpty({
|
||||
sessionId: sid('local-3'), target: { kind: 'workspace-intent' },
|
||||
prompt: 'x', phase: 'ready',
|
||||
}, [], { name: 'workspace', phase: 'creating' })
|
||||
expect(creating.view.getByRole('status').textContent).toBe('Creating workspace…')
|
||||
cleanup()
|
||||
const workspaceFailed = mountEmpty({
|
||||
sessionId: sid('local-3'), target: { kind: 'workspace-intent' },
|
||||
prompt: 'x', phase: 'ready',
|
||||
}, [], { name: 'workspace', phase: 'ready', error: 'offline' })
|
||||
expect(workspaceFailed.view.getByRole('alert').textContent).toBe('Workspace creation failed: offline')
|
||||
cleanup()
|
||||
const failed = mountEmpty({
|
||||
sessionId: sid('local-3'), target: { kind: 'workspace', workspaceId: wid('w1') },
|
||||
prompt: 'x', phase: 'ready', error: { step: 'session', message: 'offline' },
|
||||
}, [workspace()])
|
||||
expect(failed.view.getByRole('alert').textContent).toBe('Session creation failed: offline')
|
||||
})
|
||||
})
|
||||
|
||||
function conversationSnapshot(
|
||||
composerPhase: ConversationSnapshot['composerPhase'],
|
||||
pendingPrompt: ConversationSnapshot['pendingPrompt'] = null,
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, composerPhase, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
|
||||
const { useSessions } = fakeSessions([
|
||||
{ id: 'a', title: 'a', cwd: '/w/app' },
|
||||
{ id: 'b', title: 'b', cwd: '/w/lib' },
|
||||
{ id: 'c', title: 'c', cwd: '/w/app' }, // duplicate cwd dedupes
|
||||
])
|
||||
let reject!: (e: Error) => void
|
||||
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
{...noopImages}
|
||||
startSession={startSession}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
function mountConversation(pendingPrompt: ConversationSnapshot['pendingPrompt'] = null) {
|
||||
const root = sid('root')
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [root, SID],
|
||||
byId: {
|
||||
[root]: { id: root, displayTitle: 'Root', running: false, updatedAt: 1 },
|
||||
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, updatedAt: 2 },
|
||||
},
|
||||
current: SID,
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState([{ ...workspace('one'), sessionIds: [SID] }]))
|
||||
const session = createSnapshotStore<ConversationSnapshot>(conversationSnapshot(
|
||||
pendingPrompt === null ? 'active' : 'blank', pendingPrompt,
|
||||
))
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.setDraft('ordinary draft')
|
||||
const send = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
const updateSessionPrompt = vi.fn()
|
||||
const retrySessionPrompt = vi.fn()
|
||||
const renderSlot = ((_key: string, _owner: object, opts?: { only?: string }) => (
|
||||
<div data-testid={`view-${opts?.only ?? 'all'}`} />
|
||||
)) as ConversationRootProps['renderSlot']
|
||||
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
|
||||
const SessionProvider: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
const props: ConversationRootProps = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useWorkspaces: bindSnapshotSelector(workspaces),
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
renderSlotChain,
|
||||
SessionProvider,
|
||||
views: { list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 },
|
||||
addImages: () => null,
|
||||
removeImage: () => {},
|
||||
draftImages: () => [],
|
||||
releaseSessionImages: () => {},
|
||||
send,
|
||||
stop,
|
||||
open,
|
||||
updateSessionPrompt,
|
||||
retrySessionPrompt,
|
||||
}
|
||||
const view = render(<ConversationRoot {...props} />)
|
||||
return { view, chat, send, open, updateSessionPrompt, retrySessionPrompt }
|
||||
}
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '项目目录' })
|
||||
fireEvent.click(trigger)
|
||||
const menu = screen.getByRole('menu')
|
||||
expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
|
||||
.toEqual(['app', 'lib', 'New Workspace'])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'app' }))
|
||||
const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands')
|
||||
fireEvent.change(box, { target: { value: '造一个轮子' } })
|
||||
describe('ConversationRoot draft ownership', () => {
|
||||
it('keeps ordinary per-Session composer text in the chat store and selects through runtime actions', () => {
|
||||
const b = mountConversation()
|
||||
const box = b.view.getByRole('textbox')
|
||||
expect((box as HTMLTextAreaElement).value).toBe('ordinary draft')
|
||||
fireEvent.change(box, { target: { value: 'ordinary revised' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' })
|
||||
|
||||
reject(new Error('后端拒收'))
|
||||
expect(await screen.findByText(/后端拒收/)).toBeTruthy()
|
||||
// Draft survives the failure for retry.
|
||||
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
|
||||
expect(b.send).toHaveBeenCalledWith('ordinary revised', [], 'queue')
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
|
||||
expect(b.open).toHaveBeenCalledWith(sid('root'))
|
||||
})
|
||||
|
||||
it('Use a existing folder opens the path modal and Open Folder sets the chip', () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
{...noopImages}
|
||||
startSession={() => Promise.resolve()}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
|
||||
const newWs = screen.getByRole('menuitem', { name: 'New Workspace' })
|
||||
fireEvent.mouseEnter(newWs.parentElement as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy()
|
||||
const path = screen.getByLabelText('Folder path') as HTMLInputElement
|
||||
fireEvent.change(path, { target: { value: '/tmp/fresh' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Folder' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh')
|
||||
})
|
||||
|
||||
it('Create new opens the modal and createWorkspaceSession succeeds', async () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
const createWorkspaceSession = vi.fn(() => Promise.resolve())
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
{...noopImages}
|
||||
startSession={() => Promise.resolve()}
|
||||
createWorkspaceSession={createWorkspaceSession}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
|
||||
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy()
|
||||
const name = screen.getByLabelText('Workspace name') as HTMLInputElement
|
||||
expect(name.value).toBe('New WorkSpace')
|
||||
fireEvent.change(name, { target: { value: 'My Proj' } })
|
||||
fireEvent.keyDown(name, { key: 'Enter' })
|
||||
await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj'))
|
||||
})
|
||||
|
||||
it('Create modal Cancel dismisses without calling createWorkspaceSession', () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
const createWorkspaceSession = vi.fn(() => Promise.resolve())
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
{...noopImages}
|
||||
startSession={() => Promise.resolve()}
|
||||
createWorkspaceSession={createWorkspaceSession}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
|
||||
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(createWorkspaceSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes empty-state draft image creation and release through the injected lifecycle', () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = {
|
||||
kind: 'image' as const,
|
||||
id: 'draft-1',
|
||||
file,
|
||||
previewUrl: 'blob:draft-1',
|
||||
}
|
||||
const createDraftImages = vi.fn()
|
||||
.mockReturnValueOnce([attachment])
|
||||
.mockImplementationOnce(() => { throw new Error('图片过大') })
|
||||
const releaseDraftImage = vi.fn()
|
||||
const releaseDraftImages = vi.fn()
|
||||
const view = render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
createDraftImages={createDraftImages}
|
||||
releaseDraftImage={releaseDraftImage}
|
||||
releaseDraftImages={releaseDraftImages}
|
||||
startSession={() => Promise.resolve()}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const clipboardData = {
|
||||
items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }],
|
||||
getData: () => '',
|
||||
}
|
||||
fireEvent.paste(textarea, { clipboardData })
|
||||
expect(createDraftImages).toHaveBeenCalledWith([file], [])
|
||||
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
|
||||
expect(releaseDraftImage).toHaveBeenCalledWith('draft-1')
|
||||
|
||||
fireEvent.paste(textarea, { clipboardData })
|
||||
expect(view.getByText('图片过大')).toBeTruthy()
|
||||
view.unmount()
|
||||
expect(releaseDraftImages).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConversationRoot', () => {
|
||||
function bench(
|
||||
tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
|
||||
renderSlotChain?: ConversationRootProps['renderSlotChain'],
|
||||
) {
|
||||
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
|
||||
const { useSessions } = fakeSessions([
|
||||
{ id: 'root', title: 'proj' },
|
||||
{ id: 's1', title: 'child', parentId: 'root' },
|
||||
])
|
||||
const chat = createChatStore().create()
|
||||
if (activeView !== undefined) chat.actions.setView(activeView)
|
||||
const send = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
// The renderSlot share as the outlet would bake it: renders a marker for
|
||||
// the ring key carrying the active-id filter (a Mock cannot satisfy the
|
||||
// generic method type directly — cast once at the prop seam).
|
||||
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
|
||||
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
|
||||
))
|
||||
const ui = render(
|
||||
<ConversationRoot
|
||||
sessionId={sid('s1')}
|
||||
useSession={useSession}
|
||||
useSessions={useSessions}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
|
||||
renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{
|
||||
list: () => tabs,
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
addImages={vi.fn(() => null)}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
releaseSessionImages={vi.fn()}
|
||||
send={send}
|
||||
stop={stop}
|
||||
open={open}
|
||||
/>)
|
||||
return { ui, chat, send, stop, open, renderSlot }
|
||||
}
|
||||
|
||||
const tab = (id: string, label: string): ViewTab => ({ id, label })
|
||||
|
||||
it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => {
|
||||
const { open } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
|
||||
expect(screen.getByText('proj')).toBeTruthy()
|
||||
expect(screen.getByText('child')).toBeTruthy()
|
||||
expect(screen.getByText(/2 turns/)).toBeTruthy()
|
||||
expect(screen.getByTestId('view-chat')).toBeTruthy()
|
||||
// Ancestor crumb navigates; current crumb is disabled.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'proj' }))
|
||||
expect(open).toHaveBeenCalledWith('root')
|
||||
expect((screen.getByRole('button', { name: 'child' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('switches views through the store view field and falls back on unknown ids', () => {
|
||||
const { chat } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(chat.store.getSnapshot().view).toBe('trajectory')
|
||||
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
|
||||
cleanup()
|
||||
// A stale persisted id (its view plugin unloaded) falls to the first view.
|
||||
bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')], 'ghost-view')
|
||||
expect(screen.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the active view through the declared ring slot with the only filter', () => {
|
||||
const { renderSlot } = bench([tab('chat', 'Chat')])
|
||||
// No owner share: views take everything from the standard kit (contract).
|
||||
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
|
||||
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
|
||||
})
|
||||
|
||||
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
|
||||
const { chat, send } = bench([tab('chat', 'Chat')])
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
const box = screen.getByPlaceholderText(/输入消息/)
|
||||
fireEvent.change(box, { target: { value: 'hi' } })
|
||||
// Typing goes through actions.setDraft into the shared store.
|
||||
expect(chat.store.getSnapshot().draft).toBe('hi')
|
||||
it('reads a retained prompt from useSession and edits/retries it through the scoped Session', () => {
|
||||
const b = mountConversation({
|
||||
workspaceId: wid('one'), text: 'retry me', phase: 'failed',
|
||||
retry: 'send', error: 'offline',
|
||||
})
|
||||
const box = b.view.getByRole('textbox')
|
||||
expect((box as HTMLTextAreaElement).value).toBe('retry me')
|
||||
expect(b.view.getByRole('alert').textContent).toBe('Message send failed: offline')
|
||||
fireEvent.change(box, { target: { value: 'revised prompt' } })
|
||||
expect(b.updateSessionPrompt).toHaveBeenCalledWith('revised prompt')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('ordinary draft')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(send).toHaveBeenCalledWith('hi', [], 'queue')
|
||||
})
|
||||
|
||||
it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => {
|
||||
const wait = new PendingWait('question', RpcId('rq'), sid('s1'),
|
||||
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())
|
||||
// A matching entry takes the composer over.
|
||||
const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationRootProps['renderSlotChain']
|
||||
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
|
||||
expect(screen.getByText('question takeover')).toBeTruthy()
|
||||
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
|
||||
// The owner dispatches the raw pending list (chain currency); routing
|
||||
// lives in entry selectors, not here.
|
||||
expect(renderSlotChain).toHaveBeenCalledWith(
|
||||
'conversation.composer',
|
||||
expect.objectContaining({
|
||||
interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]),
|
||||
}),
|
||||
expect.objectContaining({ fallback: expect.anything() }),
|
||||
)
|
||||
cleanup()
|
||||
// Zero registered entries (default all-decline stub): the fallback IS the
|
||||
// default InputBar — behavior equals the pre-chain composer.
|
||||
bench([tab('chat', 'Chat')], undefined, { pending: [wait] })
|
||||
expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel', () => {
|
||||
function benchDetails(snapshot: Partial<FakeSnapshot>, selection: SelectionTarget | null) {
|
||||
const { useSession } = fakeSession(snapshot)
|
||||
const { useSessions } = fakeSessions([])
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const closeDetails = vi.fn()
|
||||
render(
|
||||
<DetailsPanel
|
||||
sessionId={sid('s1')}
|
||||
useSession={useSession}
|
||||
useSessions={useSessions}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={closeDetails}
|
||||
/>)
|
||||
return { closeDetails, chat }
|
||||
}
|
||||
|
||||
it('renders the selected call args and result off the shared store; close fires the injected callback', () => {
|
||||
const { closeDetails } = benchDetails({
|
||||
nodes: [{
|
||||
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"cmd":"ls"}' },
|
||||
callTime: 500,
|
||||
content: [{ type: 'text', text: 'file-a\nfile-b' }],
|
||||
isError: false, callView: null, resultView: null,
|
||||
}],
|
||||
}, { turnSeq: 1, callId: 'c1' })
|
||||
expect(screen.getByText('bash')).toBeTruthy()
|
||||
expect(screen.getByText(/"cmd": "ls"/)).toBeTruthy()
|
||||
expect(screen.getByText(/file-a/)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭详情' }))
|
||||
expect(closeDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the empty hint without a selection and the running state for open calls', () => {
|
||||
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null)
|
||||
expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy()
|
||||
cleanup()
|
||||
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' })
|
||||
expect(screen.getByText('运行中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports an out-of-window call distinctly', () => {
|
||||
benchDetails({}, { turnSeq: 1, callId: 'ghost' })
|
||||
expect(screen.getByText(/不在当前窗口内/)).toBeTruthy()
|
||||
expect(b.retrySessionPrompt).toHaveBeenCalledOnce()
|
||||
expect(b.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user