diff --git a/package-lock.json b/package-lock.json index 6aad70d75..1652b764a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35400,6 +35400,7 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "express-basic-auth": "^1.2.1", + "fast-deep-equal": "^3.1.3", "fast-uri": "^3.1.0", "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.11", diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 121a61fa9..75d37bcf6 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -57,11 +57,6 @@ function HostIndexRouteContent() { ); const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : []; - visibleWorkspaces.sort((left, right) => { - const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY; - const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY; - return rightTime - leftTime; - }); const primaryAgent = visibleAgents[0]; if (primaryAgent?.cwd?.trim()) { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index 88f4925c6..dd0f01ac4 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -15,7 +15,7 @@ function item(key: string): OrderedItem { } function workspace( - input: Pick & + input: Pick & Partial< Pick< WorkspaceDescriptor, @@ -32,7 +32,6 @@ function workspace( workspaceKind: input.workspaceKind ?? "local_checkout", name: input.name, status: input.status, - activityAt: input.activityAt, diffStat: null, }; } @@ -100,7 +99,6 @@ describe("buildSidebarProjectsFromWorkspaces", () => { projectId: "project-1", name: "feat/hard-cut", status: "failed", - activityAt: new Date("2026-01-01T00:00:00.000Z"), }), ]; @@ -117,21 +115,19 @@ describe("buildSidebarProjectsFromWorkspaces", () => { expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed"); }); - it("preserves stored project order even when activity changes", () => { + it("preserves stored project order even when input order differs", () => { const initialWorkspaces: WorkspaceDescriptor[] = [ workspace({ id: "/repo/b", projectId: "project-b", name: "feat/b", status: "running", - activityAt: new Date("2026-01-02T00:00:00.000Z"), }), workspace({ id: "/repo/a", projectId: "project-a", name: "feat/a", status: "running", - activityAt: new Date("2026-01-01T00:00:00.000Z"), }), ]; @@ -148,19 +144,17 @@ describe("buildSidebarProjectsFromWorkspaces", () => { const updatedProjects = buildSidebarProjectsFromWorkspaces({ serverId: "srv", workspaces: [ - workspace({ - id: "/repo/b", - projectId: "project-b", - name: "feat/b", - status: "running", - activityAt: new Date("2026-01-02T00:00:00.000Z"), - }), workspace({ id: "/repo/a", projectId: "project-a", name: "feat/a", status: "running", - activityAt: new Date("2026-01-03T00:00:00.000Z"), + }), + workspace({ + id: "/repo/b", + projectId: "project-b", + name: "feat/b", + status: "running", }), ], projectOrder: seededOrder, @@ -168,8 +162,8 @@ describe("buildSidebarProjectsFromWorkspaces", () => { }); expect(updatedProjects.map((project) => project.projectKey)).toEqual([ - "project-b", "project-a", + "project-b", ]); }); @@ -182,21 +176,18 @@ describe("buildSidebarProjectsFromWorkspaces", () => { projectId: "project-c", name: "feat/c", status: "running", - activityAt: new Date("2026-01-04T00:00:00.000Z"), }), workspace({ id: "/repo/b", projectId: "project-b", name: "feat/b", status: "running", - activityAt: new Date("2026-01-02T00:00:00.000Z"), }), workspace({ id: "/repo/a", projectId: "project-a", name: "feat/a", status: "running", - activityAt: new Date("2026-01-01T00:00:00.000Z"), }), ], projectOrder: ["project-b", "project-a", "project-c"], @@ -219,14 +210,12 @@ describe("buildSidebarProjectsFromWorkspaces", () => { projectId: "project-1", name: "main", status: "running", - activityAt: new Date("2026-01-02T00:00:00.000Z"), }), workspace({ id: "/repo/feature", projectId: "project-1", name: "feature", status: "running", - activityAt: new Date("2026-01-01T00:00:00.000Z"), }), ], projectOrder: ["project-1"], @@ -246,14 +235,12 @@ describe("buildSidebarProjectsFromWorkspaces", () => { projectId: "project-1", name: "main", status: "running", - activityAt: new Date("2026-01-02T00:00:00.000Z"), }), workspace({ id: "/repo/feature", projectId: "project-1", name: "feature", status: "running", - activityAt: new Date("2026-01-03T00:00:00.000Z"), }), ], projectOrder: ["project-1"], @@ -263,8 +250,8 @@ describe("buildSidebarProjectsFromWorkspaces", () => { }); expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([ - "/repo/main", "/repo/feature", + "/repo/main", ]); }); }); diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 947c83423..34049979a 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -7,6 +7,7 @@ import { import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceDescriptorPayload } from "@server/shared/messages"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; const EMPTY_ORDER: string[] = []; @@ -21,7 +22,6 @@ export interface SidebarWorkspaceEntry { workspaceId: string; workspaceKind: WorkspaceDescriptor["workspaceKind"]; name: string; - activityAt: Date | null; statusBucket: SidebarStateBucket; diffStat: { additions: number; deletions: number } | null; } @@ -34,7 +34,6 @@ export interface SidebarProjectEntry { statusBucket: SidebarStateBucket; activeCount: number; totalWorkspaces: number; - latestActivityAt: Date | null; workspaces: SidebarWorkspaceEntry[]; } @@ -67,15 +66,6 @@ function compareWorkspaceBaseline( left: SidebarWorkspaceEntry, right: SidebarWorkspaceEntry, ): number { - if (left.activityAt && right.activityAt) { - const dateDelta = right.activityAt.getTime() - left.activityAt.getTime(); - if (dateDelta !== 0) { - return dateDelta; - } - } else if (left.activityAt || right.activityAt) { - return left.activityAt ? -1 : 1; - } - const nameDelta = left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base", @@ -91,15 +81,6 @@ function compareWorkspaceBaseline( } function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number { - if (left.latestActivityAt && right.latestActivityAt) { - const dateDelta = right.latestActivityAt.getTime() - left.latestActivityAt.getTime(); - if (dateDelta !== 0) { - return dateDelta; - } - } else if (left.latestActivityAt || right.latestActivityAt) { - return left.latestActivityAt ? -1 : 1; - } - return left.projectName.localeCompare(right.projectName, undefined, { numeric: true, sensitivity: "base", @@ -126,7 +107,6 @@ export function buildSidebarProjectsFromWorkspaces(input: { statusBucket: "done", activeCount: 0, totalWorkspaces: 0, - latestActivityAt: null, workspaces: [], } satisfies SidebarProjectEntry); @@ -136,7 +116,6 @@ export function buildSidebarProjectsFromWorkspaces(input: { workspaceId: workspace.id, workspaceKind: workspace.workspaceKind, name: workspace.name, - activityAt: workspace.activityAt, statusBucket: workspace.status, diffStat: workspace.diffStat, }; @@ -147,12 +126,6 @@ export function buildSidebarProjectsFromWorkspaces(input: { project.activeCount += 1; } project.statusBucket = aggregateBucket(project.statusBucket, workspace.status); - if ( - !project.latestActivityAt || - (workspace.activityAt && workspace.activityAt.getTime() > project.latestActivityAt.getTime()) - ) { - project.latestActivityAt = workspace.activityAt; - } byProject.set(workspace.projectId, project); } @@ -251,17 +224,7 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string return `${serverId.trim()}::${projectKey.trim()}`; } -function toWorkspaceDescriptor(payload: { - id: string; - projectId: string; - projectDisplayName: string; - projectRootPath: string; - projectKind: WorkspaceDescriptor["projectKind"]; - workspaceKind: WorkspaceDescriptor["workspaceKind"]; - name: string; - status: WorkspaceDescriptor["status"]; - activityAt: string | null; -}): WorkspaceDescriptor { +function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor { return normalizeWorkspaceDescriptor(payload); } diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index 84562c96d..b43130fc5 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -17,7 +17,6 @@ describe("workspace source of truth consumption", () => { workspaceKind: "local_checkout", name: "feat/workspace-sot", status: "running", - activityAt: new Date("2026-03-01T00:00:00.000Z"), diffStat: null, }; diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index ee6d649bb..c724bdc15 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -13,7 +13,6 @@ function createWorkspace( workspaceKind: input.workspaceKind ?? "local_checkout", name: input.name ?? "main", status: input.status ?? "done", - activityAt: input.activityAt ?? null, diffStat: input.diffStat ?? null, }; } diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index cc31a8a9f..086f11a75 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -120,14 +120,12 @@ export interface WorkspaceDescriptor { workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; name: string; status: WorkspaceDescriptorPayload["status"]; - activityAt: Date | null; diffStat: { additions: number; deletions: number } | null; } export function normalizeWorkspaceDescriptor( payload: WorkspaceDescriptorPayload, ): WorkspaceDescriptor { - const activityAt = payload.activityAt ? new Date(payload.activityAt) : null; return { id: normalizeWorkspaceIdentity(payload.id) ?? payload.id, projectId: payload.projectId, @@ -137,7 +135,6 @@ export function normalizeWorkspaceDescriptor( workspaceKind: payload.workspaceKind, name: payload.name, status: payload.status, - activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null, diffStat: payload.diffStat ?? null, }; } diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 9bc7538d3..b2c2a0055 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -15,7 +15,6 @@ function workspace(overrides: Partial = {}): SidebarWorks workspaceId: "/repo", workspaceKind: "directory", name: "paseo", - activityAt: null, statusBucket: "done", diffStat: null, ...overrides, @@ -31,7 +30,6 @@ function project(overrides: Partial = {}): SidebarProjectEn statusBucket: "done", activeCount: 0, totalWorkspaces: 1, - latestActivityAt: null, workspaces: [workspace()], ...overrides, }; diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 355cfa509..ac092fd9a 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -13,7 +13,6 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry { workspaceId: cwd, workspaceKind: "local_checkout", name: cwd, - activityAt: null, statusBucket: "done", diffStat: null, }; @@ -28,7 +27,6 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb statusBucket: "done", activeCount: 0, totalWorkspaces: workspaces.length, - latestActivityAt: null, workspaces, }; } diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index eeaba5bca..e171d79cb 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -17,7 +17,6 @@ function workspace( workspaceKind: input.workspaceKind ?? "worktree", name: input.name ?? input.id, status: input.status ?? "done", - activityAt: input.activityAt ?? null, diffStat: input.diffStat ?? null, }; } diff --git a/packages/server/package.json b/packages/server/package.json index 6ab7895e4..473acbf4d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -76,6 +76,7 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "express-basic-auth": "^1.2.1", + "fast-deep-equal": "^3.1.3", "fast-uri": "^3.1.0", "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.11", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f73051c65..c5da9e0a2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,4 @@ +import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; import { stat } from "fs/promises"; import { exec, execFile } from "node:child_process"; @@ -323,6 +324,7 @@ type WorkspaceUpdatesSubscriptionState = { filter?: WorkspaceUpdatesFilter; isBootstrapping: boolean; pendingUpdatesByWorkspaceId: Map; + lastEmittedByWorkspaceId: Map; }; type FetchWorkspacesCursor = { sort: FetchWorkspacesRequestSort[]; @@ -5301,25 +5303,6 @@ export class Session { return "done"; } - private accumulateLatestActivityAt( - current: string | null, - agent: AgentSnapshotPayload, - ): string | null { - const candidateRaw = agent.lastUserMessageAt ?? agent.updatedAt; - const candidateMs = Date.parse(candidateRaw); - if (Number.isNaN(candidateMs)) { - return current; - } - if (!current) { - return new Date(candidateMs).toISOString(); - } - const currentMs = Date.parse(current); - if (Number.isNaN(currentMs) || candidateMs > currentMs) { - return new Date(candidateMs).toISOString(); - } - return current; - } - private async describeWorkspaceRecord( workspace: PersistedWorkspaceRecord, projectRecord?: PersistedProjectRecord | null, @@ -5464,7 +5447,6 @@ export class Session { if (this.workspaceStatePriority[bucket] < this.workspaceStatePriority[existing.status]) { existing.status = bucket; } - existing.activityAt = this.accumulateLatestActivityAt(existing.activityAt, agent); } return descriptorsByWorkspaceId; @@ -5746,6 +5728,8 @@ export class Session { subscription.pendingUpdatesByWorkspaceId.set(workspaceId, payload); return; } + const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id; + subscription.lastEmittedByWorkspaceId.set(workspaceId, payload); this.emit({ type: "workspace_update", payload, @@ -5878,6 +5862,7 @@ export class Session { : null; if (!nextWorkspace) { + subscription.lastEmittedByWorkspaceId.delete(workspaceId); this.bufferOrEmitWorkspaceUpdate(subscription, { kind: "remove", id: workspaceId, @@ -5885,10 +5870,17 @@ export class Session { continue; } - this.bufferOrEmitWorkspaceUpdate(subscription, { + const nextPayload: WorkspaceUpdatePayload = { kind: "upsert", workspace: nextWorkspace, - }); + }; + + const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId); + if (lastEmitted && lastEmitted.kind === "upsert" && equal(lastEmitted.workspace, nextWorkspace)) { + continue; + } + + this.bufferOrEmitWorkspaceUpdate(subscription, nextPayload); } if (!options?.skipReconcile) { @@ -6001,6 +5993,7 @@ export class Session { filter: request.filter, isBootstrapping: true, pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), }; }