refactor(client): isolate trajectory history

This commit is contained in:
imccyu
2026-07-29 20:21:37 +08:00
parent ee9ea7e3aa
commit e0022ff19e
28 changed files with 1261 additions and 698 deletions

View File

@@ -83,8 +83,8 @@ export function apply(ctx: Context): void {
},
}), 'ui-conversation: input standard-kit provider')
// Resident current-session-optional shell. It constructs the stable
// composer frame; the strict session child decides whether Chat mounts it.
// Resident current-session-optional shell. It owns the stable Hero/composer
// frame while strict session slots fill only their session-bound regions.
slots.register({
name: 'conversation',
children: {
@@ -114,8 +114,8 @@ export function apply(ctx: Context): void {
}),
}, ConversationRoot)
// The strict session subtree owns the per-session store and view content;
// the resident parent supplies the composer node through owner props.
// The strict session subtree owns only per-session store and view content;
// the resident parent keeps Hero and composer layout identity stable.
slots.register({
name: 'conversation.session',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },

View File

@@ -19,7 +19,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps }
/**
* The conversation view ring: one list entry per view tab (chat here;
* Trajectory from ui-trajectory), rendered one-at-a-time by
* 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.
@@ -117,8 +117,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Owner share of the strict session content seat. */
export interface ConversationSessionOwnerProps {
/** Composer chain assembled by the resident parent; only Chat mounts it. */
composer: ReactNode
}
/**
@@ -143,7 +141,7 @@ export interface ConvViewOwnerProps {}
/**
* Owner share of a per-view toolview slot: the call material the rendering
* view supplies per row. Uniform across views — the Trajectory
* view supplies per row. Uniform across views — the trajectory/waterfall
* toolview slots (same kind/scope/owner, names fixed by the slot-naming
* discipline) land with their own row render sites; today only the chat slot
* is declared (RendersCheck rejects a declaration nobody renders).

View File

@@ -118,11 +118,6 @@ export function ConversationRoot({
{inputBar}
</div>
)
const composer = renderSlotChain(
'conversation.composer',
{ interactions: pending },
{ fallback: composerBar, overlay: true },
)
return (
<div className={css.root} data-phase={settling ? 'settling' : hero ? 'hero' : 'active'}>
@@ -130,9 +125,12 @@ export function ConversationRoot({
renders no chrome while blank but 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', { composer })
: composer}
{sessionId !== undefined && renderSlot('conversation.session', {})}
{renderSlotChain(
'conversation.composer',
{ interactions: pending },
{ fallback: composerBar, overlay: true },
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
/** Strict per-session conversation content: header, view ring, and chat store bindings. */
import { Fragment, useEffect, useSyncExternalStore } from 'react'
import { useEffect, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
@@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open, composer,
renderSlot, views, bindDraftMirror, open,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
@@ -32,7 +32,6 @@ export function ConversationSession({
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const composerPhase = useSession(s => s.composerPhase)
const hasPending = useSession(s => s.pending.length > 0)
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
const storedDraft = useStore(s => s.draft)
@@ -45,11 +44,11 @@ export function ConversationSession({
// the machine mirror, not this seed effect.
}, [inputActions])
const blankHero = blank && composerPhase === 'blank'
if (blank && composerPhase === 'blank') return null
return (
<>
{!blankHero && <header className={css.header}>
<header className={css.header}>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="Session hierarchy">
{ancestry.map((summary, index) => {
@@ -87,13 +86,10 @@ export function ConversationSession({
))}
</div>
)}
</header>}
{!blankHero && <div className={css.viewArea}>
</header>
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>}
{(blankHero || active?.id === 'chat' || hasPending) && (
<Fragment key="composer">{composer}</Fragment>
)}
</div>
</>
)
}

View File

@@ -34,7 +34,6 @@ function sessionFakeFor() {
return {
open: vi.fn(() => Promise.resolve()),
loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
loadAllHistory: vi.fn<ISession['loadAllHistory']>(() => Promise.resolve()),
prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
} satisfies SessionBehaviorOverrides

View File

@@ -59,7 +59,6 @@ async function bench(nodes: ToolResultNode[]) {
snapshot: { nodes },
session: {
loadOlder: vi.fn<ISession['loadOlder']>(),
loadAllHistory: vi.fn<ISession['loadAllHistory']>(),
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
},
})

View File

@@ -18,7 +18,7 @@ import { ConversationSession } from '../src/client/skeleton/ConversationSession.
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import type {
ComposerBarOwnerProps, ConversationSessionOwnerProps,
ComposerBarOwnerProps,
} from '../src/client/contract/slots.ts'
/** Machine-backed wiring over a sink spy. */
@@ -88,10 +88,8 @@ function mount(
slotCalls.push(key)
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
if (key === 'conversation.session') {
const sessionOwner = owner as ConversationSessionOwnerProps
return (
<ConversationSession
composer={sessionOwner.composer}
sessionId={SID}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}