Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/concepts/workspaces.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ Think of it as the place where the work happens: you keep the actual source mate

## Source of Truth

The app stores workspace access, the item tree, relationships, current document checkpoints, and extracted text in Postgres. Original and preview file bytes live in R2. The `WorkspaceKernel` Durable Object is a live room for presence, revision notifications, and cleanup coordination; it is not a second database. Clients refetch the authoritative workspace query when a newer revision is announced. UI and AI operations go through workspace commands rather than writing directly to scattered client state.
The app stores workspace access, the item tree, relationships, current document checkpoints, and extracted text in Postgres. Original and preview file bytes live in R2. The `WorkspaceKernel` Durable Object is a live room for presence, workspace-page deltas, and cleanup coordination; it is not a second database. Clients apply canonical item deltas and refetch the authoritative workspace query after reconnecting or when a full refresh is announced. UI and AI operations go through workspace commands rather than writing directly to scattered client state.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@
"@posthog/rollup-plugin": "^1.4.7",
"@streamdown/cjk": "^1.0.3",
"@streamdown/math": "^1.0.2",
"@tanstack/pacer": "^0.22.0",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.23",
Expand Down
12 changes: 0 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions src/features/workspaces/ai/ai-thread-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,17 @@ function getWorkspaceAiLanguageModelForGatewayModel(
function getWorkspaceAiGatewayTransportOptions() {
return {
caching: "auto" as const,
// Buy the fast lane where it exists. The gateway only forwards a tier to
// OpenAI, Google AI Studio, and Vertex, so this is a no-op on the Claude
// primaries and moves the models we actually default to (`auto`/luna, the
// Gemini pair, the nano/flash-lite title legs). It is a hint, never a
// promise: an unsupported model ignores it, and a provider that is out of
// priority capacity silently downgrades to standard and bills standard.
// Priority runs ~1.8-2x standard token price when it *is* granted, which
// the `cost` ladder in models.ts does not account for — read
// `service_tier` in PostHog before trusting those multipliers, since a
// missing value means we paid standard and got standard.
serviceTier: "priority" as const,
// Time-to-first-token budget before a BYOK leg is abandoned for the next
// provider — and eventually for Vercel's own credits. A flat 8s evicted
// healthy requests: 37% of gemini-3.1-pro steps and 29% of sonnet steps
Expand Down
87 changes: 87 additions & 0 deletions src/features/workspaces/cache-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";

import { workspacePageQueryKey } from "#/features/workspaces/cache-keys";
import { applyWorkspacePageDeltaToCache } from "#/features/workspaces/cache-page";
import type { WorkspaceItemSummary, WorkspacePage } from "#/features/workspaces/contracts";

describe("workspace page cache ordering", () => {
it("applies only the next revision", () => {
const queryClient = createQueryClient(createPage(3, createItem({ name: "Before" })));

applyWorkspacePageDeltaToCache(queryClient, {
type: "workspace.items.upserted",
workspaceId: "workspace-1",
revision: 4,
items: [createItem({ name: "After" })],
});

expect(readPage(queryClient)).toMatchObject({
items: [{ name: "After" }],
revision: 4,
});
});

it("ignores stale revisions", () => {
const queryClient = createQueryClient(createPage(3, createItem({ name: "Current" })));
const invalidate = vi.spyOn(queryClient, "invalidateQueries").mockResolvedValue();

applyWorkspacePageDeltaToCache(queryClient, {
type: "workspace.items.upserted",
workspaceId: "workspace-1",
revision: 2,
items: [createItem({ name: "Stale" })],
});

expect(readItem(queryClient)).toMatchObject({ name: "Current" });
expect(invalidate).not.toHaveBeenCalled();
});

it("keeps current cache data and reconciles a revision gap", () => {
const queryClient = createQueryClient(createPage(3, createItem({ name: "Newer" })));
const invalidate = vi.spyOn(queryClient, "invalidateQueries").mockResolvedValue();

applyWorkspacePageDeltaToCache(queryClient, {
type: "workspace.items.upserted",
workspaceId: "workspace-1",
revision: 5,
items: [createItem({ name: "Older" })],
});

expect(readItem(queryClient)).toMatchObject({ name: "Newer" });
expect(invalidate).toHaveBeenCalledWith({ queryKey: workspacePageQueryKey("workspace-1") });
});
});

function createQueryClient(page: WorkspacePage) {
const queryClient = new QueryClient();
queryClient.setQueryData(workspacePageQueryKey("workspace-1"), page);
return queryClient;
}

function readItem(queryClient: QueryClient) {
return readPage(queryClient)?.items[0];
}

function readPage(queryClient: QueryClient) {
return queryClient.getQueryData<WorkspacePage>(workspacePageQueryKey("workspace-1"));
}

function createPage(revision: number, item: WorkspaceItemSummary): WorkspacePage {
return { workspace: {} as WorkspacePage["workspace"], items: [item], revision };
}

function createItem(input: Partial<WorkspaceItemSummary> = {}): WorkspaceItemSummary {
return {
color: input.color ?? null,
createdAt: "2026-01-01T00:00:00.000Z",
id: "folder-1",
metadataJson: {},
name: input.name ?? "Folder",
parentId: null,
sortOrder: 1,
type: "folder",
updatedAt: "2026-01-01T00:00:00.000Z",
workspaceId: "workspace-1",
};
}
57 changes: 27 additions & 30 deletions src/features/workspaces/cache-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,40 @@ import { workspacePageQueryKey } from "#/features/workspaces/cache-keys";
import type {
CreateWorkspaceItemInput,
MoveWorkspaceItemsInput,
UpdateWorkspaceItemColorInput,
WorkspacePage,
} from "#/features/workspaces/contracts";
import {
createWorkspaceItemInPage,
moveWorkspaceItemsInPage,
removeWorkspaceItemsFromPage,
updateWorkspaceItemColorInPage,
upsertWorkspaceItemInPage,
} from "#/features/workspaces/model/workspace-page";
import type { WorkspacePageDelta } from "#/features/workspaces/realtime/messages";

export function applyWorkspacePageDeltaToCache(
queryClient: QueryClient,
change: WorkspacePageDelta,
) {
let shouldReconcile = false;
queryClient.setQueryData<WorkspacePage>(workspacePageQueryKey(change.workspaceId), (current) => {
if (!current) return current;
if (change.revision <= current.revision) return current;
if (change.revision !== current.revision + 1) {
shouldReconcile = true;
return current;
}
if (change.type === "workspace.items.deleted") {
return removeWorkspaceItemsFromPage(current, change.itemIds, change.revision);
}
return change.items.reduce(
(page, item) => upsertWorkspaceItemInPage(page, item, change.revision),
current,
);
});
if (shouldReconcile) {
void queryClient.invalidateQueries({ queryKey: workspacePageQueryKey(change.workspaceId) });
}
}

export function createWorkspaceItemInPageCache(
queryClient: QueryClient,
Expand Down Expand Up @@ -40,31 +65,3 @@ export function removeWorkspaceItemsFromPageCache(
current ? removeWorkspaceItemsFromPage(current, itemIds) : current,
);
}

export function updateWorkspaceItemColorInPageCache(
queryClient: QueryClient,
input: UpdateWorkspaceItemColorInput,
) {
queryClient.setQueryData<WorkspacePage>(workspacePageQueryKey(input.workspaceId), (current) => {
if (!current) {
return current;
}

const updateResult = updateWorkspaceItemColorInPage(current, input);

if (!updateResult) {
return current;
}

return updateResult;
});
}

export function getWorkspaceItemColorInPageCache(
queryClient: QueryClient,
input: Pick<UpdateWorkspaceItemColorInput, "itemId" | "workspaceId">,
) {
const page = queryClient.getQueryData<WorkspacePage>(workspacePageQueryKey(input.workspaceId));

return page?.items.find((item) => item.id === input.itemId)?.color ?? null;
}
2 changes: 0 additions & 2 deletions src/features/workspaces/cache-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,12 @@ export function setWorkspacePageCache(
input: {
workspace: WorkspaceSummary;
items: WorkspaceItemSummary[];
itemFacts: WorkspacePage["itemFacts"];
revision: number;
},
) {
queryClient.setQueryData<WorkspacePage>(workspacePageQueryKey(input.workspace.id), {
workspace: input.workspace,
items: input.items,
itemFacts: input.itemFacts,
revision: input.revision,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from "#/components/ui/alert-dialog";
import { useBillingState } from "#/features/account/use-billing-state";
import { showUpgradeDialog } from "#/features/account/upgrade-navigation";
import { workspacePageQueryKey } from "#/features/workspaces/cache";
import { applyWorkspacePageDeltaToCache } from "#/features/workspaces/cache";
import { useWorkspaceMutationAccess } from "#/features/workspaces/components/workspace-mutation-access";
import { runWorkspaceFileUploadBatch } from "#/features/workspaces/files/workspace-file-upload";
import { workspaceUploadAccept } from "#/features/workspaces/upload/workspace-upload-intake";
Expand Down Expand Up @@ -57,8 +57,13 @@ export function WorkspaceFileUploadProvider({
parentId,
files: fileList,
onLimitReached: setLimitResult,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: workspacePageQueryKey(workspaceId) });
onSuccess: (command) => {
applyWorkspacePageDeltaToCache(queryClient, {
type: "workspace.items.upserted",
workspaceId,
items: [command.result],
revision: command.revision,
});
},
});
};
Expand Down
12 changes: 6 additions & 6 deletions src/features/workspaces/components/WorkspaceItemActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function WorkspaceItemActionsMenuContent({
<WorkspaceItemColorSubmenu
item={item}
menuKind={menuKind}
readOnly={readOnly}
disabled={readOnly || updateWorkspaceItemColorMutation.isPending}
onUpdateItemColor={(color) =>
updateWorkspaceItemColorMutation.mutate({
workspaceId: item.workspaceId,
Expand Down Expand Up @@ -193,12 +193,12 @@ function WorkspaceItemRenameMenuItem({
function WorkspaceItemColorSubmenu({
item,
menuKind,
readOnly,
disabled,
onUpdateItemColor,
}: {
item: WorkspaceItem;
menuKind: "dropdown" | "context";
readOnly: boolean;
disabled: boolean;
onUpdateItemColor: (color: WorkspaceItemColor) => void;
}) {
const selectedColor = getWorkspaceItemColorValue(item.color);
Expand All @@ -210,14 +210,14 @@ function WorkspaceItemColorSubmenu({
onValueChange={onUpdateItemColor}
showLabels={false}
className="grid-flow-col grid-rows-4 gap-1.5"
disabled={readOnly}
disabled={disabled}
/>
);

if (menuKind === "context") {
return (
<ContextMenuSub>
<ContextMenuSubTrigger disabled={readOnly}>
<ContextMenuSubTrigger disabled={disabled}>
{workspaceItemColorSubmenuTrigger}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="max-w-[calc(100vw-2rem)] w-fit overflow-x-auto p-2">
Expand All @@ -229,7 +229,7 @@ function WorkspaceItemColorSubmenu({

return (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={readOnly}>
<DropdownMenuSubTrigger disabled={disabled}>
{workspaceItemColorSubmenuTrigger}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-w-[calc(100vw-2rem)] w-fit overflow-x-auto p-2">
Expand Down
19 changes: 6 additions & 13 deletions src/features/workspaces/components/WorkspaceLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { workspacePageQueryKey } from "#/features/workspaces/cache";
import { applyWorkspacePageDeltaToCache, workspacePageQueryKey } from "#/features/workspaces/cache";
import AiChatPanel from "#/features/workspaces/components/AiChatPanel";
import WorkspaceChatLayout from "#/features/workspaces/components/WorkspaceChatLayout";
import WorkspaceContextBar from "#/features/workspaces/components/WorkspaceContextBar";
Expand All @@ -22,11 +22,7 @@ import {
useWorkspaceViewPolicy,
WorkspaceViewCapabilitiesProvider,
} from "#/features/workspaces/components/workspace-view-policy";
import type {
WorkspaceItemFacts,
WorkspaceItemType,
WorkspaceSummary,
} from "#/features/workspaces/contracts";
import type { WorkspaceItemType, WorkspaceSummary } from "#/features/workspaces/contracts";
import type { WorkspaceLocation } from "#/features/workspaces/locations/workspace-location";
import { WorkspaceLocationProvider } from "#/features/workspaces/locations/workspace-location-context";
import { DocumentEditReviewProvider } from "#/features/workspaces/documents/document-edit-review-context";
Expand Down Expand Up @@ -57,17 +53,13 @@ export type { WorkspaceItem } from "#/features/workspaces/model/types";
interface WorkspaceShellProps {
workspace: WorkspaceSummary;
items: WorkspaceItem[];
itemFacts: WorkspaceItemFacts[];
revision: number;
activeTabIdFromUrl?: string;
activeViewFromUrl?: string;
}

export function WorkspaceShell({
workspace,
items,
itemFacts,
revision,
activeTabIdFromUrl,
activeViewFromUrl,
}: WorkspaceShellProps) {
Expand All @@ -83,8 +75,10 @@ export function WorkspaceShell({
const normalizedUiSession = useWorkspaceUiSession(workspace.id);
const realtime = useWorkspaceRealtime({
workspaceId: workspace.id,
lastSeenRevision: revision,
onWorkspaceChanged: () => {
onPageChange: (change) => {
applyWorkspacePageDeltaToCache(queryClient, change);
},
onDesync: () => {
void queryClient.invalidateQueries({
queryKey: workspacePageQueryKey(workspace.id),
});
Expand Down Expand Up @@ -183,7 +177,6 @@ export function WorkspaceShell({
activeItem: isWorkspaceItemView(activeItem) ? activeItem : undefined,
activeTabId: activeTab.id,
itemViewStatesByItemId,
itemFactsById: new Map(itemFacts.map((item) => [item.itemId, item])),
itemsById,
presentation,
selectedItemIds,
Expand Down
2 changes: 0 additions & 2 deletions src/features/workspaces/components/WorkspacePageRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,6 @@ export default function WorkspacePageRoute() {
activeTabIdFromUrl={tab}
activeViewFromUrl={view}
items={page.items}
itemFacts={page.itemFacts}
revision={page.revision}
workspace={page.workspace}
/>
);
Expand Down
Loading