refactor: replace overloaded surface terminology

This commit is contained in:
Turtle
2026-07-24 19:54:25 +08:00
parent c172faed37
commit 0c708cb10d
626 changed files with 1396 additions and 1397 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 605bba15d704c0c6e9f28abb3cddeb68bdd7e0d8
README.md: ed8f888d35693ecaa2667ea432b462f4bb3369cf
README.zh.md: e6a2dd0b545b66ab01b213b5ebc937e22af8ac1a

View File

@@ -40,7 +40,7 @@ The composer bar declares session-scoped single seats for `'conversation.input.p
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` exports contain only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
A finished turn materializes one ordered `turn-tail` Conversation Node. Its engine-owned `TurnLocation` supplies the closing Assistant and Turn data; the renderer places the `conversation.chat.turnTail` chain before that node's IconActions and dispatches `TurnTailOwnerProps` containing the Turn, closing seq, and `openFile`. This package owns only the hole; `@deepseek-ai/dsh-client-ui-deliverables` accumulates mutation-tool `locations` into Turn data and owns the produced-files row, chip cap, and copy, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert.

View File

@@ -122,7 +122,7 @@ export class ConversationService extends Service implements IConversation {
/**
* Send a prompt into the scoped session. Business failures also land in the
* session snapshot's promptError (object-layer surface); the rejection here
* session snapshot's promptError (object-layer state); the rejection here
* exists for caller choreography (the composer restores the draft on it).
* @param text - prompt text, sent verbatim as one text block.
*/

View File

@@ -1,15 +1,15 @@
// @vitest-environment jsdom
// apply inject factories exercised end to end against the terminal thin
// shape: the strict session surface (views triple, draft mirror), the
// API: the strict session API (views triple, draft mirror), the
// provide-channel input face (machine-sink submit choreography incl.
// optimistic clear + failure restore), the resident surface (selectWorkspace
// optimistic clear + failure restore), the resident API (selectWorkspace
// draft carrying), the composer-bar stop face, openDetails = select action +
// layout orchestration, and the closeDetails details surface. Complements
// layout orchestration, and the closeDetails details API. Complements
// chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store
// axis). History opening is NOT an inject concern — the runtime sessions
// service opens on watch (sessions-service.spec.ts owns that behavior).
//
// The inject surfaces are read off the ledger entries deliberately (typed at
// The inject APIs are read off the ledger entries deliberately (typed at
// this spec's own contract): these cases pin factory choreography the UI
// guards would mask. Rendering-path acceptance lives in
// chat-toolview-slot.spec.tsx.
@@ -75,30 +75,30 @@ async function bench() {
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
runtime.slots.entries(key)[0]!
/** Resolve store instance + call the inject the way the outlet would. */
const conversationSurface = (id: SessionId) => {
const conversationApi = (id: SessionId) => {
const entry = entryOf('conversation.session')
const instance = runtime.storeOf('conversation.session', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
id, instance.actions)
return { instance, injected }
}
const conversationHeaderSurface = (id: SessionId) => {
const conversationHeaderApi = (id: SessionId) => {
const entry = entryOf('conversation.session.header')
const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)(
id, instance.actions)
return { instance, injected }
}
const residentSurface = (id: SessionId | undefined) => {
const residentApi = (id: SessionId | undefined) => {
const entry = entryOf('conversation')
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
}
const composerSurface = (id: SessionId | undefined) => {
const composerApi = (id: SessionId | undefined) => {
const entry = entryOf('conversation.composer.bar')
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
}
/** Same resolution for the chat entry riding the view ring. */
const chatViewSurface = (id: SessionId) => {
const chatViewApi = (id: SessionId) => {
const entry = entryOf('conversation.view')
const instance = runtime.storeOf('conversation.view', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
@@ -106,7 +106,7 @@ async function bench() {
return { instance, injected }
}
/** Materialize the input provide contribution the way the runtime does. */
const inputSurface = (id: SessionId) => {
const inputApi = (id: SessionId) => {
const info = runtime.sessions.provideInfo(id)!
const state = info.hooks['input'] as {
getSnapshot: () => { draft: string }
@@ -120,21 +120,21 @@ async function bench() {
}
return {
runtime, feature, slots: runtime.slots, entryOf,
conversationSurface, conversationHeaderSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
conversationApi, conversationHeaderApi, residentApi, composerApi, chatViewApi, inputApi,
sessionFake, layoutFake,
}
}
describe('conversation slot inject surface', () => {
it('assembles the thin surface side-effect-free', async () => {
describe('conversation slot inject API', () => {
it('assembles the thin API side-effect-free', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const { injected } = b.conversationApi(ROOT)
// Assembly has no session side effects: opening the event window belongs
// to the runtime watch path, not the inject factory.
expect(b.sessionFake.open).not.toHaveBeenCalled()
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
const chatView = b.chatViewSurface(ROOT)
const chatView = b.chatViewApi(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
chatView.injected.forkAt(17)
@@ -149,8 +149,8 @@ describe('conversation slot inject surface', () => {
it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const { state, actions } = b.inputSurface(ROOT)
const { injected } = b.conversationApi(ROOT)
const { state, actions } = b.inputApi(ROOT)
// Whitespace-only: the machine treats it as empty — no prompt, draft kept.
actions.setDraft(' ')
actions.submit()
@@ -176,16 +176,16 @@ describe('conversation slot inject surface', () => {
await new Promise(r => setTimeout(r, 0))
expect(state.getSnapshot().draft).toBe('typed during flight')
// The provide contribution is idempotent per session: one shell identity.
expect(b.inputSurface(ROOT).state).toBe(state)
expect(b.inputApi(ROOT).state).toBe(state)
// The draft mirror rides the conversation inject face.
const mirrored: string[] = []
const unbind = injected.bindDraftMirror(text => mirrored.push(text))
actions.setDraft('mirrored text')
expect(mirrored).toEqual(['mirrored text'])
unbind()
// Stop failure is swallowed (promptError owns the surface).
// Stop failure is swallowed (promptError owns the display).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
b.composerSurface(ROOT).stop!()
b.composerApi(ROOT).stop!()
await new Promise(r => setTimeout(r, 0))
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
await b.runtime.dispose()
@@ -216,20 +216,20 @@ describe('conversation slot inject surface', () => {
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
const b = await bench()
const { instance, injected } = b.chatViewSurface(ROOT)
const { instance, injected } = b.chatViewApi(ROOT)
injected.openDetails({ turnSeq: 2, callId: 'c1' })
expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
// The chat view shares the conversation entry's store instance: selection
// writes land where the skeleton and details read.
const conv = b.conversationSurface(ROOT)
const conv = b.conversationApi(ROOT)
expect(conv.instance).toBe(instance)
await b.runtime.dispose()
})
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
const b = await bench()
const { injected } = b.chatViewSurface(ROOT)
const { injected } = b.chatViewApi(ROOT)
injected.openFile('src/a.ts')
await vi.waitFor(() => {
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
@@ -239,11 +239,11 @@ describe('conversation slot inject surface', () => {
it('routes workspace switching through the runtime owner, carrying the draft', async () => {
const b = await bench()
const resident = b.residentSurface(ROOT)
const resident = b.residentApi(ROOT)
// Same-session connect (the picked workspace resolves to this session):
// no draft movement, plain re-open.
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
const { state, actions } = b.inputSurface(ROOT)
const { state, actions } = b.inputApi(ROOT)
actions.setDraft('carry me')
void resident.selectWorkspace('workspace-1' as never)
await vi.waitFor(() => {
@@ -261,7 +261,7 @@ describe('conversation slot inject surface', () => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
})
expect(state.getSnapshot().draft).toBe('')
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('carry me')
await b.runtime.dispose()
})
@@ -269,7 +269,7 @@ describe('conversation slot inject surface', () => {
const b = await bench()
// No-session resident (hero before any session): connect resolves and
// navigation proceeds without any draft choreography.
const noSession = b.residentSurface(undefined)
const noSession = b.residentApi(undefined)
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
void noSession.selectWorkspace('workspace-0' as never)
await vi.waitFor(() => {
@@ -279,15 +279,15 @@ describe('conversation slot inject surface', () => {
// Cross-session connect with an EMPTY draft: no move, no clearing.
const OTHER = 'b9-other' as SessionId
await b.runtime.sessions.add({ id: OTHER }, { current: false })
const resident = b.residentSurface(ROOT)
const { state } = b.inputSurface(ROOT)
const resident = b.residentApi(ROOT)
const { state } = b.inputApi(ROOT)
expect(state.getSnapshot().draft).toBe('')
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
void resident.selectWorkspace('workspace-3' as never)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
})
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('')
expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('')
// Connect failure: the rejection propagates to the caller (the view owns
// the rollback) and no further navigation happens.
@@ -310,7 +310,7 @@ describe('conversation slot inject surface', () => {
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const { injected } = b.conversationApi(ROOT)
const before = injected.views.version()
const listener = vi.fn()
const unsub = injected.views.subscribe(listener)
@@ -332,7 +332,7 @@ describe('conversation slot inject surface', () => {
})
})
describe('details inject surface', () => {
describe('details inject API', () => {
it('details injects the one layout callback; selection rides the shared store instead', async () => {
const b = await bench()
const entry = b.entryOf('details')