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) {...@@ -1408,8 +1408,9 @@ export default function ChatView(props: ChatViewProps) {
1408 if (previewUrls.length === 0) return;1408 if (previewUrls.length === 0) return;
14091409
1410 const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? [];1410 const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? [];
1411 const nextPreviewUrlSet = new Set(previewUrls);
1411 for (const previewUrl of previousPreviewUrls) {1412 for (const previewUrl of previousPreviewUrls) {
1412 if (!previewUrls.includes(previewUrl)) {1413 if (!nextPreviewUrlSet.has(previewUrl)) {
1413 revokeBlobPreviewUrl(previewUrl);1414 revokeBlobPreviewUrl(previewUrl);
1414 }1415 }
1415 }1416 }
apps/web/src/components/Sidebar.logic.ts+1-1
...@@ -300,7 +300,7 @@ export function resolveThreadRowClassName(input: {...@@ -300,7 +300,7 @@ export function resolveThreadRowClassName(input: {
300 isSelected: boolean;300 isSelected: boolean;
301}): string {301}): string {
302 const baseClassName =302 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
305 if (input.isSelected && input.isActive) {305 if (input.isSelected && input.isActive) {
306 return cn(306 return cn(
apps/web/src/components/Sidebar.tsx+3-3
...@@ -680,11 +680,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -680,11 +680,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
680 render={680 render={
681 <span681 <span
682 aria-label={threadEnvironmentLabel ?? "Remote"}682 aria-label={threadEnvironmentLabel ?? "Remote"}
683 className="inline-flex h-5 items-center justify-center"683 className="inline-flex items-center justify-center"
684 />684 />
685 }685 }
686 >686 >
687 <CloudIcon className="block size-3 text-muted-foreground/60" />687 <CloudIcon className="size-3 text-muted-foreground/40" />
688 </TooltipTrigger>688 </TooltipTrigger>
689 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>689 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
690 </Tooltip>690 </Tooltip>
...@@ -809,7 +809,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(...@@ -809,7 +809,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
809 return (809 return (
810 <SidebarMenuSub810 <SidebarMenuSub
811 ref={attachThreadListAutoAnimateRef}811 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"
813 >813 >
814 {shouldShowThreadPanel && showEmptyThreadState ? (814 {shouldShowThreadPanel && showEmptyThreadState ? (
815 <SidebarMenuSubItem className="w-full" data-thread-selection-safe>815 <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({...@@ -89,17 +89,20 @@ export const ChatHeader = memo(function ChatHeader({
89 });89 });
9090
91 return (91 return (
92 <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2">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-1 items-center gap-2 overflow-hidden sm:gap-3">93 <div className="flex min-w-0 flex-wrap items-center gap-2 overflow-hidden sm:flex-1 sm:flex-nowrap sm:gap-3">
94 <SidebarTrigger className="size-7 shrink-0 md:hidden" />94 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
95 <h295 <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"
97 title={activeThreadTitle}97 title={activeThreadTitle}
98 >98 >
99 {activeThreadTitle}99 {activeThreadTitle}
100 </h2>100 </h2>
101 {activeProjectName && (101 {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 >
103 <span className="min-w-0 truncate">{activeProjectName}</span>106 <span className="min-w-0 truncate">{activeProjectName}</span>
104 </Badge>107 </Badge>
105 )}108 )}
...@@ -109,7 +112,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -109,7 +112,7 @@ export const ChatHeader = memo(function ChatHeader({
109 </Badge>112 </Badge>
110 )}113 )}
111 </div>114 </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">
113 {activeProjectScripts && (116 {activeProjectScripts && (
114 <ProjectScriptsControl117 <ProjectScriptsControl
115 scripts={activeProjectScripts}118 scripts={activeProjectScripts}
apps/web/src/components/chat/OpenInPicker.tsx+2-1
...@@ -146,7 +146,8 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor...@@ -146,7 +146,8 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor
146 value: "file-manager",146 value: "file-manager",
147 },147 },
148 ];148 ];
149 return baseOptions.filter((option) => availableEditors.includes(option.value));149 const availableEditorSet = new Set(availableEditors);
150 return baseOptions.filter((option) => availableEditorSet.has(option.value));
150};151};
151152
152export const OpenInPicker = memo(function OpenInPicker({153export const OpenInPicker = memo(function OpenInPicker({
apps/web/src/lib/contextWindow.test.ts+17
...@@ -44,6 +44,23 @@ describe("contextWindow", () => {...@@ -44,6 +44,23 @@ describe("contextWindow", () => {
44 expect(snapshot).toBeNull();44 expect(snapshot).toBeNull();
45 });45 });
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
47 it("formats compact token counts", () => {64 it("formats compact token counts", () => {
48 expect(formatContextWindowTokens(999)).toBe("999");65 expect(formatContextWindowTokens(999)).toBe("999");
49 expect(formatContextWindowTokens(1400)).toBe("1.4k");66 expect(formatContextWindowTokens(1400)).toBe("1.4k");
apps/web/src/lib/contextWindow.ts+1-1
...@@ -36,7 +36,7 @@ export function deriveLatestContextWindowSnapshot(...@@ -36,7 +36,7 @@ export function deriveLatestContextWindowSnapshot(
3636
37 const payload = asRecord(activity.payload);37 const payload = asRecord(activity.payload);
38 const usedTokens = asFiniteNumber(payload?.usedTokens);38 const usedTokens = asFiniteNumber(payload?.usedTokens);
39 if (usedTokens === null || usedTokens <= 0) {39 if (usedTokens === null || usedTokens < 0) {
40 continue;40 continue;
41 }41 }
4242
apps/web/src/lib/lruCache.test.ts+17
...@@ -40,4 +40,21 @@ describe("LRUCache", () => {...@@ -40,4 +40,21 @@ describe("LRUCache", () => {
40 expect(cache.get("b")).toBe("B");40 expect(cache.get("b")).toBe("B");
41 expect(cache.get("c")).toBe("C");41 expect(cache.get("c")).toBe("C");
42 });42 });
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 });
43});60});
apps/web/src/lib/lruCache.ts+4
...@@ -23,6 +23,10 @@ export class LRUCache<T> {...@@ -23,6 +23,10 @@ export class LRUCache<T> {
23 }23 }
2424
25 set(key: string, value: T, approximateSize: number): void {25 set(key: string, value: T, approximateSize: number): void {
26 if (approximateSize > this.maxMemoryBytes) {
27 return;
28 }
29
26 const existing = this.cache.get(key);30 const existing = this.cache.get(key);
27 if (existing) {31 if (existing) {
28 this.totalSize -= existing.approximateSize;32 this.totalSize -= existing.approximateSize;
apps/web/src/lib/threadSort.test.ts+48
...@@ -114,6 +114,31 @@ describe("sortThreads", () => {...@@ -114,6 +114,31 @@ describe("sortThreads", () => {
114 ]);114 ]);
115 });115 });
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
117 it("falls back to id ordering when threads have no sortable timestamps", () => {142 it("falls back to id ordering when threads have no sortable timestamps", () => {
118 const sorted = sortThreads(143 const sorted = sortThreads(
119 [144 [
...@@ -162,6 +187,29 @@ describe("sortThreads", () => {...@@ -162,6 +187,29 @@ describe("sortThreads", () => {
162 ]);187 ]);
163 });188 });
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
165 it("returns the latest active thread for a project", () => {213 it("returns the latest active thread for a project", () => {
166 const latestThread = getLatestThreadForProject(214 const latestThread = getLatestThreadForProject(
167 [215 [
apps/web/src/lib/threadSort.ts+15-2
...@@ -13,6 +13,17 @@ export function toSortableTimestamp(iso: string | undefined): number | null {...@@ -13,6 +13,17 @@ export function toSortableTimestamp(iso: string | undefined): number | null {
13 return Number.isFinite(ms) ? ms : null;13 return Number.isFinite(ms) ? ms : null;
14}14}
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
16function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {27function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
17 if (thread.latestUserMessageAt) {28 if (thread.latestUserMessageAt) {
18 return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;29 return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;
...@@ -34,7 +45,7 @@ function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {...@@ -34,7 +45,7 @@ function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
34 return latestUserMessageTimestamp;45 return latestUserMessageTimestamp;
35 }46 }
3647
37 return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY;48 return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY;
38}49}
3950
40export function getThreadSortTimestamp(51export function getThreadSortTimestamp(
...@@ -42,7 +53,9 @@ export function getThreadSortTimestamp(...@@ -42,7 +53,9 @@ export function getThreadSortTimestamp(
42 sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,53 sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,
43): number {54): number {
44 if (sortOrder === "created_at") {55 if (sortOrder === "created_at") {
45 return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY;56 return (
57 getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY
58 );
46 }59 }
47 return getLatestUserMessageTimestamp(thread);60 return getLatestUserMessageTimestamp(thread);
48}61}
apps/web/src/uiStateStore.ts+13-2
...@@ -63,6 +63,7 @@ const initialState: UiState = {...@@ -63,6 +63,7 @@ const initialState: UiState = {
63const persistedCollapsedProjectCwds = new Set<string>();63const persistedCollapsedProjectCwds = new Set<string>();
64const persistedExpandedProjectCwds = new Set<string>();64const persistedExpandedProjectCwds = new Set<string>();
65const persistedProjectOrderCwds: string[] = [];65const persistedProjectOrderCwds: string[] = [];
66const persistedProjectOrderCwdSet = new Set<string>();
66// Pre-fix persisted shape only listed expanded cwds, so anything not listed67// Pre-fix persisted shape only listed expanded cwds, so anything not listed
67// was treated as collapsed. Track whether the loaded blob carried the new68// was treated as collapsed. Track whether the loaded blob carried the new
68// `collapsedProjectCwds` field so we can preserve that legacy semantic for69// `collapsedProjectCwds` field so we can preserve that legacy semantic for
...@@ -140,6 +141,7 @@ export function hydratePersistedProjectState(parsed: PersistedUiState): void {...@@ -140,6 +141,7 @@ export function hydratePersistedProjectState(parsed: PersistedUiState): void {
140 persistedCollapsedProjectCwds.clear();141 persistedCollapsedProjectCwds.clear();
141 persistedExpandedProjectCwds.clear();142 persistedExpandedProjectCwds.clear();
142 persistedProjectOrderCwds.length = 0;143 persistedProjectOrderCwds.length = 0;
144 persistedProjectOrderCwdSet.clear();
143 persistedProjectStateUsesLegacyShape = !Array.isArray(parsed.collapsedProjectCwds);145 persistedProjectStateUsesLegacyShape = !Array.isArray(parsed.collapsedProjectCwds);
144 for (const cwd of parsed.collapsedProjectCwds ?? []) {146 for (const cwd of parsed.collapsedProjectCwds ?? []) {
145 if (typeof cwd === "string" && cwd.length > 0) {147 if (typeof cwd === "string" && cwd.length > 0) {
...@@ -152,7 +154,8 @@ export function hydratePersistedProjectState(parsed: PersistedUiState): void {...@@ -152,7 +154,8 @@ export function hydratePersistedProjectState(parsed: PersistedUiState): void {
152 }154 }
153 }155 }
154 for (const cwd of parsed.projectOrderCwds ?? []) {156 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);
156 persistedProjectOrderCwds.push(cwd);159 persistedProjectOrderCwds.push(cwd);
157 }160 }
158 }161 }
...@@ -254,14 +257,22 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput...@@ -254,14 +257,22 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
254 currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey);257 currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey);
255 }258 }
256 currentProjectCwdsByLogicalKey.clear();259 currentProjectCwdsByLogicalKey.clear();
260 const currentProjectCwdSetsByLogicalKey = new Map<string, Set<string>>();
257 for (const project of projects) {261 for (const project of projects) {
258 const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey);262 const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey);
259 if (cwds) {263 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);
261 cwds.push(project.cwd);271 cwds.push(project.cwd);
262 }272 }
263 } else {273 } else {
264 currentProjectCwdsByLogicalKey.set(project.logicalKey, [project.cwd]);274 currentProjectCwdsByLogicalKey.set(project.logicalKey, [project.cwd]);
275 currentProjectCwdSetsByLogicalKey.set(project.logicalKey, new Set([project.cwd]));
265 }276 }
266 }277 }
267 // Build reverse map: for each new logical key, which previous logical keys278 // Build reverse map: for each new logical key, which previous logical keys
packages/shared/src/observability.test.ts+7-4
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Arr from "effect/Array";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
6import * as Logger from "effect/Logger";7import * as Logger from "effect/Logger";
8import * as Order from "effect/Order";
7import * as Path from "effect/Path";9import * as Path from "effect/Path";
8import * as References from "effect/References";10import * as References from "effect/References";
9import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
...@@ -170,12 +172,13 @@ describe("observability", () => {...@@ -170,12 +172,13 @@ describe("observability", () => {
170 }172 }
171 yield* sink.close();173 yield* sink.close();
172174
173 const matchingFiles = (yield* fileSystem.readDirectory(tempDir))175 const matchingFiles = Arr.sort(
174 .filter(176 (yield* fileSystem.readDirectory(tempDir)).filter(
175 (entry) =>177 (entry) =>
176 entry === "shared.trace.ndjson" || entry.startsWith("shared.trace.ndjson."),178 entry === "shared.trace.ndjson" || entry.startsWith("shared.trace.ndjson."),
177 )179 ),
178 .toSorted();180 Order.String,
181 );
179182
180 assert.equal(183 assert.equal(
181 matchingFiles.some((entry) => entry === "shared.trace.ndjson.1"),184 matchingFiles.some((entry) => entry === "shared.trace.ndjson.1"),