Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
#	docs/architecture.i18n.yaml
#	docs/core-data-structures/core.i18n.yaml
#	packages/client/runtime/README.i18n.yaml
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/input-matrix.spec.tsx
#	packages/client/ui-conversation/tests/input-scenarios.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
This commit is contained in:
Yichen Jiang
2026-07-28 16:07:58 +08:00
243 files changed
+3167 -1684

No files matched your search

@@ -153,6 +153,13 @@ export class SessionsService {
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry. */
private readonly manager: SessionManager
/**
* Atomic current-session provide projection: selection changes and
* provider-roster changes publish through this one source (the renderer
* host's `sessions.provide` feed), so a roster change under a stable
* current id republishes the bundle instead of stranding mounted entries.
*/
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* Persisted selection cell (the durable half of `list.current`). Private on
@@ -169,6 +176,10 @@ export class SessionsService {
private readonly providers: SessionProvideDescriptor[] = []
/** Static no-session projection, rebuilt only when the provider roster changes. */
private maybeInfo: SessionMaybeProvideInfo
/** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
private currentProvideInfoSnapshot: SessionMaybeProvideInfo
/** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
private readonly currentProvideInfoListeners = new Set<() => void>()
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
@@ -201,7 +212,11 @@ export class SessionsService {
// dedicated code path. Safe to run synchronously inside the store notify:
// the follower writes no list state — session.open()'s synchronous prefix
// touches only session-side state and its own microtask-batched notifier.
this.list.subscribe(() => { this.followCurrent() })
// The current-provide projection follows the same current writes.
this.list.subscribe(() => {
this.followCurrent()
this.updateCurrentProvideInfo()
})
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
this.providers.push({
@@ -209,6 +224,14 @@ export class SessionsService {
resolve: binding => ({ hooks: { session: binding.session } }),
})
this.maybeInfo = this.materializeMaybeProvideInfo()
this.currentProvideInfoSnapshot = this.maybeInfo
this.currentProvideInfo = {
getSnapshot: () => this.currentProvideInfoSnapshot,
subscribe: (fn) => {
this.currentProvideInfoListeners.add(fn)
return () => { this.currentProvideInfoListeners.delete(fn) }
},
}
rootCtx.reflect.provide('sessions', this, undefined)
}
@@ -257,6 +280,30 @@ export class SessionsService {
for (const record of this.scopes.values()) {
record.provideInfo = this.materializeProvideInfo(record.binding)
}
this.updateCurrentProvideInfo()
}
/**
* Re-derive the current selection's provide bundle and publish it when it
* changed. Bundles are identity-stable per (scope, roster)
* materialization, so an identity compare is exact; synchronous notify —
* both call sites (list.subscribe, provide()) already sit behind their own
* batching or registration edges.
*/
private updateCurrentProvideInfo(): void {
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
if (next === this.currentProvideInfoSnapshot) return
this.currentProvideInfoSnapshot = next
for (const fn of [...this.currentProvideInfoListeners]) {
try {
fn()
} catch (error) {
// Contain subscriber failures: this notify runs inside the list
// notification, where a throwing render-side subscriber would starve
// later listeners and abort the projection pass that scheduled it.
console.error('sessions.currentProvideInfo subscriber failed:', error)
}
}
}
/** Build the static no-session kit and reject duplicate declared names. */
@@ -423,25 +470,21 @@ export class SessionsService {
}
/**
* Resolve the render-layer standard-props bundle (SessionProvider's feed
* through the renderer host; ctx never enters the render layer). Pure
* resolution — render-safe: SessionProvider calls this during render, so no
* staging, no window side effects (StrictMode double-invokes and concurrent
* discarded passes must stay free).
* @param id - session id.
* @returns the provide info, or undefined for a session neither listed nor already scoped.
* Resolve one session's render-layer standard-props bundle (ctx never
* enters the render layer; the renderer subscribes to
* {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
* no staging, no window side effects (StrictMode double-invokes and
* concurrent discarded passes must stay free).
*/
provideInfo(id: string): SessionProvideInfo | undefined {
private provideInfo(id: string): SessionProvideInfo | undefined {
return this.resolve(id as SessionId)?.provideInfo
}
/**
* Resolve the current-session-optional standard kit. Unknown or absent ids
* return the static no-session projection rather than removing hook props.
* @param id - current session id, when selected.
* @returns a definite or no-session provide bundle.
*/
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
}
+1 -10
View File
@@ -246,13 +246,6 @@ export class SlotsService extends Service {
if (workspaces === undefined) {
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
}
// Identity-stable view: current rides the list snapshot (arbitrated), but
// the provider consumes it as its own observable; one cached object keeps
// the renderer's per-source hook cache stable.
const current = {
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
}
this._host = {
subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key),
@@ -263,9 +256,7 @@ export class SlotsService extends Service {
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
sessions: {
list: sessions.list,
current,
provideInfo: id => sessions.provideInfo(id),
maybeProvideInfo: id => sessions.maybeProvideInfo(id),
provideInfo: sessions.currentProvideInfo,
},
workspaces: { list: workspaces.list },
}