+
{activeThreadTitle}
{activeProjectName && (
-
+
{activeProjectName}
)}
@@ -109,7 +112,7 @@ export const ChatHeader = memo(function ChatHeader({
)}
-
+
{activeProjectScripts && (
availableEditors.includes(option.value));
+ const availableEditorSet = new Set(availableEditors);
+ return baseOptions.filter((option) => availableEditorSet.has(option.value));
};
export const OpenInPicker = memo(function OpenInPicker({
diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts
index 70479b3fa43..8cb6a7f5822 100644
--- a/apps/web/src/lib/contextWindow.test.ts
+++ b/apps/web/src/lib/contextWindow.test.ts
@@ -44,6 +44,23 @@ describe("contextWindow", () => {
expect(snapshot).toBeNull();
});
+ it("keeps valid zero-usage snapshots", () => {
+ const snapshot = deriveLatestContextWindowSnapshot([
+ makeActivity("activity-1", "context-window.updated", {
+ usedTokens: 0,
+ maxTokens: 100_000,
+ }),
+ ]);
+
+ expect(snapshot).toMatchObject({
+ usedTokens: 0,
+ maxTokens: 100_000,
+ remainingTokens: 100_000,
+ usedPercentage: 0,
+ remainingPercentage: 100,
+ });
+ });
+
it("formats compact token counts", () => {
expect(formatContextWindowTokens(999)).toBe("999");
expect(formatContextWindowTokens(1400)).toBe("1.4k");
diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts
index f668135a13a..02cf262191b 100644
--- a/apps/web/src/lib/contextWindow.ts
+++ b/apps/web/src/lib/contextWindow.ts
@@ -36,7 +36,7 @@ export function deriveLatestContextWindowSnapshot(
const payload = asRecord(activity.payload);
const usedTokens = asFiniteNumber(payload?.usedTokens);
- if (usedTokens === null || usedTokens <= 0) {
+ if (usedTokens === null || usedTokens < 0) {
continue;
}
diff --git a/apps/web/src/lib/lruCache.test.ts b/apps/web/src/lib/lruCache.test.ts
index 7f53db2607e..62060a7b512 100644
--- a/apps/web/src/lib/lruCache.test.ts
+++ b/apps/web/src/lib/lruCache.test.ts
@@ -40,4 +40,21 @@ describe("LRUCache", () => {
expect(cache.get("b")).toBe("B");
expect(cache.get("c")).toBe("C");
});
+
+ it("does not cache entries larger than the memory budget", () => {
+ const cache = new LRUCache(2, 25);
+ cache.set("a", "A", 10);
+ cache.set("oversized", "X", 30);
+
+ expect(cache.get("a")).toBe("A");
+ expect(cache.get("oversized")).toBeNull();
+ });
+
+ it("preserves an existing entry when an oversized replacement is rejected", () => {
+ const cache = new LRUCache(2, 25);
+ cache.set("a", "A", 10);
+ cache.set("a", "oversized", 30);
+
+ expect(cache.get("a")).toBe("A");
+ });
});
diff --git a/apps/web/src/lib/lruCache.ts b/apps/web/src/lib/lruCache.ts
index 141c796e737..65925734300 100644
--- a/apps/web/src/lib/lruCache.ts
+++ b/apps/web/src/lib/lruCache.ts
@@ -23,6 +23,10 @@ export class LRUCache {
}
set(key: string, value: T, approximateSize: number): void {
+ if (approximateSize > this.maxMemoryBytes) {
+ return;
+ }
+
const existing = this.cache.get(key);
if (existing) {
this.totalSize -= existing.approximateSize;
diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts
index 2a69357c49e..88e9617862b 100644
--- a/apps/web/src/lib/threadSort.test.ts
+++ b/apps/web/src/lib/threadSort.test.ts
@@ -114,6 +114,31 @@ describe("sortThreads", () => {
]);
});
+ it("falls back to createdAt when updatedAt is invalid", () => {
+ const sorted = sortThreads(
+ [
+ makeThread({
+ id: ThreadId.make("thread-1"),
+ createdAt: "2026-03-09T10:00:00.000Z",
+ updatedAt: "invalid-date" as never,
+ messages: [],
+ }),
+ makeThread({
+ id: ThreadId.make("thread-2"),
+ createdAt: "2026-03-09T09:00:00.000Z",
+ updatedAt: "2026-03-09T09:30:00.000Z",
+ messages: [],
+ }),
+ ],
+ "updated_at",
+ );
+
+ expect(sorted.map((thread) => thread.id)).toEqual([
+ ThreadId.make("thread-1"),
+ ThreadId.make("thread-2"),
+ ]);
+ });
+
it("falls back to id ordering when threads have no sortable timestamps", () => {
const sorted = sortThreads(
[
@@ -162,6 +187,29 @@ describe("sortThreads", () => {
]);
});
+ it("uses updatedAt as a fallback for created_at sorting when createdAt is invalid", () => {
+ const sorted = sortThreads(
+ [
+ makeThread({
+ id: ThreadId.make("thread-1"),
+ createdAt: "invalid-date" as never,
+ updatedAt: "2026-03-09T10:05:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.make("thread-2"),
+ createdAt: "2026-03-09T10:00:00.000Z",
+ updatedAt: "2026-03-09T10:10:00.000Z",
+ }),
+ ],
+ "created_at",
+ );
+
+ expect(sorted.map((thread) => thread.id)).toEqual([
+ ThreadId.make("thread-1"),
+ ThreadId.make("thread-2"),
+ ]);
+ });
+
it("returns the latest active thread for a project", () => {
const latestThread = getLatestThreadForProject(
[
diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts
index 837a7788d38..de8b22e93c5 100644
--- a/apps/web/src/lib/threadSort.ts
+++ b/apps/web/src/lib/threadSort.ts
@@ -13,6 +13,17 @@ export function toSortableTimestamp(iso: string | undefined): number | null {
return Number.isFinite(ms) ? ms : null;
}
+function getFirstSortableTimestamp(...values: Array): number | null {
+ for (const value of values) {
+ const timestamp = toSortableTimestamp(value ?? undefined);
+ if (timestamp !== null) {
+ return timestamp;
+ }
+ }
+
+ return null;
+}
+
function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
if (thread.latestUserMessageAt) {
return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;
@@ -34,7 +45,7 @@ function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
return latestUserMessageTimestamp;
}
- return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY;
+ return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY;
}
export function getThreadSortTimestamp(
@@ -42,7 +53,9 @@ export function getThreadSortTimestamp(
sortOrder: SidebarThreadSortOrder | Exclude,
): number {
if (sortOrder === "created_at") {
- return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY;
+ return (
+ getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY
+ );
}
return getLatestUserMessageTimestamp(thread);
}
diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts
index b76f5f6859a..f16495bed7f 100644
--- a/apps/web/src/uiStateStore.ts
+++ b/apps/web/src/uiStateStore.ts
@@ -63,6 +63,7 @@ const initialState: UiState = {
const persistedCollapsedProjectCwds = new Set();
const persistedExpandedProjectCwds = new Set();
const persistedProjectOrderCwds: string[] = [];
+const persistedProjectOrderCwdSet = new Set();
// Pre-fix persisted shape only listed expanded cwds, so anything not listed
// was treated as collapsed. Track whether the loaded blob carried the new
// `collapsedProjectCwds` field so we can preserve that legacy semantic for
@@ -140,6 +141,7 @@ export function hydratePersistedProjectState(parsed: PersistedUiState): void {
persistedCollapsedProjectCwds.clear();
persistedExpandedProjectCwds.clear();
persistedProjectOrderCwds.length = 0;
+ persistedProjectOrderCwdSet.clear();
persistedProjectStateUsesLegacyShape = !Array.isArray(parsed.collapsedProjectCwds);
for (const cwd of parsed.collapsedProjectCwds ?? []) {
if (typeof cwd === "string" && cwd.length > 0) {
@@ -152,7 +154,8 @@ export function hydratePersistedProjectState(parsed: PersistedUiState): void {
}
}
for (const cwd of parsed.projectOrderCwds ?? []) {
- if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwds.includes(cwd)) {
+ if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwdSet.has(cwd)) {
+ persistedProjectOrderCwdSet.add(cwd);
persistedProjectOrderCwds.push(cwd);
}
}
@@ -254,14 +257,22 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey);
}
currentProjectCwdsByLogicalKey.clear();
+ const currentProjectCwdSetsByLogicalKey = new Map>();
for (const project of projects) {
const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey);
if (cwds) {
- if (!cwds.includes(project.cwd)) {
+ let cwdSet = currentProjectCwdSetsByLogicalKey.get(project.logicalKey);
+ if (!cwdSet) {
+ cwdSet = new Set(cwds);
+ currentProjectCwdSetsByLogicalKey.set(project.logicalKey, cwdSet);
+ }
+ if (!cwdSet.has(project.cwd)) {
+ cwdSet.add(project.cwd);
cwds.push(project.cwd);
}
} else {
currentProjectCwdsByLogicalKey.set(project.logicalKey, [project.cwd]);
+ currentProjectCwdSetsByLogicalKey.set(project.logicalKey, new Set([project.cwd]));
}
}
// Build reverse map: for each new logical key, which previous logical keys
diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts
index 5e9894550a5..2943bf46a42 100644
--- a/packages/shared/src/observability.test.ts
+++ b/packages/shared/src/observability.test.ts
@@ -1,9 +1,11 @@
import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as Arr from "effect/Array";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
+import * as Order from "effect/Order";
import * as Path from "effect/Path";
import * as References from "effect/References";
import * as Schema from "effect/Schema";
@@ -170,12 +172,13 @@ describe("observability", () => {
}
yield* sink.close();
- const matchingFiles = (yield* fileSystem.readDirectory(tempDir))
- .filter(
+ const matchingFiles = Arr.sort(
+ (yield* fileSystem.readDirectory(tempDir)).filter(
(entry) =>
entry === "shared.trace.ndjson" || entry.startsWith("shared.trace.ndjson."),
- )
- .toSorted();
+ ),
+ Order.String,
+ );
assert.equal(
matchingFiles.some((entry) => entry === "shared.trace.ndjson.1"),