Merge latest master into web transcript projection

# Conflicts:
#	packages/client/runtime/README.i18n.yaml
This commit is contained in:
Tianyi Cui
2026-07-31 14:31:14 +08:00
70 changed files with 982 additions and 148 deletions

View File

@@ -76,4 +76,11 @@ export interface IWorkspaces {
* @returns the updated Workspace view.
*/
insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>
/**
* Archive a session into the registry-global set (hidden from grouping
* surfaces; session log and accounting slot remain). Archiving the current
* session clears the selection into the New Session view state.
* @param sessionId - session to archive.
*/
archiveSession(sessionId: SessionId): Promise<void>
}

View File

@@ -14,6 +14,14 @@ export type WorkspaceListPhase = 'pending' | 'ready'
/** Immutable workspace-list snapshot. */
export interface WorkspaceListSnapshot {
items: readonly WorkspaceView[]
/**
* Registry-global archive set in Host order (hidden from grouping
* surfaces; accounting slots retained). A plain array, not a Set: public
* snapshot state stays in the store engine's plain-data vocabulary
* (immer drafts reject Sets without the MapSet plugin); membership
* lookups build their own transient Set where they need one.
*/
archivedSessionIds: readonly SessionId[]
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RpcError | null
@@ -28,11 +36,21 @@ export class WorkspaceManager {
private items: Workspace[] = []
private itemViewsSource: readonly Workspace[] | null = null
private itemViewsCache: readonly WorkspaceView[] = []
// Full-snapshot state (list response / unary response / changed frame all
// carry the complete set), so deltas never merge — installs replace.
private archivedSessionIds: readonly SessionId[] = []
private state: WorkspaceListSnapshot['state'] = 'idle'
private phase: WorkspaceListPhase = 'pending'
private error: RpcError | null = null
private inflight: Promise<void> | null = null
private refreshFrames: WorkspaceDelta[] | null = null
/**
* True once a frame or unary echo installed the archive set while a list
* request was in flight: that install is newer than the pending baseline,
* so the baseline's (older) set must not roll it back — the archive
* mirror of replaying refreshFrames over the item baseline.
*/
private archivedSupersedesRefresh = false
/**
* Ids this process has seen removed, kept for the connection's lifetime so
* a late changed frame or a stale baseline row cannot resurrect a deleted
@@ -77,6 +95,7 @@ export class WorkspaceManager {
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
this.installViews(items)
if (!this.archivedSupersedesRefresh) this.installArchived(result.value.archivedSessionIds)
this.state = 'idle'
this.phase = 'ready'
} else {
@@ -90,6 +109,7 @@ export class WorkspaceManager {
this.error = folded.ok ? null : folded.error
} finally {
this.refreshFrames = null
this.archivedSupersedesRefresh = false
this.inflight = null
this.notifier.markDirty()
}
@@ -158,6 +178,18 @@ export class WorkspaceManager {
return result
}
/**
* Archive one session in the registry-global set, then install the
* returned full set without waiting for the changed frame.
* @param sessionId - session to archive.
* @returns the wire result.
*/
async archiveSession(sessionId: SessionId): Promise<RpcResult<{ archivedSessionIds: SessionId[] }>> {
const { result } = await this.api.workspace.archiveSession({ sessionId })
if (result.ok) this.installArchived(result.value.archivedSessionIds)
return result
}
/**
* Host-frame entry. Non-workspace frames are ignored so the runtime can
* fan one host stream out to both object managers.
@@ -166,6 +198,9 @@ export class WorkspaceManager {
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
else if (envelope.payload.type === 'host/archived-sessions-changed') {
this.installArchived(envelope.payload.archivedSessionIds)
}
}
/** Re-pull the baseline after each connection generation. */
@@ -194,12 +229,26 @@ export class WorkspaceManager {
private buildSnapshot(): WorkspaceListSnapshot {
return {
items: this.itemViews(),
archivedSessionIds: this.archivedSessionIds,
state: this.state,
phase: this.phase,
error: this.error,
}
}
/**
* Replace the archive set when membership actually changed (array identity
* backs Object.is short-circuits). Host snapshots are append-ordered, so
* positional comparison is exact, not merely heuristic.
*/
private installArchived(archivedSessionIds: readonly SessionId[]): void {
if (this.refreshFrames !== null) this.archivedSupersedesRefresh = true
if (archivedSessionIds.length === this.archivedSessionIds.length
&& archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return
this.archivedSessionIds = [...archivedSessionIds]
this.notifier.markDirty()
}
/** Upsert one Host view, optionally retaining the local object that materialized it. */
private upsert(view: WorkspaceView, identity?: Workspace): void {
if (this.removedIds.has(view.workspaceId)) return

View File

@@ -14,6 +14,14 @@ import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
/** Workspace list plus the two-baseline readiness and default-target projection. */
export interface WorkspaceListState {
items: readonly WorkspaceView[]
/**
* Registry-global archive set in Host order: grouping surfaces hide these
* sessions everywhere (workspace groups and the ungrouped bucket) while
* their session logs and workspace accounting slots remain. A plain array
* (store-engine vocabulary; immer drafts reject Sets) — membership lookups
* build their own transient Set.
*/
archivedSessionIds: readonly SessionId[]
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RpcError | null
@@ -58,7 +66,7 @@ export class WorkspacesService implements IWorkspaces {
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) {
this.manager = new WorkspaceManager(api)
this.list = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'pending', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'pending', error: null,
baselinesReady: false, recentWorkspaceId: undefined,
})
this.manager.subscribe(() => { this.project() })
@@ -88,10 +96,14 @@ export class WorkspacesService implements IWorkspaces {
if (inflight !== undefined) return inflight
// Reuse: blank && same canonical cwd (workspace.path is the host realpath
// canon; summary cwd is the session header passthrough of the same canon).
// An archived blank is never reused: reuse would open a session no
// grouping surface can show, so New Session mints a fresh one instead.
const archived = this.list.getSnapshot().archivedSessionIds
const sessions = this.sessions.list.getSnapshot()
for (const id of sessions.ids) {
const summary = sessions.byId[id]
if (summary !== undefined && summary.blank && summary.cwd === workspace.path) return summary.id
if (summary !== undefined && summary.blank && summary.cwd === workspace.path
&& !archived.includes(summary.id)) return summary.id
}
const attempt = this.sessions.create({ workspaceId })
.finally(() => { this.connecting.delete(workspaceId) })
@@ -249,6 +261,17 @@ export class WorkspacesService implements IWorkspaces {
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Archive a session into the registry-global set. Clearing an archived
* current selection is the projection sweep's job (one rule for the local
* echo and a remote tab's frame alike).
* @param sessionId - session to archive.
*/
async archiveSession(sessionId: SessionId): Promise<void> {
const result = await this.manager.archiveSession(sessionId)
if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
* @param workspaceId - owning workspace.
@@ -291,8 +314,17 @@ export class WorkspacesService implements IWorkspaces {
const workspace = this.manager.getSnapshot()
const sessions = this.sessions.list.getSnapshot()
const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
// An archived current selection clears into the New Session view state —
// a hidden row must not stay open behind the list. Sweeping here covers
// every install path with one rule: the local unary echo, another tab's
// changed frame, and a reconnect baseline restoring a persisted
// selection that was archived while this client was away.
if (sessions.current !== undefined && workspace.archivedSessionIds.includes(sessions.current)) {
this.sessions.clear()
}
this.list.set({
items: workspace.items,
archivedSessionIds: workspace.archivedSessionIds,
state: workspace.state,
phase: workspace.phase,
error: workspace.error,