authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-05-28 16:50:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-28 16:50:40-07:00
logcf07d0632174b432ce8bd70f3292f615afe8b184
tree87eda75cce61ff2c67571664f9887836d0eeeecb
parent31268945f88a712fad9309d5ea9290cb8496af83
signature Signed by PGP key B5690EEEBB952194

Extract independent web cleanup from mobile stack (#2855)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

13 files changed, 138 insertions(+), 20 deletions(-)

apps/web/src/components/ChatView.tsx+2-1
......@@ -1408,8 +1408,9 @@ export default function ChatView(props: ChatViewProps) {
14081408 if (previewUrls.length === 0) return;
14091409
14101410 const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? [];
1411 const nextPreviewUrlSet = new Set(previewUrls);
14111412 for (const previewUrl of previousPreviewUrls) {
1412 if (!previewUrls.includes(previewUrl)) {
1413 if (!nextPreviewUrlSet.has(previewUrl)) {
14131414 revokeBlobPreviewUrl(previewUrl);
14141415 }
14151416 }
apps/web/src/components/Sidebar.logic.ts+1-1
......@@ -300,7 +300,7 @@ export function resolveThreadRowClassName(input: {
300300 isSelected: boolean;
301301}): string {
302302 const baseClassName =
303 "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";
303 "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";
304304
305305 if (input.isSelected && input.isActive) {
306306 return cn(
apps/web/src/components/Sidebar.tsx+3-3
......@@ -680,11 +680,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
680680 render={
681681 <span
682682 aria-label={threadEnvironmentLabel ?? "Remote"}
683 className="inline-flex h-5 items-center justify-center"
683 className="inline-flex items-center justify-center"
684684 />
685685 }
686686 >
687 <CloudIcon className="block size-3 text-muted-foreground/60" />
687 <CloudIcon className="size-3 text-muted-foreground/40" />
688688 </TooltipTrigger>
689689 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
690690 </Tooltip>
......@@ -809,7 +809,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
809809 return (
810810 <SidebarMenuSub
811811 ref={attachThreadListAutoAnimateRef}
812 className="mx-1 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1.5 py-0"
812 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"
813813 >
814814 {shouldShowThreadPanel && showEmptyThreadState ? (
815815 <SidebarMenuSubItem className="w-full" data-thread-selection-safe>
apps/web/src/components/chat/ChatHeader.tsx+8-5
......@@ -89,17 +89,20 @@ export const ChatHeader = memo(function ChatHeader({
8989 });
9090
9191 return (
92 <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2">
93 <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
92 <div className="@container/header-actions flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
93 <div className="flex min-w-0 flex-wrap items-center gap-2 overflow-hidden sm:flex-1 sm:flex-nowrap sm:gap-3">
9494 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
9595 <h2
96 className="min-w-0 shrink truncate text-sm font-medium text-foreground"
96 className="min-w-0 flex-1 basis-40 truncate text-sm font-medium text-foreground"
9797 title={activeThreadTitle}
9898 >
9999 {activeThreadTitle}
100100 </h2>
101101 {activeProjectName && (
102 <Badge variant="outline" className="min-w-0 shrink overflow-hidden">
102 <Badge
103 variant="outline"
104 className="min-w-0 max-w-full shrink overflow-hidden sm:max-w-56"
105 >
103106 <span className="min-w-0 truncate">{activeProjectName}</span>
104107 </Badge>
105108 )}
......@@ -109,7 +112,7 @@ export const ChatHeader = memo(function ChatHeader({
109112 </Badge>
110113 )}
111114 </div>
112 <div className="flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3">
115 <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">
113116 {activeProjectScripts && (
114117 <ProjectScriptsControl
115118 scripts={activeProjectScripts}
apps/web/src/components/chat/OpenInPicker.tsx+2-1
......@@ -146,7 +146,8 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor
146146 value: "file-manager",
147147 },
148148 ];
149 return baseOptions.filter((option) => availableEditors.includes(option.value));
149 const availableEditorSet = new Set(availableEditors);
150 return baseOptions.filter((option) => availableEditorSet.has(option.value));
150151};
151152
152153export const OpenInPicker = memo(function OpenInPicker({
apps/web/src/lib/contextWindow.test.ts+17
......@@ -44,6 +44,23 @@ describe("contextWindow", () => {
4444 expect(snapshot).toBeNull();
4545 });
4646
47 it("keeps valid zero-usage snapshots", () => {
48 const snapshot = deriveLatestContextWindowSnapshot([
49 makeActivity("activity-1", "context-window.updated", {
50 usedTokens: 0,
51 maxTokens: 100_000,
52 }),
53 ]);
54
55 expect(snapshot).toMatchObject({
56 usedTokens: 0,
57 maxTokens: 100_000,
58 remainingTokens: 100_000,
59 usedPercentage: 0,
60 remainingPercentage: 100,
61 });
62 });
63
4764 it("formats compact token counts", () => {
4865 expect(formatContextWindowTokens(999)).toBe("999");
4966 expect(formatContextWindowTokens(1400)).toBe("1.4k");
apps/web/src/lib/contextWindow.ts+1-1
......@@ -36,7 +36,7 @@ export function deriveLatestContextWindowSnapshot(
3636
3737 const payload = asRecord(activity.payload);
3838 const usedTokens = asFiniteNumber(payload?.usedTokens);
39 if (usedTokens === null || usedTokens <= 0) {
39 if (usedTokens === null || usedTokens < 0) {
4040 continue;
4141 }
4242
apps/web/src/lib/lruCache.test.ts+17
......@@ -40,4 +40,21 @@ describe("LRUCache", () => {
4040 expect(cache.get("b")).toBe("B");
4141 expect(cache.get("c")).toBe("C");
4242 });
43
44 it("does not cache entries larger than the memory budget", () => {
45 const cache = new LRUCache<string>(2, 25);
46 cache.set("a", "A", 10);
47 cache.set("oversized", "X", 30);
48
49 expect(cache.get("a")).toBe("A");
50 expect(cache.get("oversized")).toBeNull();
51 });
52
53 it("preserves an existing entry when an oversized replacement is rejected", () => {
54 const cache = new LRUCache<string>(2, 25);
55 cache.set("a", "A", 10);
56 cache.set("a", "oversized", 30);
57
58 expect(cache.get("a")).toBe("A");
59 });
4360});
apps/web/src/lib/lruCache.ts+4
......@@ -23,6 +23,10 @@ export class LRUCache<T> {
2323 }
2424
2525 set(key: string, value: T, approximateSize: number): void {
26 if (approximateSize > this.maxMemoryBytes) {
27 return;
28 }
29
2630 const existing = this.cache.get(key);
2731 if (existing) {
2832 this.totalSize -= existing.approximateSize;
apps/web/src/lib/threadSort.test.ts+48
......@@ -114,6 +114,31 @@ describe("sortThreads", () => {
114114 ]);
115115 });
116116
117 it("falls back to createdAt when updatedAt is invalid", () => {
118 const sorted = sortThreads(
119 [
120 makeThread({
121 id: ThreadId.make("thread-1"),
122 createdAt: "2026-03-09T10:00:00.000Z",
123 updatedAt: "invalid-date" as never,
124 messages: [],
125 }),
126 makeThread({
127 id: ThreadId.make("thread-2"),
128 createdAt: "2026-03-09T09:00:00.000Z",
129 updatedAt: "2026-03-09T09:30:00.000Z",
130 messages: [],
131 }),
132 ],
133 "updated_at",
134 );
135
136 expect(sorted.map((thread) => thread.id)).toEqual([
137 ThreadId.make("thread-1"),
138 ThreadId.make("thread-2"),
139 ]);
140 });
141
117142 it("falls back to id ordering when threads have no sortable timestamps", () => {
118143 const sorted = sortThreads(
119144 [
......@@ -162,6 +187,29 @@ describe("sortThreads", () => {
162187 ]);
163188 });
164189
190 it("uses updatedAt as a fallback for created_at sorting when createdAt is invalid", () => {
191 const sorted = sortThreads(
192 [
193 makeThread({
194 id: ThreadId.make("thread-1"),
195 createdAt: "invalid-date" as never,
196 updatedAt: "2026-03-09T10:05:00.000Z",
197 }),
198 makeThread({
199 id: ThreadId.make("thread-2"),
200 createdAt: "2026-03-09T10:00:00.000Z",
201 updatedAt: "2026-03-09T10:10:00.000Z",
202 }),
203 ],
204 "created_at",
205 );
206
207 expect(sorted.map((thread) => thread.id)).toEqual([
208 ThreadId.make("thread-1"),
209 ThreadId.make("thread-2"),
210 ]);
211 });
212
165213 it("returns the latest active thread for a project", () => {
166214 const latestThread = getLatestThreadForProject(
167215 [
apps/web/src/lib/threadSort.ts+15-2
......@@ -13,6 +13,17 @@ export function toSortableTimestamp(iso: string | undefined): number | null {
1313 return Number.isFinite(ms) ? ms : null;
1414}
1515
16function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null {
17 for (const value of values) {
18 const timestamp = toSortableTimestamp(value ?? undefined);
19 if (timestamp !== null) {
20 return timestamp;
21 }
22 }
23
24 return null;
25}
26
1627function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
1728 if (thread.latestUserMessageAt) {
1829 return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;
......@@ -34,7 +45,7 @@ function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
3445 return latestUserMessageTimestamp;
3546 }
3647
37 return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY;
48 return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY;
3849}
3950
4051export function getThreadSortTimestamp(
......@@ -42,7 +53,9 @@ export function getThreadSortTimestamp(
4253 sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,
4354): number {
4455 if (sortOrder === "created_at") {
45 return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY;
56 return (
57 getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY
58 );
4659 }
4760 return getLatestUserMessageTimestamp(thread);
4861}
apps/web/src/uiStateStore.ts+13-2
......@@ -63,6 +63,7 @@ const initialState: UiState = {
6363const persistedCollapsedProjectCwds = new Set<string>();
6464const persistedExpandedProjectCwds = new Set<string>();
6565const persistedProjectOrderCwds: string[] = [];
66const persistedProjectOrderCwdSet = new Set<string>();
6667// Pre-fix persisted shape only listed expanded cwds, so anything not listed
6768// was treated as collapsed. Track whether the loaded blob carried the new
6869// `collapsedProjectCwds` field so we can preserve that legacy semantic for
......@@ -140,6 +141,7 @@ export function hydratePersistedProjectState(parsed: PersistedUiState): void {
140141 persistedCollapsedProjectCwds.clear();
141142 persistedExpandedProjectCwds.clear();
142143 persistedProjectOrderCwds.length = 0;
144 persistedProjectOrderCwdSet.clear();
143145 persistedProjectStateUsesLegacyShape = !Array.isArray(parsed.collapsedProjectCwds);
144146 for (const cwd of parsed.collapsedProjectCwds ?? []) {
145147 if (typeof cwd === "string" && cwd.length > 0) {
......@@ -152,7 +154,8 @@ export function hydratePersistedProjectState(parsed: PersistedUiState): void {
152154 }
153155 }
154156 for (const cwd of parsed.projectOrderCwds ?? []) {
155 if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwds.includes(cwd)) {
157 if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwdSet.has(cwd)) {
158 persistedProjectOrderCwdSet.add(cwd);
156159 persistedProjectOrderCwds.push(cwd);
157160 }
158161 }
......@@ -254,14 +257,22 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
254257 currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey);
255258 }
256259 currentProjectCwdsByLogicalKey.clear();
260 const currentProjectCwdSetsByLogicalKey = new Map<string, Set<string>>();
257261 for (const project of projects) {
258262 const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey);
259263 if (cwds) {
260 if (!cwds.includes(project.cwd)) {
264 let cwdSet = currentProjectCwdSetsByLogicalKey.get(project.logicalKey);
265 if (!cwdSet) {
266 cwdSet = new Set(cwds);
267 currentProjectCwdSetsByLogicalKey.set(project.logicalKey, cwdSet);
268 }
269 if (!cwdSet.has(project.cwd)) {
270 cwdSet.add(project.cwd);
261271 cwds.push(project.cwd);
262272 }
263273 } else {
264274 currentProjectCwdsByLogicalKey.set(project.logicalKey, [project.cwd]);
275 currentProjectCwdSetsByLogicalKey.set(project.logicalKey, new Set([project.cwd]));
265276 }
266277 }
267278 // Build reverse map: for each new logical key, which previous logical keys
packages/shared/src/observability.test.ts+7-4
......@@ -1,9 +1,11 @@
11import { assert, describe, it } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Arr from "effect/Array";
34import * as Effect from "effect/Effect";
45import * as FileSystem from "effect/FileSystem";
56import * as Layer from "effect/Layer";
67import * as Logger from "effect/Logger";
8import * as Order from "effect/Order";
79import * as Path from "effect/Path";
810import * as References from "effect/References";
911import * as Schema from "effect/Schema";
......@@ -170,12 +172,13 @@ describe("observability", () => {
170172 }
171173 yield* sink.close();
172174
173 const matchingFiles = (yield* fileSystem.readDirectory(tempDir))
174 .filter(
175 const matchingFiles = Arr.sort(
176 (yield* fileSystem.readDirectory(tempDir)).filter(
175177 (entry) =>
176178 entry === "shared.trace.ndjson" || entry.startsWith("shared.trace.ndjson."),
177 )
178 .toSorted();
179 ),
180 Order.String,
181 );
179182
180183 assert.equal(
181184 matchingFiles.some((entry) => entry === "shared.trace.ndjson.1"),