Merge remote-tracking branch 'origin/master' into worktree/web-skill-tool-row

# Conflicts:
#	packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-06 20:41:13 +08:00
344 changed files with 7357 additions and 3313 deletions

View File

@@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ViewTab } from './contract/views.ts'
import type {
ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionInjected, DetailsInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import type { InputNotice } from './input/contract.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
@@ -33,7 +33,7 @@ import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { ConversationSession } from './skeleton/ConversationSession.tsx'
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { en, NS, zh, type ConversationKey } from './locales.ts'
@@ -123,6 +123,11 @@ export function apply(ctx: Context): void {
}
return tabs
}
const views = {
list: viewTabs,
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
}
// The per-session input machine registry (InputService face; published as
// ctx.conversation.input by the service below sharing this one instance).
@@ -151,6 +156,7 @@ export function apply(ctx: Context): void {
locale: NS,
children: {
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.session.header': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
'conversation.input.overlay': { kind: 'list', scope: 'session' },
@@ -176,27 +182,36 @@ export function apply(ctx: Context): void {
}),
}, ConversationRoot)
// The strict session subtree owns only per-session store and view content;
// the resident parent keeps Hero and composer layout identity stable.
// The strict session body fills the resident scrollport without owning it;
// the Hero/composer path therefore stays fixed while the first blank
// session appears after a Workspace pick.
slots.register({
name: 'conversation.session',
locale: NS,
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
views: {
list: viewTabs,
subscribe: fn => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
},
views,
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
open: (id) => { sessions.open(id) },
}),
}, ConversationSession)
// Header chrome sits above the resident scrollport but shares the same
// per-session chat store (active view) as its body and view entries.
slots.register({
name: 'conversation.session.header',
locale: NS,
children: {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (): ConversationSessionHeaderInjected => ({
views,
open: (id) => { sessions.open(id) },
}),
}, ConversationSessionHeader)
// The default composer body: its own single slot inside the composer
// chain's fallback (decision 20). Public machine surface arrives via the
// provide channel above; the keyboard command face and the stop/retry

View File

@@ -13,19 +13,20 @@ import type { CallId, SelectionTarget, ViewTab } from './views.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* Strict-session content inside the resident conversation shell. This
* subtree owns the per-session chat store, header, and view ring and is
* remounted when the current session id changes.
* Strict-session body inside the resident conversation scrollport. It
* owns the per-session draft mirror and active view ring.
*/
'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps }
'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. */
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
* The conversation view ring: one list entry per view tab (chat here;
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
* ConversationRoot via `only: <active id>`. Declared by this package's
* 'conversation' entry (declaring is claiming). Session scope: views read
* the conversation snapshot through the standard kit.
* the session body via `only: <active id>`. Declared by this package's
* body entry (declaring is claiming). Session scope: views read the
* conversation snapshot through the standard kit.
*/
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
/**
@@ -122,22 +123,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
}
/** Owner share of the strict session content seat. */
export interface ConversationSessionOwnerProps {
/**
* Wrap the view ring in the transcript scrollport that also hosts the
* sticky composer seat (whole `'conversation.composer'` chain output).
* Supplied for every real session (hero/settling/active) so the composer
* keeps one tree seat across the blank → active flip; the header stays
* outside that wrapper as ordinary column chrome (`flex: none`), while
* active CSS sticks the seat to the bottom of the same scrollport so wheel
* over the footer scrolls the flow.
* @param view - the session view-ring content (null while blank chrome is hidden).
* @returns the scrollport containing `view` and the sticky composer seat.
*/
wrapActiveBody?: (view: ReactNode) => ReactNode
}
/** Header actions derive their state from the standard session/global kit. */
export interface ConversationHeaderActionOwnerProps {}
@@ -228,7 +213,7 @@ export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'>
*/
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). */
/** The shared chat store handle type declared by the Session header/body, details, and chat-view registrations. */
export type ChatStore = ReturnType<typeof createChatStore>
/** Business callbacks injected into the conversation slot. */
@@ -240,7 +225,7 @@ export interface ConversationInjected {
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
}
/** Business callbacks injected into the strict session content seat. */
/** Business callbacks injected into the strict Session body seat. */
export interface ConversationSessionInjected {
/** Views projected from the `conversation.view` slot ledger. */
views: {
@@ -250,6 +235,16 @@ export interface ConversationSessionInjected {
}
/** Bind the input machine's draft persistence mirror to the session store. */
bindDraftMirror: (write: (text: string) => void) => () => void
}
/** Business callbacks injected into the strict session header seat. */
export interface ConversationSessionHeaderInjected {
/** Views projected from the `conversation.view` slot ledger. */
views: {
list: () => readonly ViewTab[]
subscribe: (fn: () => void) => () => void
version: () => number
}
/** Select a real Session through the runtime navigation owner. */
open: (sessionId: SessionId) => void
}
@@ -354,7 +349,8 @@ export interface ComposerChainProps {
*/
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<
| 'conversation.session' | 'conversation.composer' | 'conversation.composer.bar'
| 'conversation.session' | 'conversation.session.header'
| 'conversation.composer' | 'conversation.composer.bar'
| 'conversation.input.overlay'
| 'conversation.input.dock' | 'conversation.composer.dock'
| 'conversation.input.left' | 'conversation.input.right'
@@ -363,12 +359,19 @@ export type ConversationSlotProps =
& ConversationInjected
& PropsLocale<'conversation'>
/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */
/** Full strict-session body props: per-session store, view ring, and draft mirror. */
export type ConversationSessionSlotProps =
PropsRuntime<'conversation.session'>
& PropsRenderSlots<'conversation.view' | 'conversation.session.header.actions'>
& PropsRenderSlots<'conversation.view'>
& PropsStore<ChatStore>
& ConversationSessionInjected
/** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */
export type ConversationSessionHeaderSlotProps =
PropsRuntime<'conversation.session.header'>
& PropsRenderSlots<'conversation.session.header.actions'>
& PropsStore<ChatStore>
& ConversationSessionHeaderInjected
& PropsLocale<'conversation'>
/** The pending approval carrier the owner dispatches into the composer chain. */

View File

@@ -15,7 +15,8 @@ export type { ConversationKey } from './locales.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerChainProps, ConversationInjected,
ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps,
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -31,8 +31,8 @@
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* Blank hero/settling: keep the header node mounted (stable Session tree for
the wrapActiveBody composer) without taking column space. */
/* Blank hero/settling: keep the strict Session header mounted without taking
column space; the root-owned scrollport and composer remain below it. */
.headerHidden {
display: none;
}

View File

@@ -2,7 +2,7 @@
// chain, AND the composer bar (session-maybe slot) stay mounted across
// no-session/session transitions — the bar renders inert via owner props.
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
@@ -31,9 +31,8 @@ export function ConversationRoot({
// Publishes the seat's live height as --dsh-composer-height on the scroll
// body so floating controls (ChatView back-to-bottom) clear the composer as
// it grows. Callback ref, not an effect: the seat remounts when the tree
// moves between the no-session and session paths. Stable identity so React
// reattaches only on those remounts, not on every render.
// it grows. Callback ref, not an effect; stable identity prevents observer
// churn while the first blank session fills the resident body outlet.
const seatObserver = useRef<ResizeObserver | null>(null)
const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => {
seatObserver.current?.disconnect()
@@ -167,28 +166,13 @@ export function ConversationRoot({
</div>
)
// Header stays column chrome above this scrollport; the sticky composer
// seat lives inside it with the transcript. Always wrap while a session
// exists (hero/settling/active) so the composer keeps one tree seat across
// the blank → active flip — relocating it only in active remounted the textarea.
const wrapActiveBody = (view: ReactNode): ReactNode => (
<div className={css.scrollBody} data-conversation-scroll="">
{view}
{composerSeat}
</div>
)
return (
<div className={css.root} data-phase={phase}>
{/* Mounted for every real session, hero included: ConversationSession
keeps a chrome-hidden shell while blank and owns the draft-
persistence mirror bind — unmounting it in the hero would lose
pre-first-send text on a refresh or scope rebuild. */}
{sessionId !== undefined && renderSlot(
'conversation.session',
{ wrapActiveBody },
)}
{sessionId === undefined ? wrapActiveBody(null) : null}
{renderSlot('conversation.session.header', {})}
<div className={css.scrollBody} data-conversation-scroll="">
{renderSlot('conversation.session', {})}
{composerSeat}
</div>
</div>
)
}

View File

@@ -1,14 +1,19 @@
/** Strict per-session conversation content: header, view ring, and chat store bindings. */
/** Strict per-session header/body content inserted into the resident conversation layout. */
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
import { useEffect, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSessionSlotProps } from '../contract/slots.ts'
import type {
ConversationSessionHeaderSlotProps, ConversationSessionSlotProps,
} from '../contract/slots.ts'
import css from './ConversationRoot.module.css'
/** Full props composed from the strict session slot contract. */
/** Full props composed from the strict session body contract. */
export type ConversationSessionProps = ConversationSessionSlotProps
/** Full props composed from the strict session header contract. */
export type ConversationSessionHeaderProps = ConversationSessionHeaderSlotProps
interface Breadcrumb {
readonly id: SessionId
readonly displayTitle: string
@@ -38,10 +43,15 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
})
}
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
}: ConversationSessionProps) {
/**
* Renders Session header chrome above the resident conversation scrollport.
* @param props - Strict Session store, view ledger, navigation, render, and locale shares.
* @returns the hidden blank-session header or visible title and tabs.
*/
export function ConversationSessionHeader({
sessionId, useSession, useSessions, useStore, actions,
renderSlot, views, open, t,
}: ConversationSessionHeaderProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
const activeId = useStore(s => s.view) ?? 'chat'
@@ -49,6 +59,77 @@ export function ConversationSession({
const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
const hideChrome = blank && composerPhase === 'blank'
return (
<header
className={clsx(css.header, hideChrome && css.headerHidden)}
aria-hidden={hideChrome || undefined}
>
{!hideChrome && (
<>
<div className={css.titleRow}>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.headerActions}>
{renderSlot('conversation.session.header.actions', {})}
</div>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(viewTab => (
<button
key={viewTab.id}
type="button"
role="tab"
aria-selected={viewTab.id === active?.id}
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(viewTab.id) }}
>
{viewTab.label}
</button>
))}
</div>
)}
</>
)}
</header>
)
}
/**
* Renders the active Session view inside the resident scrollport and keeps
* the input draft mirrored while blank Hero chrome is visible.
* @param props - Strict Session input/store, view ledger, and render shares.
* @returns the active view area, or null while the Session remains blank.
*/
export function ConversationSession({
useSession, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
const storedDraft = useStore(s => s.draft)
// `?? null`: persisted snapshots from before the inspect field rehydrate without it.
@@ -62,13 +143,8 @@ export function ConversationSession({
// the machine mirror, not this seed effect.
}, [inputActions])
// Blank hero/settling: keep the same header + body tree shape so a
// wrapActiveBody-hosted composer keeps its DOM identity across the first
// send (hero → active). Chrome is hidden; the draft-persistence mirror
// still runs because this component stays mounted.
const hideChrome = blank && composerPhase === 'blank'
const view: ReactNode = hideChrome ? null : (
if (blank && composerPhase === 'blank') return null
return (
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {
inspect,
@@ -76,59 +152,4 @@ export function ConversationSession({
}, { only: active.id })}
</div>
)
return (
<>
<header
className={clsx(css.header, hideChrome && css.headerHidden)}
aria-hidden={hideChrome || undefined}
>
{!hideChrome && (
<>
<div className={css.titleRow}>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.headerActions}>
{renderSlot('conversation.session.header.actions', {})}
</div>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(viewTab => (
<button
key={viewTab.id}
type="button"
role="tab"
aria-selected={viewTab.id === active?.id}
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(viewTab.id) }}
>
{viewTab.label}
</button>
))}
</div>
)}
</>
)}
</header>
{wrapActiveBody !== undefined ? wrapActiveBody(view) : view}
</>
)
}

View File

@@ -123,9 +123,9 @@ export function HeroShell({ t, children }: HeroShellProps) {
<span className={css.previewBadge}>{t('hero.preview')}</span>
</div>
<div className={css.body}>
{/* The resident composer (ConversationRoot wrapActiveBody seat; the
workspace row rides the stack above the card) is CSS-centered in
the session scroll body during hero — see
{/* The resident composer (ConversationRoot's root-owned scrollport;
the workspace row rides the stack above the card) is CSS-centered
in that scroll body during hero — see
ConversationRoot.module.css [data-phase='hero']. */}
</div>
</div>