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
90 changes: 17 additions & 73 deletions apps/web/src/domains/chat/utils/context-window-storage.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,14 @@
export interface ContextWindowUsage {
tokens: number;
maxTokens: number | null;
fillRatio: number | null;
}

// Persist per-conversation context window usage to localStorage so the indicator
// survives page reloads. The desktop client keeps this state alive via a
// long-lived per-conversation ChatViewModel; the web client is a short-lived
// browser tab, so we mirror the semantics with localStorage instead.
//
// Shape on disk: { [conversationId]: ContextWindowUsage }, keyed per assistant.

const STORAGE_KEY_PREFIX = "vellum:ctxwindow:";
// Cap per-assistant entries to keep localStorage footprint bounded. Older
// entries are dropped oldest-first when we exceed the limit.
const MAX_ENTRIES_PER_ASSISTANT = 200;
import { createRecordStorageAccessor } from "@/utils/typed-storage";

type StoredMap = Record<string, ContextWindowUsage>;

function storageKey(assistantId: string): string {
return `${STORAGE_KEY_PREFIX}${assistantId}`;
export interface ContextWindowUsage {
tokens: number;
maxTokens: number | null;
fillRatio: number | null;
}

function isValidUsage(value: unknown): value is ContextWindowUsage {
Expand All @@ -45,69 +34,24 @@ function isValidUsage(value: unknown): value is ContextWindowUsage {
return true;
}

function safeParse(raw: string | null): StoredMap {
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") {
return {};
}
const result: StoredMap = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (isValidUsage(value)) {
result[key] = value;
}
}
return result;
} catch {
return {};
}
}
const storage = createRecordStorageAccessor<ContextWindowUsage>({
keyFn: (assistantId) => `vellum:ctxwindow:${assistantId}`,
scope: "user",
parseValue: (value) => (isValidUsage(value) ? value : null),
fallback: {},
maxEntries: 200,
});

export function loadContextWindowUsageMap(assistantId: string): Map<string, ContextWindowUsage> {
if (typeof window === "undefined") {
return new Map();
}
try {
const raw = window.localStorage.getItem(storageKey(assistantId));
return new Map(Object.entries(safeParse(raw)));
} catch {
return new Map();
}
export function loadContextWindowUsageMap(
assistantId: string,
): Map<string, ContextWindowUsage> {
return new Map(Object.entries(storage.load(assistantId)));
}

export function saveContextWindowUsage(
assistantId: string,
conversationId: string,
usage: ContextWindowUsage,
): void {
if (typeof window === "undefined") {
return;
}
try {
const key = storageKey(assistantId);
const existing = safeParse(window.localStorage.getItem(key));
existing[conversationId] = usage;

const entries = Object.entries(existing);
if (entries.length > MAX_ENTRIES_PER_ASSISTANT) {
// Drop oldest entries. We don't track timestamps, so this relies on
// insertion order being preserved by JSON serialization, which holds
// for all supported browsers.
const trimmed = entries.slice(entries.length - MAX_ENTRIES_PER_ASSISTANT);
const trimmedMap: StoredMap = {};
for (const [k, v] of trimmed) {
trimmedMap[k] = v;
}
window.localStorage.setItem(key, JSON.stringify(trimmedMap));
return;
}

window.localStorage.setItem(key, JSON.stringify(existing));
} catch {
// Storage can fail in private browsing / quota-exceeded cases. Silently
// drop; the in-memory cache still works for the current session.
}
storage.set(assistantId, conversationId, usage);
}
89 changes: 15 additions & 74 deletions apps/web/src/domains/chat/utils/dismissed-surfaces-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,98 +8,39 @@
// (a) reappear as active on reload and wedge the composer, or (b) disappear
// entirely even if still pending. We persist resolved IDs here so rehydration
// can filter them out safely.
//
// Shape on disk: { [conversationId]: string[] }, keyed per assistant.

import type { DisplayMessage } from "@/domains/chat/types/types";
import { isStringArray } from "@/domains/chat/utils/storage-validators";
import { createRecordStorageAccessor } from "@/utils/typed-storage";

const STORAGE_KEY_PREFIX = "vellum:dismissed-surfaces:";
const MAX_ENTRIES_PER_ASSISTANT = 200;
const MAX_IDS_PER_CONVERSATION = 500;

type StoredMap = Record<string, string[]>;

function storageKey(assistantId: string): string {
return `${STORAGE_KEY_PREFIX}${assistantId}`;
}

function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string");
}

function safeParse(raw: string | null): StoredMap {
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") {
return {};
}
const result: StoredMap = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (isStringArray(value)) {
result[key] = value;
}
}
return result;
} catch {
return {};
}
}
const storage = createRecordStorageAccessor<string[]>({
keyFn: (assistantId) => `vellum:dismissed-surfaces:${assistantId}`,
scope: "user",
parseValue: (value) => (isStringArray(value) ? value : null),
fallback: {},
maxEntries: 200,
});

export function loadDismissedSurfaceIds(
assistantId: string,
conversationId: string,
): Set<string> {
if (typeof window === "undefined") {
return new Set();
}
try {
const raw = window.localStorage.getItem(storageKey(assistantId));
const map = safeParse(raw);
const ids = map[conversationId];
return ids ? new Set(ids) : new Set();
} catch {
return new Set();
}
const ids = storage.get(assistantId, conversationId);
return ids ? new Set(ids) : new Set();
}

export function saveDismissedSurfaceIds(
assistantId: string,
conversationId: string,
ids: Set<string>,
): void {
if (typeof window === "undefined") {
return;
}
try {
const key = storageKey(assistantId);
const existing = safeParse(window.localStorage.getItem(key));

// Cap per-conversation list size; drop oldest on overflow (insertion order).
let idArray = Array.from(ids);
if (idArray.length > MAX_IDS_PER_CONVERSATION) {
idArray = idArray.slice(idArray.length - MAX_IDS_PER_CONVERSATION);
}
existing[conversationId] = idArray;

const entries = Object.entries(existing);
if (entries.length > MAX_ENTRIES_PER_ASSISTANT) {
const trimmed = entries.slice(entries.length - MAX_ENTRIES_PER_ASSISTANT);
const trimmedMap: StoredMap = {};
for (const [k, v] of trimmed) {
trimmedMap[k] = v;
}
window.localStorage.setItem(key, JSON.stringify(trimmedMap));
return;
}

window.localStorage.setItem(key, JSON.stringify(existing));
} catch {
// Storage can fail in private browsing / quota-exceeded cases. Silently
// drop; in-memory state still works for the current session.
let idArray = Array.from(ids);
if (idArray.length > MAX_IDS_PER_CONVERSATION) {
idArray = idArray.slice(idArray.length - MAX_IDS_PER_CONVERSATION);
}
storage.set(assistantId, conversationId, idArray);
}

// Strip any surfaces (and matching contentOrder entries) whose IDs the user
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,71 +4,12 @@ import {
loadOpenCategories,
saveOpenCategories,
} from "@/domains/chat/utils/sidebar-group-collapse-storage";
import { installMemoryStorage } from "@/utils/memory-storage.test-helper";

const ASSISTANT_ID = "asst_123";
const STORAGE_KEY = `vellum:sidebar-open-categories:${ASSISTANT_ID}`;

class MemoryStorage implements Storage {
private store = new Map<string, string>();

get length(): number {
return this.store.size;
}

clear(): void {
this.store.clear();
}

getItem(key: string): string | null {
return this.store.has(key) ? (this.store.get(key) ?? null) : null;
}

key(index: number): string | null {
return Array.from(this.store.keys())[index] ?? null;
}

removeItem(key: string): void {
this.store.delete(key);
}

setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
}

const memoryStorage = new MemoryStorage();
// Track the original `window` descriptor so we can restore it after this test
// file finishes. Other tests in the same bun worker rely on `typeof window ===
// "undefined"` to pick a baseUrl for the HTTP client, so we must not leak a
// defined `window` into unrelated suites.
const ORIGINAL_WINDOW_DESCRIPTOR = Object.getOwnPropertyDescriptor(
globalThis,
"window",
);

beforeAll(() => {
Object.defineProperty(globalThis, "window", {
value: { localStorage: memoryStorage },
configurable: true,
writable: true,
});
});

afterAll(() => {
if (ORIGINAL_WINDOW_DESCRIPTOR) {
Object.defineProperty(globalThis, "window", ORIGINAL_WINDOW_DESCRIPTOR);
} else {
delete (globalThis as { window?: unknown }).window;
}
});

beforeEach(() => {
memoryStorage.clear();
});

afterEach(() => {
memoryStorage.clear();
});
const memoryStorage = installMemoryStorage({ beforeAll, afterAll, beforeEach, afterEach });

describe("loadOpenCategories", () => {
test("returns default [] when no value is stored", () => {
Expand Down
Loading