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
3 changes: 2 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1408,8 +1408,9 @@
if (previewUrls.length === 0) return;

const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? [];
const nextPreviewUrlSet = new Set(previewUrls);
for (const previewUrl of previousPreviewUrls) {
if (!previewUrls.includes(previewUrl)) {
if (!nextPreviewUrlSet.has(previewUrl)) {
revokeBlobPreviewUrl(previewUrl);
}
}
Expand Down Expand Up @@ -1780,7 +1781,7 @@
);

const focusComposer = useCallback(() => {
composerRef.current?.focusAtEnd();

Check warning on line 1784 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, []);
const scheduleComposerFocus = useCallback(() => {
window.requestAnimationFrame(() => {
Expand All @@ -1788,7 +1789,7 @@
});
}, [focusComposer]);
const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => {
composerRef.current?.addTerminalContext(selection);

Check warning on line 1792 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, []);
const setTerminalOpen = useCallback(
(open: boolean) => {
Expand Down Expand Up @@ -2467,7 +2468,7 @@
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(terminalState.terminalOpen),
modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,

Check warning on line 2471 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useEffect has a missing dependency: 'composerRef.current'
};

const command = resolveShortcutCommand(event, keybindings, {
Expand Down Expand Up @@ -3019,7 +3020,7 @@
};
});
promptRef.current = "";
composerRef.current?.resetCursorState({ cursor: 0 });

Check warning on line 3023 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
},
[activePendingProgress?.activeQuestion, activePendingUserInput],
);
Expand All @@ -3046,7 +3047,7 @@
),
},
}));
const snapshot = composerRef.current?.readSnapshot();

Check warning on line 3050 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (
snapshot?.value !== value ||
snapshot.cursor !== nextCursor ||
Expand Down Expand Up @@ -3109,7 +3110,7 @@
return;
}

const sendCtx = composerRef.current?.getSendContext();

Check warning on line 3113 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (!sendCtx) {
return;
}
Expand Down Expand Up @@ -3246,7 +3247,7 @@
return;
}

const sendCtx = composerRef.current?.getSendContext();

Check warning on line 3250 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (!sendCtx) {
return;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ export function resolveThreadRowClassName(input: {
isSelected: boolean;
}): string {
const baseClassName =
"h-7 w-full translate-x-0 cursor-pointer justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring";
"h-6 w-full translate-x-0 cursor-pointer justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring sm:h-7";

if (input.isSelected && input.isActive) {
return cn(
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -680,11 +680,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
render={
<span
aria-label={threadEnvironmentLabel ?? "Remote"}
className="inline-flex h-5 items-center justify-center"
className="inline-flex items-center justify-center"
/>
}
>
<CloudIcon className="block size-3 text-muted-foreground/60" />
<CloudIcon className="size-3 text-muted-foreground/40" />
</TooltipTrigger>
<TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
</Tooltip>
Expand Down Expand Up @@ -809,7 +809,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
return (
<SidebarMenuSub
ref={attachThreadListAutoAnimateRef}
className="mx-1 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1.5 py-0"
className="mx-0.5 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1 py-0 sm:mx-1 sm:px-1.5"
>
{shouldShowThreadPanel && showEmptyThreadState ? (
<SidebarMenuSubItem className="w-full" data-thread-selection-safe>
Expand Down
13 changes: 8 additions & 5 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,20 @@ export const ChatHeader = memo(function ChatHeader({
});

return (
<div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
<div className="@container/header-actions flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2 overflow-hidden sm:flex-1 sm:flex-nowrap sm:gap-3">
<SidebarTrigger className="size-7 shrink-0 md:hidden" />
<h2
className="min-w-0 shrink truncate text-sm font-medium text-foreground"
className="min-w-0 flex-1 basis-40 truncate text-sm font-medium text-foreground"
title={activeThreadTitle}
>
{activeThreadTitle}
</h2>
{activeProjectName && (
<Badge variant="outline" className="min-w-0 shrink overflow-hidden">
<Badge
variant="outline"
className="min-w-0 max-w-full shrink overflow-hidden sm:max-w-56"
>
<span className="min-w-0 truncate">{activeProjectName}</span>
</Badge>
)}
Expand All @@ -109,7 +112,7 @@ export const ChatHeader = memo(function ChatHeader({
</Badge>
)}
</div>
<div className="flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3">
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 sm:shrink-0 sm:justify-end @3xl/header-actions:gap-3">
{activeProjectScripts && (
<ProjectScriptsControl
scripts={activeProjectScripts}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/OpenInPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor
value: "file-manager",
},
];
return baseOptions.filter((option) => availableEditors.includes(option.value));
const availableEditorSet = new Set(availableEditors);
return baseOptions.filter((option) => availableEditorSet.has(option.value));
};

export const OpenInPicker = memo(function OpenInPicker({
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/lib/contextWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/contextWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/lib/lruCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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<string>(2, 25);
cache.set("a", "A", 10);
cache.set("a", "oversized", 30);

expect(cache.get("a")).toBe("A");
});
});
4 changes: 4 additions & 0 deletions apps/web/src/lib/lruCache.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export class LRUCache<T> {
}

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;
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/lib/threadSort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down Expand Up @@ -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(
[
Expand Down
17 changes: 15 additions & 2 deletions apps/web/src/lib/threadSort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ export function toSortableTimestamp(iso: string | undefined): number | null {
return Number.isFinite(ms) ? ms : null;
}

function getFirstSortableTimestamp(...values: Array<string | null | undefined>): 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;
Expand All @@ -34,15 +45,17 @@ 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(
thread: ThreadSortInput,
sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,
): number {
if (sortOrder === "created_at") {
return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY;
return (
getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY
);
}
return getLatestUserMessageTimestamp(thread);
}
Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/uiStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const initialState: UiState = {
const persistedCollapsedProjectCwds = new Set<string>();
const persistedExpandedProjectCwds = new Set<string>();
const persistedProjectOrderCwds: string[] = [];
const persistedProjectOrderCwdSet = new Set<string>();
// 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
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -254,14 +257,22 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey);
}
currentProjectCwdsByLogicalKey.clear();
const currentProjectCwdSetsByLogicalKey = new Map<string, Set<string>>();
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
Expand Down
11 changes: 7 additions & 4 deletions packages/shared/src/observability.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"),
Expand Down
Loading