Merge master into feature/workspace-picker-composer

This commit is contained in:
NI0317
2026-08-11 10:32:28 +08:00
2096 changed files with 28409 additions and 15503 deletions

View File

@@ -1,7 +1,9 @@
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import { resolveWorkspacePath, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
bindSettingsScope, resolveWorkspacePath, type ISessions, type SessionId,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -33,6 +35,7 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { en, NS, zh, type ConversationKey } from './locales.ts'
import { registerConversationNodes } from './conversation-nodes/register.ts'
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
@@ -43,7 +46,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Services required by the conversation plugin. */
export const inject = [
'slots', 'layout', 'sessions', 'workspaces', 'locale',
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection',
'conversationEvents', 'conversationViews',
]
@@ -124,7 +127,9 @@ export function apply(ctx: Context): void {
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
const submissionPolicy = new ComposerSubmissionPolicy()
const submissionPolicy = new ComposerSubmissionPolicy(
bindSettingsScope<ConversationSettings>(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }),
)
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { NS } from '../locales.ts'
import { AssistantNodeView } from './AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'

View File

@@ -1,10 +1,11 @@
/** Composer submission vocabulary shared by the input and settings domains. */
/** Delivery mode requested for one ordinary composer message. */
export type InputSubmitMode = 'queue' | 'steer'
import type { BusyEnterBehavior } from '../../submission-settings.ts'
/** Configurable meaning of plain Enter while the addressed agent is busy. */
export type BusyEnterBehavior = InputSubmitMode
export type { BusyEnterBehavior } from '../../submission-settings.ts'
/** Delivery mode requested for one ordinary composer message. */
export type InputSubmitMode = BusyEnterBehavior
/** Keyboard gesture whose delivery mode the submission policy resolves. */
export type ComposerSubmitGesture = 'enter' | 'accelerated'

View File

@@ -38,7 +38,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'conversation.session': { kind: 'single'; scope: 'session' }
/** Strict-session header above the resident conversation scrollport. */
'conversation.session.header': { kind: 'single'; scope: 'session' }
/** Session-header actions contributed by feature plugins. */
/**
* Session-header actions contributed by feature plugins. Entries render
* by ascending `order`; negative values are reserved for static session
* context that precedes interactive actions.
*/
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
* The conversation view ring: one list entry per view tab (chat here;
@@ -225,7 +229,7 @@ export interface ChatFileMentions {
forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
}
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
/** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */
chatFileMentions: ChatFileMentions

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch,
ConversationNodeContext, ConversationNodeDefinition,
@@ -242,6 +242,7 @@ function projectAssistant(context: ConversationNodeContext<AssistantState>): Ass
/** Per-step Assistant streaming/final/interruption Definition. */
export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
kind: 'assistant-step',
target: 'chat',
match: (event) => {
if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
if (event.type === 'assistant/chunk'
@@ -291,8 +292,7 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
value: projected.data,
}
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
buildViewNode: (context) => {
const projected = projectAssistant(context)
if (projected === undefined) return null
if (projected.settled === undefined && !projected.visible) {

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
ConversationLocation, ConversationNode, ConversationTimelineSnapshot,

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext,
ConversationNodeDefinition,
@@ -175,6 +175,7 @@ export function updateCompactionState<State extends CompactionEvidence>(
/** Slash-command lifecycle, including integrated manual compaction, Definition. */
export const commandDefinition: ConversationNodeDefinition<CommandState> = {
kind: 'command',
target: 'chat',
match: (event) => {
if (event.type === 'command/run') {
return { id: String(event.data.commandId), role: 'start' }
@@ -202,8 +203,7 @@ export const commandDefinition: ConversationNodeDefinition<CommandState> = {
}
return updateCompactionState(context.state, match)
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
buildViewNode: (context) => {
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
if (state.command.name !== 'compact') {

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -30,6 +30,7 @@ function fallbackState(context: ConversationNodeContext<CompactionState>): Compa
/** Automatic compaction lifecycle and landed checkpoint Definition. */
export const compactionDefinition: ConversationNodeDefinition<CompactionState> = {
kind: 'compaction',
target: 'chat',
match: (event) => {
const checkpoint = compactSource(event)
if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) {
@@ -47,8 +48,7 @@ export const compactionDefinition: ConversationNodeDefinition<CompactionState> =
},
start: () => ({}),
update: (context, match) => updateCompactionState(context.state, match),
buildViewNode: (context, target) => {
if (target !== 'chat') return null
buildViewNode: (context) => {
const state = context.state ?? fallbackState(context)
if (state.checkpoint === undefined) return null
const marker = compactSummary(state.summary, state.checkpoint)

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationNodeDefinition, UnknownSurfaceNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -15,6 +15,7 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
/** Unclaimed append-surface fallback Definition. */
export const unknownFallbackDefinition: ConversationNodeDefinition<UnknownSurfaceNode> = {
kind: 'unknown-surface',
target: 'chat',
match: event => isAppendSurfaceEvent(event)
? { id: String(event.seq), role: 'start' }
: null,
@@ -26,7 +27,7 @@ export const unknownFallbackDefinition: ConversationNodeDefinition<UnknownSurfac
data: match.event.data,
}),
update: context => context.state,
buildViewNode: (context, target) => target !== 'chat' || context.state === undefined
buildViewNode: context => context.state === undefined
? null
: chatNode(context, 'unknown', context.state.seq, context.state),
}

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationNodeDefinition, ConversationPreviousContext,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -50,7 +50,6 @@ function inboxDefinition(target: InboxTarget): ConversationNodeDefinition<InboxS
},
update: context => context.state,
publication: () => 'none',
buildViewNode: () => null,
}
}

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -30,6 +30,7 @@ function isCompactionCheckpoint(event: Parameters<ConversationNodeDefinition['ma
/** User, steering, and injected-context message classification Definition. */
export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
kind: 'input-message',
target: 'chat',
match: event => event.type === 'user/message'
&& isAppendSurfaceEvent(event)
&& !isCompactionCheckpoint(event)
@@ -68,8 +69,8 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
}
},
update: context => context.state,
buildViewNode: (context, target) => {
if (target !== 'chat' || context.state === undefined) return null
buildViewNode: (context) => {
if (context.state === undefined) return null
return chatNode(context, context.state.kind, context.state.seq, context.state)
},
}

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { registerAssistantConversationNode } from './assistant.ts'
import { registerChatConversationView } from './chat-snapshot-builder.ts'
import { registerCommandConversationNode } from './command.ts'

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationLocation, ConversationNodeDefinition, ModelRetryNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -40,6 +40,7 @@ function isClosed(location: ConversationLocation): boolean {
/** Producer-correlated model retry chain Definition. */
export const retryDefinition: ConversationNodeDefinition<RetryState> = {
kind: 'model-retry',
target: 'chat',
match: (event) => {
if (event.type === 'llm/retry') {
const retryId: unknown = event.data.retryId
@@ -70,8 +71,8 @@ export const retryDefinition: ConversationNodeDefinition<RetryState> = {
attempt.retry === retry ? { ...attempt, retryState: 'started' } : attempt),
}
},
buildViewNode: (context, target) => {
if (target !== 'chat' || context.state === undefined || context.state.attempts.length === 0) return null
buildViewNode: (context) => {
if (context.state === undefined || context.state.attempts.length === 0) return null
const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' as const }
const stateAttempts = context.state.attempts
const attempts = stateAttempts.map((attempt, index) =>

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
RunningToolCall, ToolCallBlock, ToolResultNode,
@@ -235,6 +235,7 @@ function fallbackState(context: ConversationNodeContext<ToolState>): ToolState |
/** Root Tool lifecycle and nested Code Dispatch Definition. */
export const toolDefinition: ConversationNodeDefinition<ToolState> = {
kind: 'tool-call',
target: 'chat',
match: (event) => {
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
@@ -257,8 +258,7 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
}
return updateDispatch(context.state, match)
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
buildViewNode: (context) => {
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
const projected = projectBlock(state.root, state, interruption(context))

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -63,6 +63,7 @@ function fallbackState(context: ConversationNodeContext<TurnErrorState>): TurnEr
/** Terminal turn failure Definition, suppressed when the turn owns a retry chain. */
export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
kind: 'turn-error',
target: 'chat',
match: (event) => {
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
if (event.type === 'turn/end' && event.data.reason.kind === 'error') {
@@ -82,8 +83,7 @@ export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
? { ...context.state, hidden: true }
: context.state
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
buildViewNode: (context) => {
const state = context.state ?? fallbackState(context)
if (state?.failure === undefined) return null
const failure = state.failure

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -151,6 +151,7 @@ function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChat
/** Completed-turn footer Definition independent of any Assistant row. */
export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
kind: 'turn-tail',
target: 'chat',
match: (event) => {
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
if (event.type === 'turn/end') return { id: String(event.data.turn), role: 'update' }
@@ -179,8 +180,7 @@ export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
value,
}
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
buildViewNode: (context) => {
const turn = turnLocation(context)
const data = turn?.data.get('turn-tail')
return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data)

View File

@@ -36,7 +36,7 @@ export type {
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
/** The outward face only; the concrete service stays inside this plugin. */
conversation: import('./service.ts').IConversation

View File

@@ -1,27 +1,42 @@
/**
* Browser-local Composer submission policy. It owns the persisted busy-Enter
* Composer submission policy. It owns the live busy-Enter
* preference and resolves keyboard gestures into queue/steer delivery modes;
* Host and Agent keep the actual delivery-window authority.
*/
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import {
createSnapshotStore, type SettingsScope, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode,
} from '../contract/composer-submission.ts'
import { BUSY_ENTER_FIELD, DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts'
import type { ConversationSettings } from '../../submission-settings.ts'
/** localStorage key holding the busy-Enter preference. */
export const BUSY_ENTER_STORAGE_KEY = 'dsh.conversation.busyEnter'
/** Default preserves Enter-as-Queue for running conversations. */
export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue'
export { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts'
/**
* Persisted policy used by both the composer inject face and its Settings row.
* Busy-Enter policy used by both the composer inject face and its Settings row.
* Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window
* submission into the next waking Queue item.
*/
export class ComposerSubmissionPolicy {
/** Reactive preference source for the Settings row. */
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = createSnapshotStore(restoreBusyEnter())
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = createSnapshotStore(DEFAULT_BUSY_ENTER_BEHAVIOR)
private readonly host: SettingsScope<ConversationSettings> | undefined
/**
* @param host - durable preference scope owned by the providing plugin;
* absent compositions stay process-local. The adoption subscription shares
* the scope's plugin lifetime — a disposed scope never publishes again, so
* the policy needs no release hook.
*/
constructor(host?: SettingsScope<ConversationSettings>) {
this.host = host
if (host !== undefined) {
host.subscribe(() => { this.adopt(host) })
this.adopt(host)
}
}
/**
* Resolve one keyboard gesture without changing state.
@@ -42,36 +57,23 @@ export class ComposerSubmissionPolicy {
}
/**
* Change and persist the plain-Enter behavior used during busy state.
* Change the plain-Enter behavior used during busy state; the live value
* publishes before the durable write starts.
* @param behavior - Queue or Steer.
*/
setBusyEnter(behavior: BusyEnterBehavior): void {
if (this.busyEnter.getSnapshot() === behavior) return
this.busyEnter.set(behavior)
persistBusyEnter(behavior)
void this.host?.set(BUSY_ENTER_FIELD, behavior)
}
}
/** Restore a valid preference; unavailable or corrupt storage uses Queue. */
function restoreBusyEnter(): BusyEnterBehavior {
if (typeof localStorage === 'undefined') return DEFAULT_BUSY_ENTER_BEHAVIOR
let stored: string | null
try {
stored = localStorage.getItem(BUSY_ENTER_STORAGE_KEY)
} catch {
// Storage access can fail in privacy modes; the default remains usable.
return DEFAULT_BUSY_ENTER_BEHAVIOR
}
if (stored === 'queue' || stored === 'steer') return stored
return DEFAULT_BUSY_ENTER_BEHAVIOR
}
/** Persist a preference when browser storage is available. */
function persistBusyEnter(behavior: BusyEnterBehavior): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, behavior)
} catch {
// A storage failure makes the preference session-only; input stays usable.
/**
* Adopt the scope's accepted durable behavior without writing it back.
* @param host - the constructor-narrowed scope driving this adoption.
*/
private adopt(host: SettingsScope<ConversationSettings>): void {
const section = host.getSnapshot().value
if (section === undefined || this.busyEnter.getSnapshot() === section.busyEnter) return
this.busyEnter.set(section.busyEnter)
}
}

View File

@@ -3,7 +3,7 @@
//
// The 'conversation.input.dock' SlotMap declaration lives in
// ../contract/slots.ts beside the other input-region slots.
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { useEffect, useId, useMemo, useState } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'

View File

@@ -7,8 +7,8 @@
* through one property read; assignment through the tracker proxy and `#`
* private fields bypass that rebinding.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
// Type-only imports: a plugin-to-plugin value import is a bundle purity
// error, so scope resolution goes through the sessions service (scopeOf
// method) instead of the standalone helper.

View File

@@ -6,7 +6,7 @@
// framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419.
import { useId, useState } from 'react'
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// The domain's client-namespace pure-type outlet: one import edge delivers
// the `todos` projection-key merge (single source, no consumer-side restated

View File

@@ -1,4 +1,23 @@
/** Host loader entry for the browser-only conversation plugin. */
/** Host registration for browser conversation preferences. */
/** Provides no host-side behavior. */
export function apply(): void {}
import type { Context } from '@deepseek-ai/cordis'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { CONVERSATION_SETTINGS_NAMESPACE, ConversationSettingsSchema } from './submission-settings.ts'
export {
BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE,
DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, type ConversationSettings,
} from './submission-settings.ts'
/**
* Register the durable conversation section when a settings provider exists.
* @param ctx - Host context whose optional settings service owns the section.
*/
export function apply(ctx: Context): void {
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.register(
settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE),
ConversationSettingsSchema,
)
})
}

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-conversation'

View File

@@ -0,0 +1,29 @@
/** Busy-Enter preference stored in the Host user-settings document. */
import z from '@deepseek-ai/schemastery'
/** Settings namespace owned by the conversation plugin. */
export const CONVERSATION_SETTINGS_NAMESPACE = 'ui-conversation'
/** Field carrying the delivery mode for plain Enter while an agent is busy. */
export const BUSY_ENTER_FIELD = 'busyEnter'
/** Busy-Enter behaviors accepted at settings and input boundaries. */
export const BUSY_ENTER_BEHAVIORS = ['queue', 'steer'] as const
/** Configurable meaning of plain Enter while the addressed agent is busy. */
export type BusyEnterBehavior = typeof BUSY_ENTER_BEHAVIORS[number]
/** Default preserves Enter-as-Queue for running conversations. */
export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue'
/** Durable conversation section shared by the Host schema and the browser scope. */
export interface ConversationSettings {
/** Delivery mode for plain Enter while the addressed agent is busy. */
busyEnter: BusyEnterBehavior
}
/** Durable conversation schema; also the wire envelope the browser scope validates against. */
export const ConversationSettingsSchema: z<ConversationSettings> = z.object({
[BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR),
})