Merge remote-tracking branch 'origin/master' into codex/status-bar-token-metrics
# Conflicts: # apps/web/tests/snapshots/fresh-round-trip/ui.expected.md # docs/architecture.i18n.yaml # docs/event-producer-consumer.md # packages/client/connection/src/client/api.ts # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/manager.ts # packages/client/runtime/src/client/sessions/service.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/src/api-proxy.ts
This commit is contained in:
@@ -1,19 +1,22 @@
|
||||
/** 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'
|
||||
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionInjected, DetailsInjected,
|
||||
} from './contract/slots.ts'
|
||||
import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import type { IConversation } from './service.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
import { queueDockEntry } from './queue/QueueDock.tsx'
|
||||
@@ -24,8 +27,8 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
/** 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 {
|
||||
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
const scoped = sessions.scope(id)
|
||||
if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
|
||||
const conversation = scoped.get('conversation')
|
||||
@@ -33,6 +36,11 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
|
||||
return conversation
|
||||
}
|
||||
|
||||
/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */
|
||||
function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null {
|
||||
return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null
|
||||
}
|
||||
|
||||
/** Mounts the conversation plugin.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
@@ -129,8 +137,9 @@ export function apply(ctx: Context): void {
|
||||
// verbs ride this inject (package-internal — hub and bar are one plugin).
|
||||
slots.register({
|
||||
name: 'conversation.composer.bar',
|
||||
// The two named control seats in the bar's tool row (plan left, model
|
||||
// right); empty until their owning plugins register (B ruling).
|
||||
// The two named control seats in the bar's tool row (plan beside the
|
||||
// access control, model right); empty until their owning plugins
|
||||
// register (B ruling).
|
||||
children: {
|
||||
'conversation.input.plan': { kind: 'single', scope: 'session' },
|
||||
'conversation.input.model': { kind: 'single', scope: 'session' },
|
||||
@@ -144,11 +153,27 @@ export function apply(ctx: Context): void {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
command: async (line) => {
|
||||
const session = sessions.binding(sessionId)?.session
|
||||
if (session === undefined) return false
|
||||
const result = await session.command(line)
|
||||
return result.ok && result.value.matched
|
||||
},
|
||||
hooks: { notices: shell.notices, lexicon: shell.lexicon },
|
||||
}
|
||||
},
|
||||
}, InputBar)
|
||||
|
||||
// The approval takeover: a selector-routed entry of the chain this package
|
||||
// just declared (the ui-question registration pattern; the entry lives here
|
||||
// because approval answering is core conversation UX, not an optional tool).
|
||||
// Zero business face — data and verbs both ride the matched carrier.
|
||||
// priority 1: question takeovers (default 0) win when both kinds are
|
||||
// pending — a question is a conversation the model is waiting on, while an
|
||||
// approval only blocks one tool call; answering the question first cannot
|
||||
// strand the approval (it re-elects the moment the question resolves).
|
||||
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel)
|
||||
|
||||
// The chat view: first entry of the ring this package just declared.
|
||||
// Declaring the keyed toolview hole here is claiming it: ChatView is the
|
||||
// only component authorized to render per-tool rows. Shares the chat
|
||||
|
||||
@@ -30,7 +30,6 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem } from './MessageItem.tsx'
|
||||
import { PendingCard } from './PendingCard.tsx'
|
||||
import { StatsLine } from './StatsLine.tsx'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
@@ -229,7 +228,6 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
const running = useSession(s => s.running)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const pending = useSession(s => s.pending)
|
||||
const openState = useSession(s => s.openState)
|
||||
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
@@ -375,7 +373,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map(item => <PendingCard key={item.key} item={item} />)}
|
||||
{/* No pending placeholders: questions (ui-question) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
{/* Turn-level loading signal: rides the whole running turn (first-token
|
||||
wait, tool execution, streaming) so it never flickers per step. */}
|
||||
{running && <TurnDots />}
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import { IconSparkle16 } from './IconSparkle16.tsx'
|
||||
|
||||
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
|
||||
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Local sparkle icon for the Others tool-row variant (figma 43:31850 leading
|
||||
// glyph is an SF Symbols "sparkles" text glyph — not extractable as vector
|
||||
// data, so this is a hand-authored three-star approximation). Lives here
|
||||
// rather than ui-primitives until the exact glyph is exported and adopted
|
||||
// into the ic_ds_* family.
|
||||
|
||||
export function IconSparkle16({ size = 16, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.1 3.1Q6.6 7.8 11.3 8.3Q6.6 8.8 6.1 13.5Q5.6 8.8 0.9 8.3Q5.6 7.8 6.1 3.1Z" fill="currentColor" />
|
||||
<path d="M11.9 1Q12.2 3.7 14.9 4Q12.2 4.3 11.9 7Q11.6 4.3 8.9 4Q11.6 3.7 11.9 1Z" fill="currentColor" />
|
||||
<path d="M12.5 9.4Q12.7 11.4 14.7 11.6Q12.7 11.8 12.5 13.8Q12.3 11.8 10.3 11.6Q12.3 11.4 12.5 9.4Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/* Amber pending strip (approval waiting = warn semantic, figma state colors). */
|
||||
|
||||
.card {
|
||||
margin: 6px 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.reason {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// PendingCard: approval/question placeholder card (visible, not answerable —
|
||||
// the composer-takeover approval panel is a P-II item; wire pending semantics
|
||||
// already exist so the flow must show them).
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './PendingCard.module.css'
|
||||
|
||||
export interface PendingCardProps {
|
||||
item: PendingInteraction
|
||||
}
|
||||
|
||||
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
|
||||
return (
|
||||
<div className={css.card}>
|
||||
{item.kind === 'approval' ? (
|
||||
<>
|
||||
<div className={css.title}>等待审批:<span className={css.mono}>{item.payload.toolName}</span></div>
|
||||
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={css.title}>等待回答({item.payload.questions.length} 题)</div>
|
||||
<JsonBlock label="问题内容" payload={item.payload.questions} />
|
||||
</>
|
||||
)}
|
||||
<div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
@@ -83,9 +83,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
*/
|
||||
'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps }
|
||||
/**
|
||||
* The Plan-mode control seat in the composer tool row (left group).
|
||||
* Declared by the composer-bar entry; empty until a plan plugin
|
||||
* registers (B ruling: no placeholder fallback).
|
||||
* The Plan-mode status seat in the composer tool row (left group,
|
||||
* right of the access-mode control). Declared by the composer-bar
|
||||
* entry; empty until a plan plugin registers (B ruling: no placeholder
|
||||
* fallback).
|
||||
*/
|
||||
'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
|
||||
/**
|
||||
@@ -250,6 +251,12 @@ export interface ComposerBarInjected {
|
||||
keyboard: ComposerKeyboard
|
||||
/** Cancel the in-flight turn. */
|
||||
stop: () => void
|
||||
/**
|
||||
* Submit one slash-command line against this session's agent (the chrome
|
||||
* controls' write path — the permission chip submits `/permission <preset>`).
|
||||
* Resolves admission: false = rejected/unmatched/transport failure.
|
||||
*/
|
||||
command: (line: string) => Promise<boolean>
|
||||
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
|
||||
hooks: {
|
||||
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
|
||||
@@ -306,6 +313,68 @@ export type ConversationSessionSlotProps =
|
||||
& PropsStore<ChatStore>
|
||||
& ConversationSessionInjected
|
||||
|
||||
/** The pending approval carrier the owner dispatches into the composer chain. */
|
||||
export type ApprovalWait = PendingWait<'approval'>
|
||||
|
||||
/**
|
||||
* Approval domain face over the carrier (the ui-question PendingQuestion
|
||||
* pattern): render identity and question material forwarded transparently;
|
||||
* answer owns the wire encoding — the ApprovalResponsePayload value shape
|
||||
* with the audit correlation the host reconciles — and turns a rejected
|
||||
* carrier receipt into a thrown error. Minted per carrier via useMemo.
|
||||
*/
|
||||
export class PendingApproval {
|
||||
/**
|
||||
* @param wait - the runtime carrier for one pending approval question.
|
||||
*/
|
||||
constructor(private readonly wait: ApprovalWait) {}
|
||||
|
||||
/** Opaque render identity (React key / one-shot latch remount axis), forwarded from the carrier. */
|
||||
get key(): string {
|
||||
return this.wait.key
|
||||
}
|
||||
|
||||
/** The tool the question is about (headline fallback), forwarded from the carrier payload. */
|
||||
get toolName(): string {
|
||||
return this.wait.payload.toolName
|
||||
}
|
||||
|
||||
/** The asker's human-readable WHY (headline when present), forwarded from the carrier payload. */
|
||||
get reason(): string | undefined {
|
||||
return this.wait.payload.reason
|
||||
}
|
||||
|
||||
/** The paired tool call's id when the ask names one (command-line lookup key), forwarded from the carrier payload. */
|
||||
get callId(): string | undefined {
|
||||
return this.wait.payload.callId
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the user's decision; a rejected carrier receipt throws. Panel
|
||||
* removal stays frame-driven: the broadcast `approval/resolved` settles the
|
||||
* wait and drops it from the pending list.
|
||||
* @param outcome - the only two client-answerable outcomes.
|
||||
*/
|
||||
async answer(outcome: 'allowed-once' | 'rejected'): Promise<void> {
|
||||
const receipt = await this.wait.respond({
|
||||
ok: true,
|
||||
value: { sessionId: this.wait.sessionId, approvalId: this.wait.payload.approvalId, outcome },
|
||||
})
|
||||
if (!receipt.accepted) {
|
||||
throw new Error(`approval response rejected: ${receipt.reason}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full approval-composer props: the framework runtime share (chain currency +
|
||||
* session/global standard kit) plus the chain `matched` share — the entry's
|
||||
* selector result, already narrowed to the approval carrier. No injected
|
||||
* share: the carrier plus the domain face above carry the whole behavior
|
||||
* surface; the paired command line derives from useSession in-component.
|
||||
*/
|
||||
export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait }
|
||||
|
||||
/**
|
||||
* Injected share of the chat view entry: the two callbacks whose targets live
|
||||
* outside the view (layout orchestration; the session object layer).
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
* between the independently implemented skeleton and chat domains; `apply.ts`
|
||||
* owns their slot assembly.
|
||||
*/
|
||||
import type { ConversationService } from './service.ts'
|
||||
|
||||
export { apply, inject } from './apply.ts'
|
||||
export { ConversationService } from './service.ts'
|
||||
export type { IConversation } from './service.ts'
|
||||
|
||||
export type {
|
||||
CallId, ChatStoreState, SelectionTarget, ViewTab,
|
||||
@@ -22,6 +21,7 @@ export type {
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
conversation: ConversationService
|
||||
/** The outward face only; the concrete service stays inside this plugin. */
|
||||
conversation: import('./service.ts').IConversation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
* bail events) and owns the default-sink choreography: every session is a
|
||||
* real host entity, so the sink is one unconditional prompt path.
|
||||
*/
|
||||
import type { ClientContext, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { queueReadFaceOf } from '../queue/store.ts'
|
||||
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
|
||||
import type { PopupDismissFace } from './facade.ts'
|
||||
@@ -113,7 +112,7 @@ export class InputHub implements InputService {
|
||||
* exactly one path; a failed first prompt is an ordinary prompt failure
|
||||
* (error strip via promptError, draft restored only while untouched).
|
||||
*/
|
||||
private sink(session: Session, text: string, mode: 'queue' | 'steer'): void {
|
||||
private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
|
||||
if (text === '') return
|
||||
const shell = this.shells.get(session.sessionId)
|
||||
// Commit, not an editable clear: undo must not resurrect sent content.
|
||||
@@ -129,7 +128,7 @@ export class InputHub implements InputService {
|
||||
}
|
||||
|
||||
private controller(actx: ClientContext): SlashController | undefined {
|
||||
const slash = this.rootCtx.get('slash') as SlashServiceContract | undefined
|
||||
const slash = this.rootCtx.get('slash')
|
||||
return slash?.sessionOf(actx)
|
||||
}
|
||||
|
||||
@@ -138,7 +137,7 @@ export class InputHub implements InputService {
|
||||
return command?.popupFor(actx)
|
||||
}
|
||||
|
||||
private sessions(): SessionsService {
|
||||
private sessions(): ISessions {
|
||||
const sessions = this.rootCtx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable')
|
||||
return sessions
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* reference-stable across unrelated snapshot swaps, so this is a pure
|
||||
* projection — no second store, no copy.
|
||||
*/
|
||||
import type { ObservableSnapshot, Session } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { QueuedMessage } from '../input/contract.ts'
|
||||
|
||||
/**
|
||||
@@ -13,10 +13,10 @@ import type { QueuedMessage } from '../input/contract.ts'
|
||||
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
|
||||
* QueuedMessage and the input-contract QueuedMessage are structurally the
|
||||
* same frozen shape ({key, preview}).
|
||||
* @param session - the resident session instance.
|
||||
* @param session - the resident session face.
|
||||
* @returns the queue read face (snapshot reference stable while the queue is unchanged).
|
||||
*/
|
||||
export function queueReadFaceOf(session: Session): ObservableSnapshot<readonly QueuedMessage[]> {
|
||||
export function queueReadFaceOf(session: SessionFace): ObservableSnapshot<readonly QueuedMessage[]> {
|
||||
return {
|
||||
getSnapshot: () => session.getSnapshot().queue,
|
||||
subscribe: fn => session.subscribe(fn),
|
||||
|
||||
@@ -12,24 +12,50 @@ import type { Context } from '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.
|
||||
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InputService } from './input/contract.ts'
|
||||
|
||||
/**
|
||||
* The outward conversation face (`ctx.conversation`): the scope-addressed
|
||||
* verbs and the input registry other plugins may reach — and exactly what a
|
||||
* test fake must supply.
|
||||
*/
|
||||
export interface IConversation {
|
||||
/** The per-session input machine registry (InputService face). */
|
||||
readonly input: InputService
|
||||
/**
|
||||
* Send a prompt into the caller scope's session.
|
||||
* @param text - prompt text, sent verbatim as one text block.
|
||||
* @param mode - queue after the current turn, or steer into it.
|
||||
* @returns completion; business failures reject (and land in promptError).
|
||||
*/
|
||||
send(text: string, mode: 'queue' | 'steer'): Promise<void>
|
||||
/**
|
||||
* Cancel the scoped session's in-flight turn.
|
||||
* @returns completion; failures reject as in send.
|
||||
*/
|
||||
cancel(): Promise<void>
|
||||
/**
|
||||
* Pull one older history page for the scoped session.
|
||||
* @returns completion of the page pull.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
}
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service {
|
||||
export class ConversationService extends Service implements IConversation {
|
||||
/** The per-session input machine registry (InputService face, design §5.2). */
|
||||
readonly input: InputHub
|
||||
readonly input: InputService
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (the plugin apply context; the service
|
||||
* registers itself and follows that fiber's lifetime).
|
||||
* @param config - the shared InputHub constructed by the plugin apply
|
||||
* (shared with the slot inject factories); absent = own instance
|
||||
* (object-layer tests that never touch slots).
|
||||
* @param config - carries the InputService instance constructed by the
|
||||
* plugin apply (the same InputHub the slot inject factories close over).
|
||||
*/
|
||||
constructor(ctx: Context, config?: { input?: InputHub }) {
|
||||
constructor(ctx: Context, config: { input: InputService }) {
|
||||
super(ctx, 'conversation')
|
||||
this.input = config?.input ?? new InputHub(ctx)
|
||||
this.input = config.input
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,8 +83,8 @@ export class ConversationService extends Service {
|
||||
await this.scopedSession('loadOlder').loadOlder()
|
||||
}
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
private scopedSession(op: string): Session {
|
||||
/** Resolve the caller scope's session face or throw on root contexts. */
|
||||
private scopedSession(op: string): SessionFace {
|
||||
const id = this.scopeId(op)
|
||||
const binding = this.requireSessions().binding(id)
|
||||
if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`)
|
||||
@@ -74,10 +100,9 @@ export class ConversationService extends Service {
|
||||
return id
|
||||
}
|
||||
|
||||
private requireSessions(): SessionsService {
|
||||
// ctx.get instead of ctx.sessions: the typed Context merge is suspended
|
||||
// while the client/host `sessions` declaration collision awaits
|
||||
// arbitration (see the runtime package's Context merge note).
|
||||
private requireSessions(): ISessions {
|
||||
// Strict ctx.get, not the injection proxy: the scope-addressed pattern
|
||||
// reads the service off whatever context the tracker rebound.
|
||||
const sessions = this.ctx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
|
||||
return sessions
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/* Composer-takeover approval panel (draft approval.png): the same floating
|
||||
capsule footprint as the InputBar card, with an amber header band, the
|
||||
justification headline, a muted command line, and right-aligned actions.
|
||||
Warn semantics ride the alias state tokens; no hardcoded colors. */
|
||||
|
||||
/* Mirrors InputBar .root so the takeover is a content swap, not a layout jump. */
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 32px 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
}
|
||||
|
||||
/* Tinted full-width header band. */
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
|
||||
/* The model's justification is the panel's message, not a footnote. */
|
||||
.headline {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.command {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.allow,
|
||||
.reject {
|
||||
padding: 6px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.allow:disabled,
|
||||
.reject:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped
|
||||
always-allow button). */
|
||||
.allow {
|
||||
border: none;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
|
||||
/* Secondary: quiet outline. */
|
||||
.reject {
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.reject:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// ApprovalPanel: the composer-takeover approval prompt (designer draft
|
||||
// approval.png), registered as a selector-routed entry of the
|
||||
// conversation-declared composer chain. While an approval question is
|
||||
// pending, this panel occupies the composer slot in place of the InputBar:
|
||||
// an amber "Waiting for approval" strip on the card top, the model's
|
||||
// justification as the headline, the paired command in muted code text, and
|
||||
// a right-aligned refuse/allow action row. One-shot: the buttons disable
|
||||
// after a click and the panel leaves (the InputBar returns) on the broadcast
|
||||
// resolved frame. The draft's "Always allow this type" is deferred with
|
||||
// grant storage.
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
|
||||
import css from './ApprovalPanel.module.css'
|
||||
|
||||
/** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */
|
||||
export function commandOf(call: RunningToolCall | undefined): string | undefined {
|
||||
if (call === undefined) return undefined
|
||||
try {
|
||||
const args = JSON.parse(call.argsRaw) as Record<string, unknown>
|
||||
return typeof args.command === 'string' ? args.command : undefined
|
||||
} catch {
|
||||
// Unparseable model args: the panel still renders, just without the command line.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer takeover boundary: mints the domain face on the carrier's stable
|
||||
* identity and remounts the flow per request key, so the one-shot answered
|
||||
* latch never leaks to the next pending approval.
|
||||
* @param props - the selector-matched pending approval carrier plus the framework standard kit.
|
||||
* @returns The approval prompt for this request.
|
||||
*/
|
||||
export function ApprovalPanel(props: ApprovalComposerProps) {
|
||||
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
|
||||
const command = props.useSession(s => commandOf(
|
||||
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
|
||||
return <ApprovalFlow key={approval.key} pending={approval} {...command === undefined ? {} : { command }} />
|
||||
}
|
||||
|
||||
function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?: string }) {
|
||||
// Local one-shot latch: the panel leaves only when the resolved frame
|
||||
// lands; until then the buttons must not re-fire. An answer failure
|
||||
// (rejected receipt / transport) re-arms them for retry.
|
||||
const [answered, setAnswered] = useState(false)
|
||||
const answer = (outcome: 'allowed-once' | 'rejected'): void => {
|
||||
setAnswered(true)
|
||||
void pending.answer(outcome).catch(() => { setAnswered(false) })
|
||||
}
|
||||
return (
|
||||
<div className={css.root} data-approval-key={pending.key}>
|
||||
<div className={css.card}>
|
||||
<div className={css.strip}><span className={css.dot} />等待审批</div>
|
||||
<div className={css.body}>
|
||||
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
|
||||
{command !== undefined && <div className={css.command}>{command}</div>}
|
||||
<div className={css.actionRow}>
|
||||
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
允许一次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,12 +6,16 @@
|
||||
* region-slot content) ride the owner props. Session facts
|
||||
* (running/removed/promptError) are self-selected via useSession. */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: the `plan` projection key merge (the TodoDock posture — the
|
||||
// composer reads a host-computed value; the domain owns the key).
|
||||
import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import { deriveDecorations } from '../input/decorations.ts'
|
||||
import { PermissionSelect } from './PermissionSelect.tsx'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
/** Prompt failure surface (derived from promptError). */
|
||||
@@ -22,13 +26,8 @@ export interface InputBarError {
|
||||
|
||||
export type InputBarProps = ComposerBarProps
|
||||
|
||||
const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
|
||||
{ id: 'readonly', label: 'Read-only' },
|
||||
{ id: 'readwrite', label: 'Read-write' },
|
||||
]
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
|
||||
useSession, useInput, inputActions, keyboard, stop, command, renderSlot, useNotices, useLexicon, useProjection,
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
@@ -37,6 +36,9 @@ export function InputBar({
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const running = useSession(s => s.running)
|
||||
const disabled = useSession(s => s.removed)
|
||||
// Plan mode swaps the textarea placeholder (the projection is the folded
|
||||
// host value; owner-prop placeholders — hero, session-unavailable — win).
|
||||
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
|
||||
// Prompt failures are ordinary failures (no create/attach transaction
|
||||
// exists anymore): the strip renders promptError, the draft stays in the
|
||||
// machine, and the user resubmits.
|
||||
@@ -58,9 +60,9 @@ export function InputBar({
|
||||
}, 10)
|
||||
}
|
||||
|
||||
// Placeholder chrome: Access selection stays local until its seam lands
|
||||
// (plan/model are real seats now — the named single slots below).
|
||||
const [readonlyId, setReadonlyId] = useState('readonly')
|
||||
// The Access seat's data: the host-computed permissions projection
|
||||
// (undefined = capability absent → the chip renders nothing).
|
||||
const permissions = useProjection('permissions')
|
||||
|
||||
// Queue cut 1: running input stays free; locked = session disabled only.
|
||||
// The transient machine locks (adjudicating pending / submitting) render
|
||||
@@ -223,19 +225,10 @@ export function InputBar({
|
||||
if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
|
||||
}
|
||||
|
||||
// Access placeholder select (the one remaining local-chrome control).
|
||||
// The Access seat: the projection-fed permission chip (renders nothing
|
||||
// while the permissions key is absent — permission-less host or Draft).
|
||||
const accessSelect: ReactNode = (
|
||||
<select
|
||||
className={css.select}
|
||||
aria-label="Access mode"
|
||||
value={readonlyId}
|
||||
disabled={locked}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => { setReadonlyId(e.target.value) }}
|
||||
>
|
||||
{READONLY_OPTIONS.map(opt => (
|
||||
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<PermissionSelect value={permissions} locked={locked} command={command} />
|
||||
)
|
||||
|
||||
// Mirror-layer decorations: a visible backdrop with transparent text. The
|
||||
@@ -334,7 +327,9 @@ export function InputBar({
|
||||
disabled={locked}
|
||||
readOnly={machineBusy}
|
||||
data-phase={input.phase}
|
||||
placeholder={placeholder ?? (disabled ? 'Session unavailable' : 'Message the agent')}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? 'Session unavailable'
|
||||
: planActive ? 'describe your task to generate plan' : 'Message the agent')}
|
||||
rows={2}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -361,15 +356,15 @@ export function InputBar({
|
||||
<IconPlusOutline16 size={14} />
|
||||
</button>
|
||||
<div className={css.modes}>
|
||||
{renderSlot('conversation.input.plan', { locked })}
|
||||
{accessSelect}
|
||||
{renderSlot('conversation.input.plan', { locked })}
|
||||
</div>
|
||||
{leftItems}
|
||||
</div>
|
||||
<div className={css.trailing}>
|
||||
{rightItems}
|
||||
{renderSlot('conversation.input.model', { locked })}
|
||||
{machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />}
|
||||
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.primary}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Composer bottom-row permission chip (draft start.jpeg `Read-only ∨`): a
|
||||
quiet text chip with a chevron; hover paints the standard interactive pill.
|
||||
The native select is stretched invisibly over the chip so the platform
|
||||
dropdown does the menu work — keyboard/AT semantics come free. */
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
pointer-events: none; /* the overlaid select owns the interaction */
|
||||
}
|
||||
|
||||
.root:hover .chip {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Invisible native select stretched over the chip: real menu, zero drawing. */
|
||||
.select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.root:has(.select:disabled) .chip {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// PermissionSelect: the composer bottom-row permission chip (draft
|
||||
// start.jpeg's `Read-only ∨` control), the Access seat's wired occupant.
|
||||
// Options and the current value read from the host-computed `permissions`
|
||||
// projection (baseline block + push frames — no fetch, no mount timing);
|
||||
// key absence (a permission-less composition, or a Draft with no host
|
||||
// session yet) renders nothing. The visible chip is presentation only — an
|
||||
// invisible native select stretched over it owns the menu and interaction.
|
||||
// A switch submits the `/permission <preset>` command line (the one write
|
||||
// path); the control shows the picked value optimistically and disables
|
||||
// until the admission result, then re-follows the projection — the pushed
|
||||
// frame confirms the switch, and a failed/unmatched submit falls back to
|
||||
// the still-authoritative projection value (`custom` is shown as the
|
||||
// current value but never offered as a target — the host omits it from
|
||||
// switchable options).
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
|
||||
import css from './PermissionSelect.module.css'
|
||||
|
||||
/**
|
||||
* Display transform: kebab-case machine names render as title-case labels
|
||||
* (`workspace-write` → `Workspace Write`). Presentation-only — the wire
|
||||
* vocabulary and the host's advertised names are untouched; a host-configured
|
||||
* name that is not kebab-case (contains spaces or uppercase) passes through.
|
||||
*/
|
||||
function displayName(name: string): string {
|
||||
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
|
||||
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
export interface PermissionSelectProps {
|
||||
/** The host-computed select, or undefined while the capability is absent. */
|
||||
value: PermissionSelectValue | undefined
|
||||
/** Session-removed lock (the bar's chrome disable state). */
|
||||
locked: boolean
|
||||
/** Submit one slash-command line; resolves admission (false = rejected/unmatched). */
|
||||
command: (line: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function PermissionSelect({ value, locked, command }: PermissionSelectProps) {
|
||||
// Optimistic pick, shown while the admission round-trip runs; null follows
|
||||
// the projection (the pushed frame lands the confirmed value there).
|
||||
const [pick, setPick] = useState<string | null>(null)
|
||||
if (value === undefined) return null
|
||||
|
||||
const currentValue = pick ?? value.currentValue
|
||||
const current = value.options.find(option => option.value === currentValue)
|
||||
|
||||
const onChange = (next: string): void => {
|
||||
if (next === value.currentValue) return
|
||||
setPick(next)
|
||||
void command(`/permission ${next}`)
|
||||
.catch(() => false)
|
||||
.then(() => { setPick(null) })
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={css.root} title={current?.description}>
|
||||
<span className={css.chip}>
|
||||
{displayName(current?.name ?? currentValue)}
|
||||
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
</span>
|
||||
<select
|
||||
className={css.select}
|
||||
aria-label="Access mode"
|
||||
value={currentValue}
|
||||
disabled={locked || pick !== null}
|
||||
onChange={(e) => { onChange(e.target.value) }}
|
||||
>
|
||||
{value.options.map(option => (
|
||||
<option key={option.value} value={option.value} disabled={option.value === 'custom'}>
|
||||
{displayName(option.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user