| author | |
| committer | |
| log | cf07d0632174b432ce8bd70f3292f615afe8b184 |
| tree | 87eda75cce61ff2c67571664f9887836d0eeeecb |
| parent | 31268945f88a712fad9309d5ea9290cb8496af83 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1408 | if (previewUrls.length === 0) return; |
| 1409 | 1409 | |
| 1410 | 1410 | const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; |
| 1411 | const nextPreviewUrlSet = new Set(previewUrls); | |
| 1411 | 1412 | for (const previewUrl of previousPreviewUrls) { |
| 1412 | if (!previewUrls.includes(previewUrl)) { | |
| 1413 | if (!nextPreviewUrlSet.has(previewUrl)) { | |
| 1413 | 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 | 300 | isSelected: boolean; |
| 301 | 301 | }): string { |
| 302 | 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"; | |
| 304 | 304 | |
| 305 | 305 | if (input.isSelected && input.isActive) { |
| 306 | 306 | return cn( |
apps/web/src/components/Sidebar.tsx+3-3| ... | ... | @@ -680,11 +680,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 680 | 680 | render={ |
| 681 | 681 | <span |
| 682 | 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 | 688 | </TooltipTrigger> |
| 689 | 689 | <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup> |
| 690 | 690 | </Tooltip> |
| ... | ... | @@ -809,7 +809,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( |
| 809 | 809 | return ( |
| 810 | 810 | <SidebarMenuSub |
| 811 | 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 | 814 | {shouldShowThreadPanel && showEmptyThreadState ? ( |
| 815 | 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 | 89 | }); |
| 90 | 90 | |
| 91 | 91 | 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"> | |
| 94 | 94 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> |
| 95 | 95 | <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 | 97 | title={activeThreadTitle} |
| 98 | 98 | > |
| 99 | 99 | {activeThreadTitle} |
| 100 | 100 | </h2> |
| 101 | 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 | 106 | <span className="min-w-0 truncate">{activeProjectName}</span> |
| 104 | 107 | </Badge> |
| 105 | 108 | )} |
| ... | ... | @@ -109,7 +112,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 109 | 112 | </Badge> |
| 110 | 113 | )} |
| 111 | 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 | 116 | {activeProjectScripts && ( |
| 114 | 117 | <ProjectScriptsControl |
| 115 | 118 | scripts={activeProjectScripts} |
apps/web/src/components/chat/OpenInPicker.tsx+2-1| ... | ... | @@ -146,7 +146,8 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor |
| 146 | 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 | }; |
| 151 | 152 | |
| 152 | 153 | export const OpenInPicker = memo(function OpenInPicker({ |
apps/web/src/lib/contextWindow.test.ts+17| ... | ... | @@ -44,6 +44,23 @@ describe("contextWindow", () => { |
| 44 | 44 | expect(snapshot).toBeNull(); |
| 45 | 45 | }); |
| 46 | 46 | |
| 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 | 64 | it("formats compact token counts", () => { |
| 48 | 65 | expect(formatContextWindowTokens(999)).toBe("999"); |
| 49 | 66 | expect(formatContextWindowTokens(1400)).toBe("1.4k"); |
apps/web/src/lib/contextWindow.ts+1-1| ... | ... | @@ -36,7 +36,7 @@ export function deriveLatestContextWindowSnapshot( |
| 36 | 36 | |
| 37 | 37 | const payload = asRecord(activity.payload); |
| 38 | 38 | const usedTokens = asFiniteNumber(payload?.usedTokens); |
| 39 | if (usedTokens === null || usedTokens <= 0) { | |
| 39 | if (usedTokens === null || usedTokens < 0) { | |
| 40 | 40 | continue; |
| 41 | 41 | } |
| 42 | 42 |
apps/web/src/lib/lruCache.test.ts+17| ... | ... | @@ -40,4 +40,21 @@ describe("LRUCache", () => { |
| 40 | 40 | expect(cache.get("b")).toBe("B"); |
| 41 | 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 | 23 | } |
| 24 | 24 | |
| 25 | 25 | set(key: string, value: T, approximateSize: number): void { |
| 26 | if (approximateSize > this.maxMemoryBytes) { | |
| 27 | return; | |
| 28 | } | |
| 29 | ||
| 26 | 30 | const existing = this.cache.get(key); |
| 27 | 31 | if (existing) { |
| 28 | 32 | this.totalSize -= existing.approximateSize; |
apps/web/src/lib/threadSort.test.ts+48| ... | ... | @@ -114,6 +114,31 @@ describe("sortThreads", () => { |
| 114 | 114 | ]); |
| 115 | 115 | }); |
| 116 | 116 | |
| 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 | 142 | it("falls back to id ordering when threads have no sortable timestamps", () => { |
| 118 | 143 | const sorted = sortThreads( |
| 119 | 144 | [ |
| ... | ... | @@ -162,6 +187,29 @@ describe("sortThreads", () => { |
| 162 | 187 | ]); |
| 163 | 188 | }); |
| 164 | 189 | |
| 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 | 213 | it("returns the latest active thread for a project", () => { |
| 166 | 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 | 13 | return Number.isFinite(ms) ? ms : null; |
| 14 | 14 | } |
| 15 | 15 | |
| 16 | function 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 | ||
| 16 | 27 | function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { |
| 17 | 28 | if (thread.latestUserMessageAt) { |
| 18 | 29 | return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; |
| ... | ... | @@ -34,7 +45,7 @@ function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { |
| 34 | 45 | return latestUserMessageTimestamp; |
| 35 | 46 | } |
| 36 | 47 | |
| 37 | return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY; | |
| 48 | return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY; | |
| 38 | 49 | } |
| 39 | 50 | |
| 40 | 51 | export function getThreadSortTimestamp( |
| ... | ... | @@ -42,7 +53,9 @@ export function getThreadSortTimestamp( |
| 42 | 53 | sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">, |
| 43 | 54 | ): number { |
| 44 | 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 | 60 | return getLatestUserMessageTimestamp(thread); |
| 48 | 61 | } |
apps/web/src/uiStateStore.ts+13-2| ... | ... | @@ -63,6 +63,7 @@ const initialState: UiState = { |
| 63 | 63 | const persistedCollapsedProjectCwds = new Set<string>(); |
| 64 | 64 | const persistedExpandedProjectCwds = new Set<string>(); |
| 65 | 65 | const persistedProjectOrderCwds: string[] = []; |
| 66 | const persistedProjectOrderCwdSet = new Set<string>(); | |
| 66 | 67 | // Pre-fix persisted shape only listed expanded cwds, so anything not listed |
| 67 | 68 | // was treated as collapsed. Track whether the loaded blob carried the new |
| 68 | 69 | // `collapsedProjectCwds` field so we can preserve that legacy semantic for |
| ... | ... | @@ -140,6 +141,7 @@ export function hydratePersistedProjectState(parsed: PersistedUiState): void { |
| 140 | 141 | persistedCollapsedProjectCwds.clear(); |
| 141 | 142 | persistedExpandedProjectCwds.clear(); |
| 142 | 143 | persistedProjectOrderCwds.length = 0; |
| 144 | persistedProjectOrderCwdSet.clear(); | |
| 143 | 145 | persistedProjectStateUsesLegacyShape = !Array.isArray(parsed.collapsedProjectCwds); |
| 144 | 146 | for (const cwd of parsed.collapsedProjectCwds ?? []) { |
| 145 | 147 | if (typeof cwd === "string" && cwd.length > 0) { |
| ... | ... | @@ -152,7 +154,8 @@ export function hydratePersistedProjectState(parsed: PersistedUiState): void { |
| 152 | 154 | } |
| 153 | 155 | } |
| 154 | 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 | 159 | persistedProjectOrderCwds.push(cwd); |
| 157 | 160 | } |
| 158 | 161 | } |
| ... | ... | @@ -254,14 +257,22 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput |
| 254 | 257 | currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey); |
| 255 | 258 | } |
| 256 | 259 | currentProjectCwdsByLogicalKey.clear(); |
| 260 | const currentProjectCwdSetsByLogicalKey = new Map<string, Set<string>>(); | |
| 257 | 261 | for (const project of projects) { |
| 258 | 262 | const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey); |
| 259 | 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 | 271 | cwds.push(project.cwd); |
| 262 | 272 | } |
| 263 | 273 | } else { |
| 264 | 274 | currentProjectCwdsByLogicalKey.set(project.logicalKey, [project.cwd]); |
| 275 | currentProjectCwdSetsByLogicalKey.set(project.logicalKey, new Set([project.cwd])); | |
| 265 | 276 | } |
| 266 | 277 | } |
| 267 | 278 | // 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 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Arr from "effect/Array"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 6 | import * as Layer from "effect/Layer"; |
| 6 | 7 | import * as Logger from "effect/Logger"; |
| 8 | import * as Order from "effect/Order"; | |
| 7 | 9 | import * as Path from "effect/Path"; |
| 8 | 10 | import * as References from "effect/References"; |
| 9 | 11 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -170,12 +172,13 @@ describe("observability", () => { |
| 170 | 172 | } |
| 171 | 173 | yield* sink.close(); |
| 172 | 174 | |
| 173 | const matchingFiles = (yield* fileSystem.readDirectory(tempDir)) | |
| 174 | .filter( | |
| 175 | const matchingFiles = Arr.sort( | |
| 176 | (yield* fileSystem.readDirectory(tempDir)).filter( | |
| 175 | 177 | (entry) => |
| 176 | 178 | entry === "shared.trace.ndjson" || entry.startsWith("shared.trace.ndjson."), |
| 177 | ) | |
| 178 | .toSorted(); | |
| 179 | ), | |
| 180 | Order.String, | |
| 181 | ); | |
| 179 | 182 | |
| 180 | 183 | assert.equal( |
| 181 | 184 | matchingFiles.some((entry) => entry === "shared.trace.ndjson.1"), |