From 731313474edc507932a6e246607283a66f520366 Mon Sep 17 00:00:00 2001 From: vardy Date: Sun, 5 Jul 2026 12:52:02 -0700 Subject: [PATCH 1/3] feat(workspace): version history timeline backed by kernel events The workspace root menu's Version history action was a disabled stub, and kernel_events rows were only consumed for realtime cache sync. This turns the stub into a paginated, inspection-only history dialog. - WorkspaceKernelEventBus.listEventsPage: additive, read-only reverse paging over kernel_events (revision cursor, limit clamped to 100, lookahead row so the cursor is only advertised when a next page exists); malformed payload rows degrade to a generic record. - getWorkspaceHistoryPageFn server function, gated by the same assertCanReadWorkspace check as workspace page loads. - Pure event-to-timeline mapping in model/workspace-history.ts with burst grouping for consecutive content edits; unknown event types render as a generic change row. - WorkspaceVersionHistoryDialog with infinite query, actor names from the members query, and loading/empty/error states. Inspection-only v1: event payloads carry post-change summaries only, so previous content cannot be reconstructed from the log. getPage() and realtime sync are untouched; history loads only on demand. Closes #566 Co-Authored-By: Claude Fable 5 --- .../components/WorkspaceContextBar.tsx | 15 +- .../WorkspaceVersionHistoryDialog.tsx | 207 +++++++++++++++ .../kernel/workspace-kernel-access.ts | 24 ++ .../kernel/workspace-kernel-events.test.ts | 120 +++++++++ .../kernel/workspace-kernel-events.ts | 65 ++++- .../kernel/workspace-kernel-types.ts | 5 + .../workspaces/kernel/workspace-kernel.ts | 6 + .../model/workspace-history.test.ts | 238 ++++++++++++++++++ .../workspaces/model/workspace-history.ts | 236 +++++++++++++++++ src/features/workspaces/server/functions.ts | 12 + src/features/workspaces/server/queries.ts | 21 +- 11 files changed, 945 insertions(+), 4 deletions(-) create mode 100644 src/features/workspaces/components/WorkspaceVersionHistoryDialog.tsx create mode 100644 src/features/workspaces/kernel/workspace-kernel-events.test.ts create mode 100644 src/features/workspaces/model/workspace-history.test.ts create mode 100644 src/features/workspaces/model/workspace-history.ts diff --git a/src/features/workspaces/components/WorkspaceContextBar.tsx b/src/features/workspaces/components/WorkspaceContextBar.tsx index 168092d6..77bf836b 100644 --- a/src/features/workspaces/components/WorkspaceContextBar.tsx +++ b/src/features/workspaces/components/WorkspaceContextBar.tsx @@ -28,6 +28,7 @@ import WorkspaceMobileBreadcrumbOverflow from "#/features/workspaces/components/ import { WorkspaceSearchDialog } from "#/features/workspaces/components/WorkspaceSearchDialog"; import WorkspaceSettingsDialog from "#/features/workspaces/components/WorkspaceSettingsDialog"; import { WorkspaceShareDialog } from "#/features/workspaces/components/WorkspaceShareDialog"; +import { WorkspaceVersionHistoryDialog } from "#/features/workspaces/components/WorkspaceVersionHistoryDialog"; import { WorkspaceToolbarGroup } from "#/features/workspaces/components/WorkspaceToolbar"; import { renderWorkspaceMenuActions, @@ -74,6 +75,7 @@ export default function WorkspaceContextBar({ const [searchOpen, setSearchOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); const breadcrumbs = getWorkspaceBreadcrumbItems(activeItem, itemsById); const mobileOverflowBreadcrumbs = breadcrumbs.slice(0, -1); const createParentId = getWorkspaceBrowseParentId(activeItem); @@ -117,6 +119,7 @@ export default function WorkspaceContextBar({ ) : ( setHistoryOpen(true)} onOpenSettings={() => setSettingsOpen(true)} onOpenShare={() => setShareOpen(true)} trigger={ @@ -215,17 +218,24 @@ export default function WorkspaceContextBar({ workspaceId={workspace.id} workspaceName={workspace.name} /> + ); } function WorkspaceRootActionsMenu({ capabilities, + onOpenHistory, onOpenSettings, onOpenShare, trigger, }: { capabilities: ReturnType["capabilities"]; + onOpenHistory: () => void; onOpenSettings: () => void; onOpenShare: () => void; trigger: ReactElement; @@ -237,6 +247,7 @@ function WorkspaceRootActionsMenu({ {renderWorkspaceMenuActions( getWorkspaceRootMenuActions({ canOpenSettings: capabilities.canMutateContent, + onOpenHistory, onOpenSettings, onOpenShare, }), @@ -249,6 +260,7 @@ function WorkspaceRootActionsMenu({ function getWorkspaceRootMenuActions(input: { canOpenSettings: boolean; + onOpenHistory: () => void; onOpenSettings: () => void; onOpenShare: () => void; }): WorkspaceMenuAction[] { @@ -265,8 +277,7 @@ function getWorkspaceRootMenuActions(input: { id: "version-history", label: "Version history", leading: , - trailing: "Soon", - disabled: true, + onSelect: input.onOpenHistory, }, { kind: "item", diff --git a/src/features/workspaces/components/WorkspaceVersionHistoryDialog.tsx b/src/features/workspaces/components/WorkspaceVersionHistoryDialog.tsx new file mode 100644 index 00000000..b2e7026d --- /dev/null +++ b/src/features/workspaces/components/WorkspaceVersionHistoryDialog.tsx @@ -0,0 +1,207 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { + CircleDot, + FolderInput, + Loader2, + Palette, + PenLine, + Pencil, + Plus, + Trash2, +} from "lucide-react"; +import type { ComponentType } from "react"; + +import { Button } from "#/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "#/components/ui/dialog"; +import { getWorkspaceMembersQueryOptions } from "#/features/workspaces/components/workspace-share-queries"; +import { formatWorkspaceRecency } from "#/features/workspaces/model/display"; +import { + mapWorkspaceHistoryEvents, + type WorkspaceHistoryEntry, + type WorkspaceHistoryEntryKind, +} from "#/features/workspaces/model/workspace-history"; +import { getWorkspaceHistoryPageFn } from "#/features/workspaces/server/functions"; + +const historyKindIcons: Record> = { + created: Plus, + renamed: PenLine, + moved: FolderInput, + color: Palette, + edited: Pencil, + deleted: Trash2, + change: CircleDot, +}; + +interface WorkspaceVersionHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string; +} + +export function WorkspaceVersionHistoryDialog({ + open, + onOpenChange, + workspaceId, +}: WorkspaceVersionHistoryDialogProps) { + const historyQuery = useWorkspaceHistoryQuery(workspaceId, open); + const membersQuery = useQuery({ + ...getWorkspaceMembersQueryOptions(workspaceId), + enabled: open, + }); + const actorNamesById = new Map( + (membersQuery.data ?? []).map((member) => [member.userId, member.name]), + ); + const entries = mapWorkspaceHistoryEvents( + (historyQuery.data?.pages ?? []).flatMap((page) => page.events), + ); + + return ( + + + + Version history + + Every change in this workspace, newest first. History is inspection-only for now — + restoring earlier versions isn't supported yet. + + + +
+ resolveActorName(actorUserId, actorNamesById)} + historyQuery={historyQuery} + /> +
+
+
+ ); +} + +function useWorkspaceHistoryQuery(workspaceId: string, open: boolean) { + return useInfiniteQuery({ + queryKey: ["workspace-history", workspaceId], + queryFn: ({ pageParam }) => + getWorkspaceHistoryPageFn({ + data: { + workspaceId, + beforeRevision: pageParam, + }, + }), + initialPageParam: undefined as number | undefined, + getNextPageParam: (lastPage) => lastPage.nextBeforeRevision ?? undefined, + enabled: open, + }); +} + +function resolveActorName(actorUserId: string | null, actorNamesById: Map) { + if (actorUserId === null) { + return "Someone"; + } + + return actorNamesById.get(actorUserId) ?? "Former member"; +} + +function WorkspaceVersionHistoryList({ + entries, + getActorName, + historyQuery, +}: { + entries: WorkspaceHistoryEntry[]; + getActorName: (actorUserId: string | null) => string; + historyQuery: ReturnType; +}) { + if (historyQuery.isPending) { + return ( +
+ + Loading history… +
+ ); + } + + if (historyQuery.isError) { + return ( +
+ Could not load version history. + +
+ ); + } + + if (entries.length === 0) { + return ( +
+ No changes yet. Edits to this workspace will show up here. +
+ ); + } + + return ( +
    + {entries.map((entry) => ( + + ))} + {historyQuery.hasNextPage ? ( +
  • + +
  • + ) : null} +
+ ); +} + +function WorkspaceVersionHistoryRow({ + actorName, + entry, +}: { + actorName: string; + entry: WorkspaceHistoryEntry; +}) { + const KindIcon = historyKindIcons[entry.kind]; + + return ( +
  • + +
    +

    + {actorName} {entry.summary} +

    +
    + +
  • + ); +} diff --git a/src/features/workspaces/kernel/workspace-kernel-access.ts b/src/features/workspaces/kernel/workspace-kernel-access.ts index ac42d864..f32caa6d 100644 --- a/src/features/workspaces/kernel/workspace-kernel-access.ts +++ b/src/features/workspaces/kernel/workspace-kernel-access.ts @@ -20,6 +20,7 @@ import type { CreateWorkspaceKernelFileFromUploadArgs, CreateWorkspaceKernelRelationArgs, DeleteWorkspaceKernelItemsResult, + ListWorkspaceKernelHistoryArgs, ListWorkspaceKernelItemRelationsArgs, MoveWorkspaceKernelItemsResult, ReadWorkspaceKernelFilePreviewResult, @@ -30,6 +31,7 @@ import type { WorkspaceKernelNameConflictPolicy, } from "#/features/workspaces/kernel/workspace-kernel-types"; import type { WorkspaceFileAssetKind } from "#/features/workspaces/model/workspace-file"; +import type { WorkspaceHistoryPage } from "#/features/workspaces/model/workspace-history"; import type { WorkspaceCommandResult } from "#/features/workspaces/realtime/messages"; import { assertCanMutateWorkspace, @@ -123,6 +125,7 @@ export interface WorkspaceKernelClient { actorUserId?: string | null; clientMutationId?: string | null; }): Promise>; + listEventsPage(input: ListWorkspaceKernelHistoryArgs): Promise; purgeForDeletion(): Promise; } @@ -182,6 +185,27 @@ export async function getWorkspaceKernelPage(input: { } } +export async function getWorkspaceKernelHistoryPage(input: { + workspaceId: string; + userId: string; + beforeRevision?: number; + limit?: number; +}): Promise { + const dbContext = await createDbContext(); + + try { + await assertCanReadWorkspace(dbContext.db, input); + const kernel = await getWorkspaceKernel(input.workspaceId); + + return await kernel.listEventsPage({ + beforeRevision: input.beforeRevision, + limit: input.limit, + }); + } finally { + await dbContext.dispose(); + } +} + export async function listWorkspaceKernelItems({ workspaceId, userId, diff --git a/src/features/workspaces/kernel/workspace-kernel-events.test.ts b/src/features/workspaces/kernel/workspace-kernel-events.test.ts new file mode 100644 index 00000000..0932ea95 --- /dev/null +++ b/src/features/workspaces/kernel/workspace-kernel-events.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; + +import { WorkspaceKernelEventBus } from "#/features/workspaces/kernel/workspace-kernel-events"; +import type { KernelEventRow } from "#/features/workspaces/kernel/workspace-kernel-rows"; +import type { WorkspaceKernelSql } from "#/features/workspaces/kernel/workspace-kernel-schema"; + +function eventRow(revision: number, overrides: Partial = {}): KernelEventRow { + return { + id: `event-${revision}`, + revision, + type: "workspace.item.created", + actor_user_id: "user-1", + client_mutation_id: null, + payload_json: JSON.stringify({ + item: { id: "item-1", name: "Notes", type: "document" }, + }), + created_at: 1_780_000_000_000 + revision, + ...overrides, + }; +} + +function createEventBus(rows: KernelEventRow[]) { + const queries: Array<{ sql: string; values: unknown[] }> = []; + const sql: WorkspaceKernelSql = (strings, ...values) => { + queries.push({ sql: strings.join("?"), values }); + return rows as never[]; + }; + const bus = new WorkspaceKernelEventBus({ + sql, + workspaceId: () => "workspace-1", + getNextRevision: () => { + throw new Error("History reads must not advance the revision."); + }, + broadcast: () => { + throw new Error("History reads must not broadcast."); + }, + }); + + return { bus, queries }; +} + +describe("WorkspaceKernelEventBus.listEventsPage", () => { + it("reads newest first with a lookahead row and no cursor filter by default", () => { + const { bus, queries } = createEventBus([eventRow(3), eventRow(2), eventRow(1)]); + const page = bus.listEventsPage({}); + + expect(queries).toHaveLength(1); + expect(queries[0].sql).toContain("ORDER BY revision DESC"); + expect(queries[0].sql).not.toContain("WHERE"); + expect(queries[0].values).toEqual([51]); + expect(page.events.map((event) => event.revision)).toEqual([3, 2, 1]); + expect(page.nextBeforeRevision).toBeNull(); + }); + + it("filters below the cursor when one is provided", () => { + const { bus, queries } = createEventBus([eventRow(2), eventRow(1)]); + bus.listEventsPage({ beforeRevision: 3, limit: 10 }); + + expect(queries[0].sql).toContain("WHERE revision <"); + expect(queries[0].values).toEqual([3, 11]); + }); + + it("clamps the limit between 1 and 100", () => { + const { bus: overBus, queries: overQueries } = createEventBus([]); + overBus.listEventsPage({ limit: 5000 }); + expect(overQueries[0].values).toEqual([101]); + + const { bus: underBus, queries: underQueries } = createEventBus([]); + underBus.listEventsPage({ limit: 0 }); + expect(underQueries[0].values).toEqual([2]); + }); + + it("clamps a negative cursor to zero", () => { + const { bus, queries } = createEventBus([]); + bus.listEventsPage({ beforeRevision: -5 }); + + expect(queries[0].values[0]).toBe(0); + }); + + it("advertises the next cursor only when the lookahead row exists", () => { + const { bus } = createEventBus([eventRow(5), eventRow(4), eventRow(3)]); + const page = bus.listEventsPage({ limit: 2 }); + + expect(page.events.map((event) => event.revision)).toEqual([5, 4]); + expect(page.nextBeforeRevision).toBe(4); + + const { bus: lastBus } = createEventBus([eventRow(2), eventRow(1)]); + const lastPage = lastBus.listEventsPage({ limit: 2 }); + + expect(lastPage.events.map((event) => event.revision)).toEqual([2, 1]); + expect(lastPage.nextBeforeRevision).toBeNull(); + }); + + it("maps rows through the shared event mapper", () => { + const { bus } = createEventBus([eventRow(7)]); + const [event] = bus.listEventsPage({}).events; + + expect(event).toMatchObject({ + id: "event-7", + revision: 7, + workspaceId: "workspace-1", + type: "workspace.item.created", + actorUserId: "user-1", + payload: { item: { id: "item-1", name: "Notes", type: "document" } }, + }); + expect(event.createdAt).toBe(new Date(1_780_000_000_007).toISOString()); + }); + + it("degrades malformed payload rows instead of failing the page", () => { + const { bus } = createEventBus([eventRow(9), eventRow(8, { payload_json: "{not json" })]); + const page = bus.listEventsPage({}); + + expect(page.events).toHaveLength(2); + expect(page.events[1]).toMatchObject({ + id: "event-8", + revision: 8, + payload: null, + }); + }); +}); diff --git a/src/features/workspaces/kernel/workspace-kernel-events.ts b/src/features/workspaces/kernel/workspace-kernel-events.ts index a4d68bb3..aad5b66c 100644 --- a/src/features/workspaces/kernel/workspace-kernel-events.ts +++ b/src/features/workspaces/kernel/workspace-kernel-events.ts @@ -1,7 +1,18 @@ import type { KernelEventRow } from "#/features/workspaces/kernel/workspace-kernel-rows"; import { mapKernelEventRow } from "#/features/workspaces/kernel/workspace-kernel-rows"; import type { WorkspaceKernelSql } from "#/features/workspaces/kernel/workspace-kernel-schema"; -import type { ListWorkspaceKernelEventsArgs } from "#/features/workspaces/kernel/workspace-kernel-types"; +import type { + ListWorkspaceKernelEventsArgs, + ListWorkspaceKernelHistoryArgs, +} from "#/features/workspaces/kernel/workspace-kernel-types"; +import type { + WorkspaceHistoryEvent, + WorkspaceHistoryPage, +} from "#/features/workspaces/model/workspace-history"; +import { + workspaceHistoryDefaultPageSize, + workspaceHistoryMaxPageSize, +} from "#/features/workspaces/model/workspace-history"; import type { WorkspaceRealtimeEvent, WorkspaceRealtimeServerMessage, @@ -40,6 +51,58 @@ export class WorkspaceKernelEventBus { return rows.map((row) => mapKernelEventRow(row, this.workspaceId())); } + listEventsPage({ + beforeRevision, + limit = workspaceHistoryDefaultPageSize, + }: ListWorkspaceKernelHistoryArgs): WorkspaceHistoryPage { + const pageSize = Math.max(1, Math.min(limit, workspaceHistoryMaxPageSize)); + // Fetch one extra row so the cursor is only advertised when a next page exists. + const lookahead = pageSize + 1; + const rows = + beforeRevision === undefined + ? this.sql` + SELECT * + FROM kernel_events + ORDER BY revision DESC + LIMIT ${lookahead} + ` + : this.sql` + SELECT * + FROM kernel_events + WHERE revision < ${Math.max(0, beforeRevision)} + ORDER BY revision DESC + LIMIT ${lookahead} + `; + const events = rows.slice(0, pageSize).map((row) => this.mapHistoryRow(row)); + + return { + events, + nextBeforeRevision: rows.length > pageSize ? events[events.length - 1].revision : null, + }; + } + + private mapHistoryRow(row: KernelEventRow): WorkspaceHistoryEvent { + try { + const event = mapKernelEventRow(row, this.workspaceId()); + + // The payload came from JSON.parse, so it is JSON by construction. + return { ...event, payload: event.payload as unknown as WorkspaceHistoryEvent["payload"] }; + } catch { + // Malformed payloads must never break the history view; realtime sync + // keeps its strict parsing. + return { + id: row.id, + revision: row.revision, + workspaceId: this.workspaceId(), + type: row.type, + actorUserId: row.actor_user_id, + clientMutationId: row.client_mutation_id, + createdAt: new Date(row.created_at).toISOString(), + payload: null, + }; + } + } + commit(input: Omit) { const createdAt = Date.now(); const event = { diff --git a/src/features/workspaces/kernel/workspace-kernel-types.ts b/src/features/workspaces/kernel/workspace-kernel-types.ts index 26a7c1f6..43b4a1f5 100644 --- a/src/features/workspaces/kernel/workspace-kernel-types.ts +++ b/src/features/workspaces/kernel/workspace-kernel-types.ts @@ -190,3 +190,8 @@ export interface ListWorkspaceKernelEventsArgs { afterRevision: number; limit?: number; } + +export interface ListWorkspaceKernelHistoryArgs { + beforeRevision?: number; + limit?: number; +} diff --git a/src/features/workspaces/kernel/workspace-kernel.ts b/src/features/workspaces/kernel/workspace-kernel.ts index cc45e3ef..63e85eda 100644 --- a/src/features/workspaces/kernel/workspace-kernel.ts +++ b/src/features/workspaces/kernel/workspace-kernel.ts @@ -24,6 +24,7 @@ import type { DeleteWorkspaceKernelItemsArgs, DeleteWorkspaceKernelItemsResult, ListWorkspaceKernelEventsArgs, + ListWorkspaceKernelHistoryArgs, ListWorkspaceKernelItemRelationsArgs, ListWorkspaceKernelItemsArgs, MoveWorkspaceKernelItemsArgs, @@ -37,6 +38,7 @@ import type { WorkspaceKernelPage, WriteWorkspaceKernelItemArgs, } from "#/features/workspaces/kernel/workspace-kernel-types"; +import type { WorkspaceHistoryPage } from "#/features/workspaces/model/workspace-history"; import type { WorkspaceCommandResult, WorkspaceConnectionState, @@ -202,6 +204,10 @@ export class WorkspaceKernel extends Agent { return this.events.getEventsSince({ afterRevision, limit }); } + async listEventsPage(input: ListWorkspaceKernelHistoryArgs = {}): Promise { + return this.events.listEventsPage(input); + } + async purgeForDeletion(): Promise { const workspaceId = this.name; const documentItemIds = this.store.getAllDocumentItemIds(); diff --git a/src/features/workspaces/model/workspace-history.test.ts b/src/features/workspaces/model/workspace-history.test.ts new file mode 100644 index 00000000..fef53d88 --- /dev/null +++ b/src/features/workspaces/model/workspace-history.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; + +import type { JsonValue } from "#/features/workspaces/contracts"; +import { + mapWorkspaceHistoryEvents, + type WorkspaceHistoryEvent, +} from "#/features/workspaces/model/workspace-history"; + +let nextRevision = 1000; + +function historyEvent(input: { + type: string; + payload?: JsonValue; + actorUserId?: string | null; + createdAt?: string; + revision?: number; +}): WorkspaceHistoryEvent { + const revision = input.revision ?? nextRevision--; + + return { + id: `event-${revision}`, + revision, + workspaceId: "workspace-1", + type: input.type, + actorUserId: input.actorUserId === undefined ? "user-1" : input.actorUserId, + clientMutationId: null, + createdAt: input.createdAt ?? "2026-07-04T12:00:00.000Z", + payload: input.payload ?? null, + }; +} + +function payloadItem(overrides: Partial<{ id: string; name: string; type: string }> = {}) { + return { + id: "item-1", + name: "Notes", + type: "document", + ...overrides, + }; +} + +describe("mapWorkspaceHistoryEvents", () => { + it("maps every known event type to readable copy", () => { + const entries = mapWorkspaceHistoryEvents([ + historyEvent({ + type: "workspace.item.created", + payload: { item: payloadItem({ type: "folder", name: "Research" }) }, + }), + historyEvent({ + type: "workspace.item.renamed", + payload: { item: payloadItem({ name: "Final notes" }) }, + }), + historyEvent({ + type: "workspace.item.moved", + payload: { item: payloadItem({ name: "Final notes" }) }, + }), + historyEvent({ + type: "workspace.item.color.updated", + payload: { item: payloadItem({ type: "folder", name: "Research" }) }, + }), + historyEvent({ + type: "workspace.item.content.updated", + payload: { item: payloadItem() }, + }), + historyEvent({ + type: "workspace.item.deleted", + payload: { itemIds: ["item-1"], deletedItemIds: ["item-1", "item-2"] }, + }), + ]); + + expect(entries.map((entry) => [entry.kind, entry.summary])).toEqual([ + ["created", "created folder “Research”"], + ["renamed", "renamed document to “Final notes”"], + ["moved", "moved “Final notes”"], + ["color", "changed the color of “Research”"], + ["edited", "edited “Notes”"], + ["deleted", "deleted an item"], + ]); + }); + + it("renders bulk moves as one grouped row", () => { + const entries = mapWorkspaceHistoryEvents([ + historyEvent({ + type: "workspace.items.moved", + payload: { + items: [ + payloadItem({ id: "item-1", name: "A" }), + payloadItem({ id: "item-2", name: "B" }), + payloadItem({ id: "item-3", name: "C" }), + ], + }, + }), + ]); + + expect(entries).toHaveLength(1); + expect(entries[0].summary).toBe("moved 3 items"); + }); + + it("renders a single-item bulk move like a plain move", () => { + const entries = mapWorkspaceHistoryEvents([ + historyEvent({ + type: "workspace.items.moved", + payload: { items: [payloadItem({ name: "A" })] }, + }), + ]); + + expect(entries[0].summary).toBe("moved “A”"); + }); + + it("counts deleted items from the root item ids", () => { + const entries = mapWorkspaceHistoryEvents([ + historyEvent({ + type: "workspace.item.deleted", + payload: { itemIds: ["item-1", "item-2"], deletedItemIds: ["item-1", "item-2", "item-3"] }, + }), + ]); + + expect(entries[0].summary).toBe("deleted 2 items"); + }); + + it("coalesces consecutive edits to the same item by the same actor", () => { + const entries = mapWorkspaceHistoryEvents([ + historyEvent({ + type: "workspace.item.content.updated", + payload: { item: payloadItem() }, + createdAt: "2026-07-04T12:04:00.000Z", + revision: 12, + }), + historyEvent({ + type: "workspace.item.content.updated", + payload: { item: payloadItem() }, + createdAt: "2026-07-04T12:02:00.000Z", + revision: 11, + }), + historyEvent({ + type: "workspace.item.content.updated", + payload: { item: payloadItem() }, + createdAt: "2026-07-04T12:00:00.000Z", + revision: 10, + }), + ]); + + expect(entries).toHaveLength(1); + expect(entries[0].summary).toBe("edited “Notes” (3 changes)"); + expect(entries[0].eventCount).toBe(3); + expect(entries[0].revision).toBe(12); + expect(entries[0].createdAt).toBe("2026-07-04T12:04:00.000Z"); + }); + + it("groups unattributed collaborative edits together", () => { + const entries = mapWorkspaceHistoryEvents([ + historyEvent({ + type: "workspace.item.content.updated", + payload: { item: payloadItem() }, + actorUserId: null, + createdAt: "2026-07-04T12:01:00.000Z", + }), + historyEvent({ + type: "workspace.item.content.updated", + payload: { item: payloadItem() }, + actorUserId: null, + createdAt: "2026-07-04T12:00:00.000Z", + }), + ]); + + expect(entries).toHaveLength(1); + expect(entries[0].actorUserId).toBeNull(); + }); + + it("splits edit bursts across actors, items, gaps, and other events", () => { + const editAt = (createdAt: string, actorUserId: string | null = "user-1") => + historyEvent({ + type: "workspace.item.content.updated", + payload: { item: payloadItem() }, + createdAt, + actorUserId, + }); + + const differentActor = mapWorkspaceHistoryEvents([ + editAt("2026-07-04T12:01:00.000Z"), + editAt("2026-07-04T12:00:00.000Z", "user-2"), + ]); + expect(differentActor).toHaveLength(2); + + const differentItem = mapWorkspaceHistoryEvents([ + editAt("2026-07-04T12:01:00.000Z"), + historyEvent({ + type: "workspace.item.content.updated", + payload: { item: payloadItem({ id: "item-2", name: "Other" }) }, + createdAt: "2026-07-04T12:00:00.000Z", + }), + ]); + expect(differentItem).toHaveLength(2); + + const longGap = mapWorkspaceHistoryEvents([ + editAt("2026-07-04T12:10:00.000Z"), + editAt("2026-07-04T12:00:00.000Z"), + ]); + expect(longGap).toHaveLength(2); + + const interleaved = mapWorkspaceHistoryEvents([ + editAt("2026-07-04T12:02:00.000Z"), + historyEvent({ + type: "workspace.item.renamed", + payload: { item: payloadItem() }, + createdAt: "2026-07-04T12:01:00.000Z", + }), + editAt("2026-07-04T12:00:00.000Z"), + ]); + expect(interleaved).toHaveLength(3); + }); + + it("renders unknown event types as a generic change row", () => { + const entries = mapWorkspaceHistoryEvents([ + historyEvent({ type: "workspace.item.starred", payload: { anything: true } }), + ]); + + expect(entries[0].kind).toBe("change"); + expect(entries[0].summary).toBe("made a change"); + }); + + it("never throws on malformed payloads", () => { + const entries = mapWorkspaceHistoryEvents([ + historyEvent({ type: "workspace.item.created", payload: null }), + historyEvent({ type: "workspace.item.renamed", payload: { item: "not-an-object" } }), + historyEvent({ type: "workspace.items.moved", payload: { items: "nope" } }), + historyEvent({ type: "workspace.item.deleted", payload: {} }), + historyEvent({ type: "workspace.item.content.updated", payload: 42 }), + ]); + + expect(entries.map((entry) => entry.summary)).toEqual([ + "created an item", + "renamed an item", + "moved items", + "deleted items", + "edited an item", + ]); + }); +}); diff --git a/src/features/workspaces/model/workspace-history.ts b/src/features/workspaces/model/workspace-history.ts new file mode 100644 index 00000000..13fa2a76 --- /dev/null +++ b/src/features/workspaces/model/workspace-history.ts @@ -0,0 +1,236 @@ +import type { JsonValue } from "#/features/workspaces/contracts"; + +export const workspaceHistoryDefaultPageSize = 50; +export const workspaceHistoryMaxPageSize = 100; +export const workspaceHistoryBurstWindowMs = 5 * 60_000; + +export interface WorkspaceHistoryEvent { + id: string; + revision: number; + workspaceId: string; + type: string; + actorUserId: string | null; + clientMutationId: string | null; + createdAt: string; + // JsonValue rather than unknown so the type survives server-function + // serialization; consumers still treat it as untrusted. + payload: JsonValue; +} + +export interface WorkspaceHistoryPage { + events: WorkspaceHistoryEvent[]; + nextBeforeRevision: number | null; +} + +export type WorkspaceHistoryEntryKind = + | "created" + | "renamed" + | "moved" + | "color" + | "edited" + | "deleted" + | "change"; + +export interface WorkspaceHistoryEntry { + id: string; + revision: number; + createdAt: string; + actorUserId: string | null; + kind: WorkspaceHistoryEntryKind; + summary: string; + eventCount: number; +} + +interface WorkspaceHistoryPayloadItem { + id: string; + name: string; + type?: string | null; +} + +/** + * Maps newest-first kernel event records into readable timeline entries, + * coalescing bursts of consecutive content edits to the same item by the + * same actor. Unknown event types and malformed payloads degrade to a + * generic "made a change" entry instead of failing the timeline. + */ +export function mapWorkspaceHistoryEvents( + events: readonly WorkspaceHistoryEvent[], + options: { burstWindowMs?: number } = {}, +): WorkspaceHistoryEntry[] { + const burstWindowMs = options.burstWindowMs ?? workspaceHistoryBurstWindowMs; + const groups: WorkspaceHistoryEvent[][] = []; + + for (const event of events) { + const group = groups[groups.length - 1]; + + if (group && extendsEditBurst(group[group.length - 1], event, burstWindowMs)) { + group.push(event); + continue; + } + + groups.push([event]); + } + + return groups.map(mapWorkspaceHistoryEventGroup); +} + +function extendsEditBurst( + previous: WorkspaceHistoryEvent, + event: WorkspaceHistoryEvent, + burstWindowMs: number, +) { + if ( + previous.type !== "workspace.item.content.updated" || + event.type !== "workspace.item.content.updated" || + event.actorUserId !== previous.actorUserId + ) { + return false; + } + + const previousItem = getPayloadItem(previous.payload); + const item = getPayloadItem(event.payload); + + if (!previousItem || !item || previousItem.id !== item.id) { + return false; + } + + const gapMs = Date.parse(previous.createdAt) - Date.parse(event.createdAt); + + return Number.isFinite(gapMs) && gapMs <= burstWindowMs; +} + +function mapWorkspaceHistoryEventGroup(group: WorkspaceHistoryEvent[]): WorkspaceHistoryEntry { + const newest = group[0]; + const { kind, summary } = describeWorkspaceHistoryEvent(newest); + + return { + id: newest.id, + revision: newest.revision, + createdAt: newest.createdAt, + actorUserId: newest.actorUserId, + kind, + summary: group.length > 1 ? `${summary} (${group.length} changes)` : summary, + eventCount: group.length, + }; +} + +function describeWorkspaceHistoryEvent(event: WorkspaceHistoryEvent): { + kind: WorkspaceHistoryEntryKind; + summary: string; +} { + switch (event.type) { + case "workspace.item.created": { + const item = getPayloadItem(event.payload); + return { + kind: "created", + summary: item ? `created ${getItemTypeWord(item)} “${item.name}”` : "created an item", + }; + } + case "workspace.item.renamed": { + const item = getPayloadItem(event.payload); + return { + kind: "renamed", + summary: item ? `renamed ${getItemTypeWord(item)} to “${item.name}”` : "renamed an item", + }; + } + case "workspace.item.moved": { + const item = getPayloadItem(event.payload); + return { + kind: "moved", + summary: item ? `moved “${item.name}”` : "moved an item", + }; + } + case "workspace.items.moved": { + const items = getPayloadItems(event.payload); + + if (items.length === 1) { + return { kind: "moved", summary: `moved “${items[0].name}”` }; + } + + return { + kind: "moved", + summary: items.length > 0 ? `moved ${items.length} items` : "moved items", + }; + } + case "workspace.item.color.updated": { + const item = getPayloadItem(event.payload); + return { + kind: "color", + summary: item ? `changed the color of “${item.name}”` : "changed an item's color", + }; + } + case "workspace.item.content.updated": { + const item = getPayloadItem(event.payload); + return { + kind: "edited", + summary: item ? `edited “${item.name}”` : "edited an item", + }; + } + case "workspace.item.deleted": { + const itemIds = getPayloadItemIds(event.payload); + + if (itemIds.length === 1) { + return { kind: "deleted", summary: "deleted an item" }; + } + + return { + kind: "deleted", + summary: itemIds.length > 0 ? `deleted ${itemIds.length} items` : "deleted items", + }; + } + default: + return { kind: "change", summary: "made a change" }; + } +} + +function getItemTypeWord(item: WorkspaceHistoryPayloadItem) { + switch (item.type) { + case "folder": + return "folder"; + case "document": + return "document"; + case "file": + return "file"; + case "flashcard": + return "flashcards"; + case "quiz": + return "quiz"; + default: + return "item"; + } +} + +function getPayloadItem(payload: unknown): WorkspaceHistoryPayloadItem | null { + const item = (payload as { item?: unknown } | null)?.item; + + return isItemLike(item) ? item : null; +} + +function getPayloadItems(payload: unknown): WorkspaceHistoryPayloadItem[] { + const items = (payload as { items?: unknown } | null)?.items; + + if (!Array.isArray(items)) { + return []; + } + + return items.filter(isItemLike); +} + +function getPayloadItemIds(payload: unknown) { + const itemIds = (payload as { itemIds?: unknown } | null)?.itemIds; + + if (!Array.isArray(itemIds)) { + return []; + } + + return itemIds.filter((itemId): itemId is string => typeof itemId === "string"); +} + +function isItemLike(value: unknown): value is WorkspaceHistoryPayloadItem { + return ( + typeof value === "object" && + value !== null && + typeof (value as { id?: unknown }).id === "string" && + typeof (value as { name?: unknown }).name === "string" + ); +} diff --git a/src/features/workspaces/server/functions.ts b/src/features/workspaces/server/functions.ts index 1be1d883..e1e86b37 100644 --- a/src/features/workspaces/server/functions.ts +++ b/src/features/workspaces/server/functions.ts @@ -24,8 +24,10 @@ import { recordWorkspaceOpenedForCurrentUser, updateWorkspaceForCurrentUser, } from "#/features/workspaces/server/mutations"; +import { workspaceHistoryMaxPageSize } from "#/features/workspaces/model/workspace-history"; import { getCurrentUserId } from "#/features/workspaces/server/permissions"; import { + getWorkspaceHistoryPageForCurrentUser, getWorkspacePageForCurrentUser, listWorkspacesForCurrentUser, } from "#/features/workspaces/server/queries"; @@ -34,6 +36,12 @@ const workspaceIdInputSchema = z.object({ workspaceId: z.string().min(1), }); +const workspaceHistoryPageInputSchema = z.object({ + workspaceId: z.string().min(1), + beforeRevision: z.number().int().positive().optional(), + limit: z.number().int().min(1).max(workspaceHistoryMaxPageSize).optional(), +}); + export const listWorkspacesFn = createServerFn({ method: "GET" }).handler(async () => listWorkspacesForCurrentUser(), ); @@ -42,6 +50,10 @@ export const getWorkspacePageFn = createServerFn({ method: "GET" }) .validator(workspaceIdInputSchema) .handler(async ({ data }) => getWorkspacePageForCurrentUser(data.workspaceId)); +export const getWorkspaceHistoryPageFn = createServerFn({ method: "GET" }) + .validator(workspaceHistoryPageInputSchema) + .handler(async ({ data }) => getWorkspaceHistoryPageForCurrentUser(data)); + export const createWorkspaceFn = createServerFn({ method: "POST" }) .validator(createWorkspaceInputSchema) .handler(async ({ data }) => createWorkspaceForCurrentUser(data)); diff --git a/src/features/workspaces/server/queries.ts b/src/features/workspaces/server/queries.ts index d0a8d436..ea99cdf5 100644 --- a/src/features/workspaces/server/queries.ts +++ b/src/features/workspaces/server/queries.ts @@ -3,7 +3,11 @@ import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"; import { workspaceMembers, workspaces } from "#/db/schema"; import { createDbContext } from "#/db/server"; import type { WorkspacePage, WorkspaceSummary } from "#/features/workspaces/contracts"; -import { getWorkspaceKernelPage } from "#/features/workspaces/kernel/workspace-kernel-access"; +import { + getWorkspaceKernelHistoryPage, + getWorkspaceKernelPage, +} from "#/features/workspaces/kernel/workspace-kernel-access"; +import type { WorkspaceHistoryPage } from "#/features/workspaces/model/workspace-history"; import { mapWorkspaceDetailRow, mapWorkspaceRow } from "#/features/workspaces/server/mappers"; import { getCurrentUserId } from "#/features/workspaces/server/permissions"; @@ -89,3 +93,18 @@ export async function getWorkspacePageForCurrentUser( await dbContext.dispose(); } } + +export async function getWorkspaceHistoryPageForCurrentUser(input: { + workspaceId: string; + beforeRevision?: number; + limit?: number; +}): Promise { + const userId = await getCurrentUserId(); + + return getWorkspaceKernelHistoryPage({ + workspaceId: input.workspaceId, + userId, + beforeRevision: input.beforeRevision, + limit: input.limit, + }); +} From 7586565ee8bd1ef457b8c6e99c37468658d38b82 Mon Sep 17 00:00:00 2001 From: vardy Date: Mon, 6 Jul 2026 11:47:22 -0700 Subject: [PATCH 2/3] fix(workspace): address version history review feedback - Keep actor labels neutral ("Someone") until the member list has loaded so a slow or failed members query cannot mislabel current members as "Former member" in the timeline. - Memoize timeline entries and the actor-name map; the dialog is mounted alongside the context bar, so unrelated parent re-renders were repeating the burst-grouping pass. - Log a warning when a history event payload fails to map, matching the kernel's existing graceful-degradation pattern, instead of swallowing the error silently. Co-Authored-By: Claude Fable 5 --- .../WorkspaceVersionHistoryDialog.tsx | 22 ++++++++++++++----- .../kernel/workspace-kernel-events.ts | 9 +++++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/features/workspaces/components/WorkspaceVersionHistoryDialog.tsx b/src/features/workspaces/components/WorkspaceVersionHistoryDialog.tsx index b2e7026d..8d90cd35 100644 --- a/src/features/workspaces/components/WorkspaceVersionHistoryDialog.tsx +++ b/src/features/workspaces/components/WorkspaceVersionHistoryDialog.tsx @@ -9,7 +9,7 @@ import { Plus, Trash2, } from "lucide-react"; -import type { ComponentType } from "react"; +import { type ComponentType, useMemo } from "react"; import { Button } from "#/components/ui/button"; import { @@ -54,11 +54,15 @@ export function WorkspaceVersionHistoryDialog({ ...getWorkspaceMembersQueryOptions(workspaceId), enabled: open, }); - const actorNamesById = new Map( - (membersQuery.data ?? []).map((member) => [member.userId, member.name]), + const members = membersQuery.data; + const actorNamesById = useMemo( + () => (members ? new Map(members.map((member) => [member.userId, member.name])) : null), + [members], ); - const entries = mapWorkspaceHistoryEvents( - (historyQuery.data?.pages ?? []).flatMap((page) => page.events), + const historyPages = historyQuery.data?.pages; + const entries = useMemo( + () => mapWorkspaceHistoryEvents((historyPages ?? []).flatMap((page) => page.events)), + [historyPages], ); return ( @@ -100,11 +104,17 @@ function useWorkspaceHistoryQuery(workspaceId: string, open: boolean) { }); } -function resolveActorName(actorUserId: string | null, actorNamesById: Map) { +function resolveActorName(actorUserId: string | null, actorNamesById: Map | null) { if (actorUserId === null) { return "Someone"; } + // Until the member list has loaded, stay neutral instead of wrongly + // labeling current members as former ones. + if (!actorNamesById) { + return "Someone"; + } + return actorNamesById.get(actorUserId) ?? "Former member"; } diff --git a/src/features/workspaces/kernel/workspace-kernel-events.ts b/src/features/workspaces/kernel/workspace-kernel-events.ts index aad5b66c..852f88d9 100644 --- a/src/features/workspaces/kernel/workspace-kernel-events.ts +++ b/src/features/workspaces/kernel/workspace-kernel-events.ts @@ -87,9 +87,16 @@ export class WorkspaceKernelEventBus { // The payload came from JSON.parse, so it is JSON by construction. return { ...event, payload: event.payload as unknown as WorkspaceHistoryEvent["payload"] }; - } catch { + } catch (error) { // Malformed payloads must never break the history view; realtime sync // keeps its strict parsing. + console.warn("[WorkspaceKernel] Unable to map history event payload", { + eventId: row.id, + revision: row.revision, + type: row.type, + error, + }); + return { id: row.id, revision: row.revision, From 2ab1355f39c4b25325a4be3c665929aa60cea862 Mon Sep 17 00:00:00 2001 From: vardy Date: Mon, 6 Jul 2026 12:01:50 -0700 Subject: [PATCH 3/3] chore(ci): retrigger after runner disconnect Co-Authored-By: Claude Fable 5