fix(workspace): unify pin IDs and preserve complete session order

This commit is contained in:
imccyu committed 2026-09-19 12:54:13 +08:00
1 parent f1e06a69ef
commit 7baccf5d85
69 files changed
+956 -543

No files matched your search

@@ -17,7 +17,7 @@ export type {
} from './model.ts'
export { WorkspaceController, WorkspaceCreateError } from './service.ts'
export type { IWorkspaces, WorkspaceSource } from './service.ts'
export type { WorkspaceId, WorkspacePinnedSession, WorkspaceView } from '../types.ts'
export type { WorkspaceId, WorkspaceView } from '../types.ts'
type WorkspaceBaselineFrame = Extract<WorkspaceFollowFrame, { type: 'baseline' }>
@@ -108,7 +108,7 @@ function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncr
accept.replaceArchived(frame.archivedSessionIds)
return
case 'pinned':
accept.replacePinned(frame.pinnedSessions)
accept.replacePinned(frame.pinnedSessionIds)
return
/* v8 ignore next -- the generated Remote codec validates this closed union */
default:
@@ -34,7 +34,7 @@ export interface WorkspaceSnapshot {
/** Complete registry-global archive set in Host order. */
readonly archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']
/** Complete registry-global pin set, most recently pinned first. */
readonly pinnedSessions: WorkspacePinValue['pinnedSessions']
readonly pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']
readonly state: 'idle' | 'loading' | 'error'
readonly phase: WorkspaceListPhase
readonly error: RemoteFailure | null
@@ -53,7 +53,7 @@ export interface WorkspaceFollowSink {
/** Replace the complete archived Session set. */
replaceArchived(sessionIds: WorkspaceArchiveValue['archivedSessionIds']): void
/** Replace the complete pinned Session set. */
replacePinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void
replacePinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void
}
/**
@@ -62,7 +62,7 @@ export interface WorkspaceFollowSink {
export class ClientWorkspaceModel implements WorkspaceFollowSink {
private items: readonly WorkspaceView[] = []
private archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] = []
private pinnedSessions: WorkspacePinValue['pinnedSessions'] = []
private pinnedSessionIds: WorkspacePinValue['pinnedSessionIds'] = []
private state: WorkspaceSnapshot['state'] = 'loading'
private phase: WorkspaceListPhase = 'pending'
private error: RemoteFailure | null = null
@@ -185,7 +185,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
this.installArchived(result.value.archivedSessionIds)
// The Host drops an archived session's pin in the same durable write;
// mirror that locally so no frame shows the row both archived and pinned.
this.installPinned(this.pinnedSessions.filter(entry => entry.sessionId !== sessionId))
this.installPinned(this.pinnedSessionIds.filter(id => id !== sessionId))
}
return result
}
@@ -219,7 +219,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
const requestSeq = ++this.pinRequestSeq
const result = await this.remote.pinSession({ sessionId })
if (result.ok && requestSeq === this.pinRequestSeq) {
this.installPinned(result.value.pinnedSessions)
this.installPinned(result.value.pinnedSessionIds)
}
return result
}
@@ -236,7 +236,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
const requestSeq = ++this.pinRequestSeq
const result = await this.remote.unpinSession({ sessionId })
if (result.ok && requestSeq === this.pinRequestSeq) {
this.installPinned(result.value.pinnedSessions)
this.installPinned(result.value.pinnedSessionIds)
}
return result
}
@@ -251,7 +251,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
this.pinRequestSeq++
this.installViews(baseline.items)
this.installArchived(baseline.archivedSessionIds)
this.installPinned(baseline.pinnedSessions)
this.installPinned(baseline.pinnedSessionIds)
this.state = 'idle'
this.phase = 'ready'
this.error = null
@@ -285,11 +285,11 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
/**
* Replace the pinned Session set from the current follow generation.
* @param pinnedSessions - complete Host-confirmed pin set, most recently pinned first.
* @param pinnedSessionIds - complete Host-confirmed pin set, most recently pinned first.
*/
replacePinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void {
replacePinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void {
this.pinRequestSeq++
this.installPinned(pinnedSessions)
this.installPinned(pinnedSessionIds)
}
/** Keep the last complete projection visible while a lost carrier reconnects. */
@@ -333,7 +333,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
return {
items: this.items,
archivedSessionIds: this.archivedSessionIds,
pinnedSessions: this.pinnedSessions,
pinnedSessionIds: this.pinnedSessionIds,
state: this.state,
phase: this.phase,
error: this.error,
@@ -347,11 +347,10 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
this.invalidate()
}
private installPinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void {
if (pinnedSessions.length === this.pinnedSessions.length
&& pinnedSessions.every((entry, index) => entry.sessionId === this.pinnedSessions[index]?.sessionId
&& entry.pinnedAt === this.pinnedSessions[index]?.pinnedAt)) return
this.pinnedSessions = [...pinnedSessions]
private installPinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void {
if (pinnedSessionIds.length === this.pinnedSessionIds.length
&& pinnedSessionIds.every((id, index) => id === this.pinnedSessionIds[index])) return
this.pinnedSessionIds = [...pinnedSessionIds]
this.invalidate()
}
@@ -73,7 +73,7 @@ export interface IWorkspaces {
*/
pinSession(sessionId: SessionId): Promise<void>
/**
* Unpin a Session back to its natural list position.
* Remove a Session's pin without changing its saved Session order.
* @param sessionId - Session to unpin.
*/
unpinSession(sessionId: SessionId): Promise<void>
@@ -10,7 +10,7 @@ import {
WorkspaceUnknownSessionError,
} from '@deepseek-ai/dsh-workspace'
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import { pinnedSessionViews, workspaceView } from './feed.ts'
import { workspaceView } from './feed.ts'
import type {
WorkspaceArchiveSessionRequest,
WorkspaceArchiveValue,
@@ -194,7 +194,7 @@ export class WorkspaceCommands {
}
throw error
}
return { pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions) }
return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }
}
/**
@@ -206,7 +206,7 @@ export class WorkspaceCommands {
*/
async unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue> {
await this.ctx.workspaceRegistry.unpinSession(request.sessionId)
return { pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions) }
return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }
}
private requireWorkspace(workspaceId: WorkspaceId): Workspace {
+5 -19
View File
@@ -3,7 +3,7 @@
import type { Context } from '@deepseek-ai/cordis'
import { Deque } from '@deepseek-ai/dsh-deque'
import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
import type { PinnedSession, Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState,
workspaceRecord,
@@ -12,7 +12,6 @@ import {
import type {
WorkspaceBaseline,
WorkspaceFollowFrame,
WorkspacePinnedSession,
WorkspaceView,
} from './types.ts'
@@ -44,19 +43,6 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
}
}
/**
* Project registry pin entries into their Remote values.
* @param entries - authoritative registry pin entries.
* @returns detached pin projections for Remote consumers.
*/
export function pinnedSessionViews(entries: readonly PinnedSession[]): WorkspacePinnedSession[] {
return entries.map(entry => ({ sessionId: entry.id, pinnedAt: entry.pinnedAt }))
}
/** One comparable key per pin entry; pinnedAt participates so a re-pin at a new instant publishes. */
const pinKeys = (entries: readonly PinnedSession[]): string[] =>
entries.map(entry => `${entry.id}@${entry.pinnedAt}`)
/** Owns Workspace domain observation and all active follow generations. */
export class WorkspaceFeed {
private readonly followers = new Set<WorkspaceFollower>()
@@ -71,7 +57,7 @@ export class WorkspaceFeed {
this.knownIds = new Set(baseline.map(workspace => String(workspace.id)))
this.order = baseline.map(workspace => String(workspace.id))
this.archived = ctx.workspaceRegistry.archivedSessionIds.map(String)
this.pinned = pinKeys(ctx.workspaceRegistry.pinnedSessions)
this.pinned = ctx.workspaceRegistry.pinnedSessionIds.map(String)
ctx.on('domain/changed', (change: DomainChanged) => { this.changed(change) })
ctx.effect(() => () => {
for (const follower of this.followers) follower.close()
@@ -87,7 +73,7 @@ export class WorkspaceFeed {
return {
items: this.ctx.workspaceRegistry.list().map(workspaceView),
archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds],
pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions),
pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds],
}
}
@@ -132,10 +118,10 @@ export class WorkspaceFeed {
this.archived = nextArchived
this.publish({ type: 'archived', archivedSessionIds: [...state.archivedSessionIds] })
}
const nextPinned = pinKeys(state.pinnedSessions)
const nextPinned = state.pinnedSessionIds.map(String)
if (!sameStrings(this.pinned, nextPinned)) {
this.pinned = nextPinned
this.publish({ type: 'pinned', pinnedSessions: pinnedSessionViews(state.pinnedSessions) })
this.publish({ type: 'pinned', pinnedSessionIds: [...state.pinnedSessionIds] })
}
return
}
@@ -134,7 +134,7 @@ export class WorkspaceController extends TypertRemoteService {
}
/**
* Return one pinned Session to its natural list position.
* Remove one Session's pin without changing its saved Session order.
* @param request - Session identity to unpin.
* @returns the complete resulting pin set, most recently pinned first.
*/
+4 -10
View File
@@ -119,20 +119,14 @@ export interface WorkspacePinSessionRequest {
readonly sessionId: SessionId
}
/** Session requested for unpinning back to its natural list position. */
/** Session requested for removal from the pin set. */
export interface WorkspaceUnpinSessionRequest {
readonly sessionId: SessionId
}
/** One pinned Session entry: the Session and the epoch-millisecond instant it was pinned at. */
export interface WorkspacePinnedSession {
readonly sessionId: SessionId
readonly pinnedAt: number
}
/** Complete pinned Session set after a mutation, most recently pinned first. */
export interface WorkspacePinValue {
readonly pinnedSessions: readonly WorkspacePinnedSession[]
readonly pinnedSessionIds: readonly SessionId[]
}
/** Complete reconnect baseline for Workspace browser state. */
@@ -140,7 +134,7 @@ export interface WorkspaceBaseline {
readonly items: readonly WorkspaceView[]
readonly archivedSessionIds: readonly SessionId[]
/** Registry-global pin set, most recently pinned first. */
readonly pinnedSessions: readonly WorkspacePinnedSession[]
readonly pinnedSessionIds: readonly SessionId[]
}
/** One ordered Workspace change after a generation's baseline. */
@@ -149,7 +143,7 @@ export type WorkspaceFollowIncrement =
| { readonly type: 'remove'; readonly workspaceId: WorkspaceId }
| { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] }
| { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] }
| { readonly type: 'pinned'; readonly pinnedSessions: readonly WorkspacePinnedSession[] }
| { readonly type: 'pinned'; readonly pinnedSessionIds: readonly SessionId[] }
/** Workspace state stream; every generation starts with exactly one baseline. */
export type WorkspaceFollowFrame =