authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-08-07 19:45:56+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 14:45:56-04:00
log72d673a855c730536f0cf3bb964ba523e0af9e2e
tree444b65704afba8d2e2fabb7579212763dff0b730
parente2cd2383cec150ea674030d408f2a99a411c94fc
signature Signed by PGP key B5690EEEBB952194

feat(desktop): remember recently used sites in the Browser panel (#5270)


16 files changed, 1207 insertions(+), 41 deletions(-)

apps/web/src/browser/browserTargetResolver.ts+3-2
......@@ -7,7 +7,8 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";
77
88import { readPreparedConnection } from "~/state/session";
99
10const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, "");
10export const normalizeHostname = (host: string): string =>
11 host.toLowerCase().replace(/^\[|\]$/g, "");
1112
1213const parseIpv4Address = (host: string): readonly number[] | null => {
1314 const parts = normalizeHostname(host).split(".").map(Number);
......@@ -17,7 +18,7 @@ const parseIpv4Address = (host: string): readonly number[] | null => {
1718 : null;
1819};
1920
20const isLocalLoopbackHost = (host: string): boolean => {
21export const isLocalLoopbackHost = (host: string): boolean => {
2122 const normalized = normalizeHostname(host);
2223 if (normalized === "localhost" || normalized === "::1") return true;
2324 return parseIpv4Address(normalized)?.[0] === 127;
apps/web/src/browserHistoryStore.test.ts created+444
......@@ -0,0 +1,444 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";
4
5const { readPreparedConnection } = vi.hoisted(() => ({
6 readPreparedConnection: vi.fn<() => { httpBaseUrl: string } | null>(() => null),
7}));
8
9vi.mock("~/state/session", () => ({ readPreparedConnection }));
10
11import {
12 BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,
13 BROWSER_HISTORY_MAX_PROJECTS,
14 BROWSER_HISTORY_MAX_TITLE_LENGTH,
15 type BrowserHistoryEntry,
16 evictExcessProjects,
17 mergeBrowserHistoryState,
18 migratePersistedBrowserHistoryState,
19 normalizeHistoryUrl,
20 recordVisitForThread,
21 removeUrlForThread,
22 resetBrowserHistoryForTests,
23 setTitleForThreadUrl,
24 upsertHistoryEntry,
25 useBrowserHistoryStore,
26} from "./browserHistoryStore";
27
28function entry(overrides: Partial<BrowserHistoryEntry> = {}): BrowserHistoryEntry {
29 return { url: "http://localhost:3000/", lastVisitedAt: 1000, ...overrides };
30}
31
32beforeEach(() => readPreparedConnection.mockReturnValue(null));
33afterEach(() => vi.restoreAllMocks());
34
35function spyOnPersistWrites() {
36 const storage = useBrowserHistoryStore.persist.getOptions().storage;
37 if (!storage) throw new Error("Browser history persistence storage is unavailable.");
38 return vi.spyOn(storage, "setItem");
39}
40
41describe("normalizeHistoryUrl", () => {
42 it("normalizes bare loopback hosts to http and keeps path/query", () => {
43 expect(normalizeHistoryUrl("localhost:3000/admin?tab=1")).toBe(
44 "http://localhost:3000/admin?tab=1",
45 );
46 });
47
48 it("normalizes bare public hosts to https", () => {
49 expect(normalizeHistoryUrl("myapp.test")).toBe("https://myapp.test/");
50 });
51
52 it("preserves hash routes and strips credentials", () => {
53 expect(normalizeHistoryUrl("http://localhost:3000/app#/route")).toBe(
54 "http://localhost:3000/app#/route",
55 );
56 expect(normalizeHistoryUrl("https://user:secret@example.com/")).toBe("https://example.com/");
57 });
58
59 it("rejects non-http(s), unparseable, and oversized urls", () => {
60 expect(normalizeHistoryUrl("ftp://example.com")).toBeNull();
61 expect(normalizeHistoryUrl("")).toBeNull();
62 expect(normalizeHistoryUrl(`http://localhost/${"a".repeat(2048)}`)).toBeNull();
63 });
64});
65
66describe("upsertHistoryEntry", () => {
67 it("prepends new urls", () => {
68 const next = upsertHistoryEntry([entry()], "http://localhost:5173/", 2000);
69 expect(next.map((e) => e.url)).toEqual(["http://localhost:5173/", "http://localhost:3000/"]);
70 expect(next[0]).toEqual({ url: "http://localhost:5173/", lastVisitedAt: 2000 });
71 });
72
73 it("moves revisits to front, updates the timestamp, and keeps the title", () => {
74 const existing = [
75 entry({ url: "http://a.test/", lastVisitedAt: 500, title: "A" }),
76 entry({ url: "http://b.test/", lastVisitedAt: 400 }),
77 ];
78 const next = upsertHistoryEntry(existing, "http://b.test/", 3000);
79 expect(next.map((e) => e.url)).toEqual(["http://b.test/", "http://a.test/"]);
80 expect(next[0]?.lastVisitedAt).toBe(3000);
81 expect(next[1]?.title).toBe("A");
82 });
83
84 it("caps the list at the per-project limit", () => {
85 const full = Array.from({ length: BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT }, (_, i) =>
86 entry({ url: `http://localhost:${3000 + i}/`, lastVisitedAt: i }),
87 );
88 const next = upsertHistoryEntry(full, "http://new.test/", 9999);
89 expect(next).toHaveLength(BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
90 expect(next[0]?.url).toBe("http://new.test/");
91 const lastPort = 3000 + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT - 1;
92 expect(next.some((e) => e.url === `http://localhost:${lastPort}/`)).toBe(false);
93 expect(next.some((e) => e.url === "http://localhost:3000/")).toBe(true);
94 });
95
96 it("with insertOrdered, slots an older entry below a newer one instead of prepending", () => {
97 const existing = [entry({ url: "http://newer.test/", lastVisitedAt: 2000 })];
98 const next = upsertHistoryEntry(existing, "http://older.test/", 1000, {
99 insertOrdered: true,
100 });
101 expect(next.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]);
102 });
103
104 it("with insertOrdered, replaying an older visit for an existing entry keeps its newer timestamp", () => {
105 const existing = [entry({ url: "http://a.test/", lastVisitedAt: 2000 })];
106 const next = upsertHistoryEntry(existing, "http://a.test/", 1000, { insertOrdered: true });
107 expect(next).toEqual([{ url: "http://a.test/", lastVisitedAt: 2000 }]);
108 });
109});
110
111describe("evictExcessProjects", () => {
112 it("keeps the most recently visited projects when over the cap", () => {
113 const byProjectKey = Object.fromEntries(
114 Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 2 }, (_, i) => [
115 `project-${i}`,
116 [entry({ lastVisitedAt: i })],
117 ]),
118 );
119 const next = evictExcessProjects(byProjectKey);
120 expect(Object.keys(next)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS);
121 expect(next["project-0"]).toBeUndefined();
122 expect(next["project-1"]).toBeUndefined();
123 expect(next[`project-${BROWSER_HISTORY_MAX_PROJECTS + 1}`]).toBeDefined();
124 });
125});
126
127describe("migratePersistedBrowserHistoryState", () => {
128 it("drops malformed state and invalid entries", () => {
129 expect(migratePersistedBrowserHistoryState(null)).toEqual({ byProjectKey: {} });
130 expect(migratePersistedBrowserHistoryState({ byProjectKey: 42 })).toEqual({ byProjectKey: {} });
131 const migrated = migratePersistedBrowserHistoryState({
132 byProjectKey: {
133 good: [
134 { url: "http://a.test/", lastVisitedAt: 100, title: "A" },
135 { url: "", lastVisitedAt: 100 },
136 { url: "ftp://ghost.test/", lastVisitedAt: 100 },
137 { url: "http://b.test/", lastVisitedAt: Number.NaN },
138 "junk",
139 ],
140 bad: "junk",
141 },
142 });
143 expect(migrated.byProjectKey["good"]).toEqual([
144 { url: "http://a.test/", lastVisitedAt: 100, title: "A" },
145 ]);
146 expect(migrated.byProjectKey["bad"]).toBeUndefined();
147 });
148
149 it("normalizes persisted urls with the same rules as live writes", () => {
150 const migrated = migratePersistedBrowserHistoryState({
151 byProjectKey: {
152 good: [{ url: "a.test/path#section", lastVisitedAt: 100 }],
153 },
154 });
155 expect(migrated.byProjectKey["good"]).toEqual([
156 { url: "https://a.test/path#section", lastVisitedAt: 100 },
157 ]);
158 });
159
160 it("restores MRU ordering, deduplicates normalized urls, and enforces project bounds", () => {
161 const byProjectKey = Object.fromEntries(
162 Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 1 }, (_, index) => [
163 `project-${index}`,
164 [{ url: `http://project-${index}.test/`, lastVisitedAt: index }],
165 ]),
166 );
167 byProjectKey["project-1"] = [
168 { url: "a.test/", lastVisitedAt: 1 },
169 { url: "http://newer.test/", lastVisitedAt: 3 },
170 { url: "https://a.test/", lastVisitedAt: 2 },
171 ];
172
173 const migrated = migratePersistedBrowserHistoryState({ byProjectKey });
174
175 expect(Object.keys(migrated.byProjectKey)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS);
176 expect(migrated.byProjectKey["project-0"]).toBeUndefined();
177 expect(migrated.byProjectKey["project-1"]).toEqual([
178 { url: "http://newer.test/", lastVisitedAt: 3 },
179 { url: "https://a.test/", lastVisitedAt: 2 },
180 ]);
181 });
182
183 it("rejects a lastVisitedAt outside Date's valid range", () => {
184 const migrated = migratePersistedBrowserHistoryState({
185 byProjectKey: {
186 good: [
187 { url: "http://a.test/", lastVisitedAt: 100 },
188 { url: "http://b.test/", lastVisitedAt: 1e20 },
189 ],
190 },
191 });
192 expect(migrated.byProjectKey["good"]).toEqual([{ url: "http://a.test/", lastVisitedAt: 100 }]);
193 });
194
195 it("truncates oversized persisted titles to the contract bound", () => {
196 const oversized = "x".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 100);
197 const migrated = migratePersistedBrowserHistoryState({
198 byProjectKey: {
199 good: [{ url: "http://a.test/", lastVisitedAt: 100, title: oversized }],
200 },
201 });
202 expect(migrated.byProjectKey["good"]?.[0]?.title).toHaveLength(
203 BROWSER_HISTORY_MAX_TITLE_LENGTH,
204 );
205 expect(migrated.byProjectKey["good"]?.[0]?.title).toBe(
206 oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH),
207 );
208 });
209});
210
211const threadRef = {
212 environmentId: EnvironmentId.make("env-1"),
213 threadId: ThreadId.make("thread-1"),
214};
215
216describe("useBrowserHistoryStore", () => {
217 beforeEach(() => {
218 resetBrowserHistoryForTests();
219 });
220
221 it("records visits for registered threads under the project key", () => {
222 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
223 recordVisitForThread(threadRef, "myapp.test/admin#section", 1234);
224 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([
225 { url: "https://myapp.test/admin#section", lastVisitedAt: 1234 },
226 ]);
227 });
228
229 it("does not persist when a thread is already registered to the same project", () => {
230 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
231 const persist = spyOnPersistWrites();
232
233 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
234
235 expect(persist).not.toHaveBeenCalled();
236 });
237
238 it("ignores invalid urls whether queued pending or recorded post-registration", () => {
239 recordVisitForThread(threadRef, "ftp://a.test/", 1);
240 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
241 recordVisitForThread(threadRef, "ftp://a.test/", 2);
242 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
243 });
244
245 it("sets titles update-only via the thread helper", () => {
246 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
247 setTitleForThreadUrl(threadRef, "http://a.test/", "Should not create");
248 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
249 recordVisitForThread(threadRef, "http://a.test/#/settings", 1);
250 setTitleForThreadUrl(threadRef, "http://a.test/#/settings", "My App");
251 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("My App");
252 });
253
254 it("does not persist when the title is already set", () => {
255 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
256 recordVisitForThread(threadRef, "http://a.test/", 1);
257 setTitleForThreadUrl(threadRef, "http://a.test/", "My App");
258 const persist = spyOnPersistWrites();
259 const byProjectKey = useBrowserHistoryStore.getState().byProjectKey;
260
261 setTitleForThreadUrl(threadRef, "http://a.test/", "My App");
262
263 expect(useBrowserHistoryStore.getState().byProjectKey).toBe(byProjectKey);
264 expect(persist).not.toHaveBeenCalled();
265 });
266
267 it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => {
268 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
269 recordVisitForThread(threadRef, "http://a.test/community", 1);
270 setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community");
271 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({
272 url: "http://a.test/community",
273 title: "Community",
274 });
275
276 useBrowserHistoryStore.setState({ byProjectKey: {} });
277 recordVisitForThread(threadRef, "http://a.test/community/", 1);
278 setTitleForThreadUrl(threadRef, "http://a.test/community", "Community");
279 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({
280 url: "http://a.test/community/",
281 title: "Community",
282 });
283 });
284
285 it("matches a requested localhost URL to the resolved environment host", () => {
286 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
287 recordVisitForThread(threadRef, "http://localhost:5173/app", 1);
288 setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2");
289 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App");
290 });
291
292 it("deduplicates loopback aliases and the resolved environment host", () => {
293 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" });
294 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
295 recordVisitForThread(threadRef, "http://localhost:5173/app", 1);
296 recordVisitForThread(threadRef, "http://127.0.0.1:5173/app", 2);
297 recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 3);
298 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([
299 { url: "http://localhost:5173/app", lastVisitedAt: 3 },
300 ]);
301
302 useBrowserHistoryStore.setState({ byProjectKey: {} });
303 recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 4);
304 recordVisitForThread(threadRef, "http://localhost:5173/app", 5);
305 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([
306 { url: "http://localhost:5173/app", lastVisitedAt: 5 },
307 ]);
308 });
309
310 it("does not match a genuinely different path via the trailing-slash comparison", () => {
311 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
312 recordVisitForThread(threadRef, "http://a.test/community", 1);
313 setTitleForThreadUrl(threadRef, "http://a.test/community/foo", "Foo");
314 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBeUndefined();
315 });
316
317 it("updates only the most recent entry when several share a trailing-slash comparison key", () => {
318 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
319 recordVisitForThread(threadRef, "http://a.test/community/", 1);
320 recordVisitForThread(threadRef, "http://a.test/community", 2);
321 setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community");
322 const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"];
323 expect(entries?.[0]).toMatchObject({ url: "http://a.test/community", title: "Community" });
324 expect(entries?.[1]).toMatchObject({ url: "http://a.test/community/" });
325 expect(entries?.[1]?.title).toBeUndefined();
326 });
327
328 it("truncates oversized titles to the contract bound", () => {
329 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
330 recordVisitForThread(threadRef, "http://a.test/", 1);
331 const oversized = "y".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 50);
332 setTitleForThreadUrl(threadRef, "http://a.test/", oversized);
333 const title = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title;
334 expect(title).toHaveLength(BROWSER_HISTORY_MAX_TITLE_LENGTH);
335 expect(title).toBe(oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH));
336 });
337
338 it("removes entries", () => {
339 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
340 recordVisitForThread(threadRef, "http://a.test/", 1);
341 recordVisitForThread(threadRef, "http://b.test/", 2);
342 removeUrlForThread(threadRef, "http://a.test/");
343 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([
344 "http://b.test/",
345 ]);
346 });
347});
348
349describe("pendingVisitsByThreadKey", () => {
350 beforeEach(() => {
351 resetBrowserHistoryForTests();
352 });
353
354 it("queues a visit recorded before registration and drains it in order on registration", () => {
355 recordVisitForThread(threadRef, "http://a.test/", 1);
356 recordVisitForThread(threadRef, "http://b.test/", 2);
357 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
358 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
359 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([
360 "http://b.test/",
361 "http://a.test/",
362 ]);
363 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.lastVisitedAt).toBe(2);
364 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[1]?.lastVisitedAt).toBe(1);
365 expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({});
366 });
367
368 it("caps the per-thread pending list at 10, dropping the oldest", () => {
369 for (let i = 0; i < 12; i++) {
370 recordVisitForThread(threadRef, `http://a.test/${i}`, i);
371 }
372 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
373 const urls = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url);
374 expect(urls).toHaveLength(10);
375 expect(urls).not.toContain("http://a.test/0");
376 expect(urls).not.toContain("http://a.test/1");
377 expect(urls?.[0]).toBe("http://a.test/11");
378 });
379
380 it("slots a replayed visit by timestamp instead of hoisting it above a newer live visit", () => {
381 const otherThreadRef = {
382 environmentId: EnvironmentId.make("env-1"),
383 threadId: ThreadId.make("thread-2"),
384 };
385 useBrowserHistoryStore.getState().registerThreadProject(otherThreadRef, "proj-a");
386 recordVisitForThread(otherThreadRef, "http://newer.test/", 2000);
387 recordVisitForThread(threadRef, "http://older.test/", 1000);
388 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
389
390 const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"];
391 expect(entries?.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]);
392 // `entries[0]` being the most recent is the invariant `evictExcessProjects` relies on.
393 expect(entries?.[0]?.lastVisitedAt).toBe(2000);
394 });
395});
396
397describe("pendingTitlesByThreadKey", () => {
398 beforeEach(() => {
399 resetBrowserHistoryForTests();
400 });
401
402 it("buffers a title set before registration and applies it once the matching visit drains", () => {
403 recordVisitForThread(threadRef, "http://a.test/", 1);
404 setTitleForThreadUrl(threadRef, "http://a.test/", "My App");
405 expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
406
407 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
408
409 const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"];
410 expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" });
411 expect(useBrowserHistoryStore.getState().pendingTitlesByThreadKey).toEqual({});
412 });
413
414 it("preserves environment host matching while a title is pending", () => {
415 recordVisitForThread(threadRef, "http://localhost:5173/app", 1);
416 setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2");
417 useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
418 expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App");
419 });
420});
421
422describe("mergeBrowserHistoryState", () => {
423 it("sanitizes same-version corrupt persisted data and preserves actions", () => {
424 // `migrate` only runs when versions differ; `merge` runs on every rehydrate.
425 const current = useBrowserHistoryStore.getState();
426 const merged = mergeBrowserHistoryState(
427 {
428 byProjectKey: {
429 a: [{ url: "ftp://bad.test/", lastVisitedAt: 1 }],
430 b: [{ url: "http://ok.test/", lastVisitedAt: 5 }],
431 },
432 projectKeyByThreadKey: { good: "b", stale: "a", malformed: 42 },
433 },
434 current,
435 );
436 expect(merged.byProjectKey).toEqual({
437 b: [{ url: "http://ok.test/", lastVisitedAt: 5 }],
438 });
439 expect(typeof merged.recordVisit).toBe("function");
440 expect(merged.projectKeyByThreadKey).toEqual({ good: "b" });
441 expect(merged.pendingVisitsByThreadKey).toEqual({});
442 expect(merged.pendingTitlesByThreadKey).toEqual({});
443 });
444});
apps/web/src/browserHistoryStore.ts created+398
......@@ -0,0 +1,398 @@
1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import type { ScopedThreadRef } from "@t3tools/contracts";
3import { create } from "zustand";
4import { createJSONStorage, persist } from "zustand/middleware";
5import { useShallow } from "zustand/react/shallow";
6
7import { normalizePreviewUrl } from "@t3tools/shared/preview";
8import { readPreparedConnection } from "~/state/session";
9
10import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver";
11import { resolveStorage } from "./lib/storage";
12
13export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: string };
14
15export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50;
16export const BROWSER_HISTORY_MAX_PROJECTS = 20;
17export const BROWSER_HISTORY_MAX_URL_LENGTH = 2048;
18export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512;
19const MAX_VALID_DATE_MS = 8_640_000_000_000_000;
20
21export function isValidHistoryTimestamp(value: unknown): value is number {
22 return (
23 typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_VALID_DATE_MS
24 );
25}
26
27export function normalizeHistoryUrl(raw: string): string | null {
28 let parsed: URL;
29 try {
30 parsed = new URL(normalizePreviewUrl(raw));
31 } catch {
32 return null;
33 }
34 parsed.username = parsed.password = "";
35 return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href;
36}
37
38export function titleLookupKey(normalized: string, environmentHostname?: string | null): string {
39 const parsed = new URL(visitLookupKey(normalized, environmentHostname));
40 if (parsed.pathname !== "/" && parsed.pathname.endsWith("/"))
41 parsed.pathname = parsed.pathname.slice(0, -1);
42 return parsed.href;
43}
44
45function visitLookupKey(normalized: string, environmentHostname?: string | null): string {
46 const parsed = new URL(normalized);
47 const host = normalizeHostname(parsed.hostname);
48 const environmentHost = environmentHostname && normalizeHostname(environmentHostname);
49 if (isLocalLoopbackHost(host) || host === "0.0.0.0" || host === environmentHost)
50 parsed.hostname = "local";
51 return parsed.href;
52}
53
54function isStableLocalUrl(normalized: string): boolean {
55 const host = normalizeHostname(new URL(normalized).hostname);
56 return isLocalLoopbackHost(host) || host === "0.0.0.0";
57}
58
59export function upsertHistoryEntry(
60 entries: ReadonlyArray<BrowserHistoryEntry>,
61 url: string,
62 at: number,
63 options?: { insertOrdered?: boolean; environmentHostname?: string | null },
64): BrowserHistoryEntry[] {
65 const key = visitLookupKey(url, options?.environmentHostname);
66 const existing = entries.find(
67 (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) === key,
68 );
69 const rest = entries.filter(
70 (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) !== key,
71 );
72 const visitedAt =
73 options?.insertOrdered && existing && existing.lastVisitedAt > at ? existing.lastVisitedAt : at;
74 const storedUrl =
75 existing && (isStableLocalUrl(existing.url) || !isStableLocalUrl(url)) ? existing.url : url;
76 const entry: BrowserHistoryEntry = existing
77 ? { ...existing, url: storedUrl, lastVisitedAt: visitedAt }
78 : { url, lastVisitedAt: visitedAt };
79 if (!options?.insertOrdered)
80 return [entry, ...rest].slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
81 const index = rest.findIndex((candidate) => candidate.lastVisitedAt < entry.lastVisitedAt);
82 const next = index === -1 ? [...rest, entry] : rest.toSpliced(index, 0, entry);
83 return next.slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
84}
85
86export function evictExcessProjects(
87 byProjectKey: Record<string, BrowserHistoryEntry[]>,
88): Record<string, BrowserHistoryEntry[]> {
89 const keys = Object.keys(byProjectKey);
90 if (keys.length <= BROWSER_HISTORY_MAX_PROJECTS) return byProjectKey;
91 const kept = keys
92 .toSorted(
93 (a, b) =>
94 (byProjectKey[b]?.[0]?.lastVisitedAt ?? 0) - (byProjectKey[a]?.[0]?.lastVisitedAt ?? 0),
95 )
96 .slice(0, BROWSER_HISTORY_MAX_PROJECTS);
97 return Object.fromEntries(kept.map((key) => [key, byProjectKey[key] ?? []]));
98}
99
100export function migratePersistedBrowserHistoryState(persistedState: unknown): {
101 byProjectKey: Record<string, BrowserHistoryEntry[]>;
102} {
103 if (!persistedState || typeof persistedState !== "object") return { byProjectKey: {} };
104 const raw = (persistedState as { byProjectKey?: unknown }).byProjectKey;
105 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byProjectKey: {} };
106 const byProjectKey: Record<string, BrowserHistoryEntry[]> = {};
107 for (const [projectKey, value] of Object.entries(raw as Record<string, unknown>)) {
108 if (!Array.isArray(value)) continue;
109 const seenUrls = new Set<string>();
110 const entries = value
111 .flatMap<BrowserHistoryEntry>((candidate) => {
112 if (!candidate || typeof candidate !== "object") return [];
113 const { url, lastVisitedAt, title } = candidate as Record<string, unknown>;
114 if (typeof url !== "string") return [];
115 const normalizedUrl = normalizeHistoryUrl(url);
116 if (!normalizedUrl) return [];
117 if (!isValidHistoryTimestamp(lastVisitedAt)) return [];
118 return [
119 {
120 url: normalizedUrl,
121 lastVisitedAt,
122 ...(typeof title === "string" && title.length > 0
123 ? { title: title.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH) }
124 : {}),
125 },
126 ];
127 })
128 .toSorted((a, b) => b.lastVisitedAt - a.lastVisitedAt)
129 .filter((entry) => {
130 const key = visitLookupKey(entry.url);
131 if (seenUrls.has(key)) return false;
132 seenUrls.add(key);
133 return true;
134 })
135 .slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
136 if (entries.length > 0) byProjectKey[projectKey] = entries;
137 }
138 return { byProjectKey: evictExcessProjects(byProjectKey) };
139}
140
141const BROWSER_HISTORY_STORAGE_KEY = "t3code:browser-history:v1";
142
143const PENDING_MAX_PER_THREAD = 10;
144const PENDING_MAX_THREADS = 20;
145
146type PendingVisit = { url: string; at: number; environmentHostname: string | null };
147type PendingTitle = { url: string; title: string; environmentHostname: string | null | undefined };
148
149interface BrowserHistoryStoreState {
150 byProjectKey: Record<string, BrowserHistoryEntry[]>;
151 projectKeyByThreadKey: Record<string, string>;
152 pendingVisitsByThreadKey: Record<string, PendingVisit[]>;
153 pendingTitlesByThreadKey: Record<string, PendingTitle[]>;
154 recordVisit: (
155 projectKey: string,
156 url: string,
157 at: number,
158 options?: { insertOrdered?: boolean; environmentHostname?: string | null },
159 ) => void;
160 setTitleForUrl: (
161 projectKey: string,
162 url: string,
163 title: string,
164 environmentHostname?: string | null,
165 ) => void;
166 removeUrl: (projectKey: string, url: string) => void;
167 registerThreadProject: (ref: ScopedThreadRef, projectKey: string) => void;
168}
169
170function addPendingByThread<T>(
171 pendingByThreadKey: Record<string, T[]>,
172 threadKey: string,
173 item: T,
174): Record<string, T[]> {
175 const existing = pendingByThreadKey[threadKey] ?? [];
176 const next = { ...pendingByThreadKey };
177 next[threadKey] = [...existing, item].slice(-PENDING_MAX_PER_THREAD);
178 const keys = Object.keys(next);
179 if (keys.length > PENDING_MAX_THREADS) {
180 const oldestKey = keys[0];
181 if (oldestKey !== undefined && oldestKey !== threadKey) delete next[oldestKey];
182 }
183 return next;
184}
185
186export const useBrowserHistoryStore = create<BrowserHistoryStoreState>()(
187 persist(
188 (set, get) => ({
189 byProjectKey: {},
190 projectKeyByThreadKey: {},
191 pendingVisitsByThreadKey: {},
192 pendingTitlesByThreadKey: {},
193 recordVisit: (projectKey, url, at, options) => {
194 const normalized = normalizeHistoryUrl(url);
195 if (!normalized) return;
196 set((state) => {
197 return {
198 byProjectKey: evictExcessProjects({
199 ...state.byProjectKey,
200 [projectKey]: upsertHistoryEntry(
201 state.byProjectKey[projectKey] ?? [],
202 normalized,
203 at,
204 options,
205 ),
206 }),
207 };
208 });
209 },
210 setTitleForUrl: (projectKey, url, title, environmentHostname) => {
211 const normalized = normalizeHistoryUrl(url);
212 const state = get();
213 const entries = state.byProjectKey[projectKey];
214 const trimmed = title.trim().slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH);
215 if (!normalized || !entries || trimmed.length === 0) return;
216 const key = titleLookupKey(normalized, environmentHostname);
217 const index = entries.findIndex(
218 (candidate) => titleLookupKey(candidate.url, environmentHostname) === key,
219 );
220 if (index === -1 || entries[index]?.title === trimmed) return;
221 set({
222 byProjectKey: {
223 ...state.byProjectKey,
224 [projectKey]: entries.map((candidate, candidateIndex) =>
225 candidateIndex === index ? { ...candidate, title: trimmed } : candidate,
226 ),
227 },
228 });
229 },
230 removeUrl: (projectKey, url) => {
231 const normalized = normalizeHistoryUrl(url);
232 const state = get();
233 const entries = state.byProjectKey[projectKey];
234 if (!normalized || !entries) return;
235 const next = entries.filter((candidate) => candidate.url !== normalized);
236 if (next.length === entries.length) return;
237 if (next.length === 0) {
238 const { [projectKey]: _removed, ...rest } = state.byProjectKey;
239 set({ byProjectKey: rest });
240 return;
241 }
242 set({ byProjectKey: { ...state.byProjectKey, [projectKey]: next } });
243 },
244 registerThreadProject: (ref, projectKey) => {
245 const threadKey = scopedThreadKey(ref);
246 const state = get();
247 const pendingVisits = state.pendingVisitsByThreadKey[threadKey];
248 const pendingTitles = state.pendingTitlesByThreadKey[threadKey];
249 if (
250 state.projectKeyByThreadKey[threadKey] === projectKey &&
251 !pendingVisits &&
252 !pendingTitles
253 ) {
254 return;
255 }
256 const nextPendingVisits = { ...state.pendingVisitsByThreadKey };
257 const nextPendingTitles = { ...state.pendingTitlesByThreadKey };
258 delete nextPendingVisits[threadKey];
259 delete nextPendingTitles[threadKey];
260 set({
261 projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey },
262 pendingVisitsByThreadKey: nextPendingVisits,
263 pendingTitlesByThreadKey: nextPendingTitles,
264 });
265 for (const visit of pendingVisits ?? [])
266 get().recordVisit(projectKey, visit.url, visit.at, {
267 insertOrdered: true,
268 environmentHostname: visit.environmentHostname,
269 });
270 for (const pendingTitle of pendingTitles ?? [])
271 get().setTitleForUrl(
272 projectKey,
273 pendingTitle.url,
274 pendingTitle.title,
275 pendingTitle.environmentHostname,
276 );
277 },
278 }),
279 {
280 name: BROWSER_HISTORY_STORAGE_KEY,
281 version: 1,
282 storage: createJSONStorage(() =>
283 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
284 ),
285 partialize: (state) => ({
286 byProjectKey: state.byProjectKey,
287 projectKeyByThreadKey: state.projectKeyByThreadKey,
288 }),
289 migrate: migratePersistedBrowserHistoryState,
290 merge: mergeBrowserHistoryState,
291 },
292 ),
293);
294
295export function mergeBrowserHistoryState(
296 persistedState: unknown,
297 currentState: BrowserHistoryStoreState,
298): BrowserHistoryStoreState {
299 const migrated = migratePersistedBrowserHistoryState(persistedState);
300 return {
301 ...currentState,
302 ...migrated,
303 projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey),
304 pendingVisitsByThreadKey: {},
305 pendingTitlesByThreadKey: {},
306 };
307}
308
309function migratePersistedThreadProjectKeys(
310 persistedState: unknown,
311 byProjectKey: Record<string, BrowserHistoryEntry[]>,
312): Record<string, string> {
313 if (!persistedState || typeof persistedState !== "object") return {};
314 const raw = (persistedState as { projectKeyByThreadKey?: unknown }).projectKeyByThreadKey;
315 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
316 return Object.fromEntries(
317 Object.entries(raw as Record<string, unknown>)
318 .filter(
319 (entry): entry is [string, string] =>
320 typeof entry[1] === "string" && entry[1] in byProjectKey,
321 )
322 .slice(-100),
323 );
324}
325
326export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: number): void {
327 const threadKey = scopedThreadKey(ref);
328 const state = useBrowserHistoryStore.getState();
329 const projectKey = state.projectKeyByThreadKey[threadKey];
330 const visitAt = at ?? Date.now();
331 const connection = readPreparedConnection(ref.environmentId);
332 const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null;
333 if (!projectKey) {
334 useBrowserHistoryStore.setState({
335 pendingVisitsByThreadKey: addPendingByThread(state.pendingVisitsByThreadKey, threadKey, {
336 url,
337 at: visitAt,
338 environmentHostname,
339 }),
340 });
341 return;
342 }
343 state.recordVisit(projectKey, url, visitAt, { environmentHostname });
344}
345
346export function setTitleForThreadUrl(
347 ref: ScopedThreadRef,
348 url: string,
349 title: string,
350 environmentHostname?: string | null,
351): void {
352 const threadKey = scopedThreadKey(ref);
353 const state = useBrowserHistoryStore.getState();
354 const projectKey = state.projectKeyByThreadKey[threadKey];
355 if (!projectKey) {
356 useBrowserHistoryStore.setState({
357 pendingTitlesByThreadKey: addPendingByThread(state.pendingTitlesByThreadKey, threadKey, {
358 url,
359 title,
360 environmentHostname,
361 }),
362 });
363 return;
364 }
365 state.setTitleForUrl(projectKey, url, title, environmentHostname);
366}
367
368export function removeUrlForThread(ref: ScopedThreadRef, url: string): void {
369 const state = useBrowserHistoryStore.getState();
370 const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)];
371 if (!projectKey) return;
372 state.removeUrl(projectKey, url);
373}
374
375const EMPTY_HISTORY: ReadonlyArray<BrowserHistoryEntry> = [];
376
377export function useThreadRecentHistory(
378 ref: ScopedThreadRef,
379 limit: number,
380): ReadonlyArray<BrowserHistoryEntry> {
381 return useBrowserHistoryStore(
382 useShallow((state) => {
383 const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)];
384 const entries = projectKey ? state.byProjectKey[projectKey] : undefined;
385 return entries && entries.length > 0 ? entries.slice(0, limit) : EMPTY_HISTORY;
386 }),
387 );
388}
389
390export function resetBrowserHistoryForTests(): void {
391 useBrowserHistoryStore.setState({
392 byProjectKey: {},
393 projectKeyByThreadKey: {},
394 pendingVisitsByThreadKey: {},
395 pendingTitlesByThreadKey: {},
396 });
397 useBrowserHistoryStore.persist.clearStorage();
398}
apps/web/src/components/ChatMarkdown.tsx+5-1
......@@ -52,6 +52,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsi
5252import { ScrollArea } from "./ui/scroll-area";
5353import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
5454import { stackedThreadToast, toastManager } from "./ui/toast";
55import { recordVisitForThread } from "../browserHistoryStore";
5556import { useOpenInPreferredEditor } from "../editorPreferences";
5657import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
5758import { fnv1a32 } from "../lib/diffRendering";
......@@ -1336,7 +1337,10 @@ function ChatMarkdown({
13361337 ),
13371338 );
13381339 }
1339 return openUrlInPreview({ threadRef, url, openPreview });
1340 return openUrlInPreview({ threadRef, url, openPreview }).then((result) => {
1341 if (result._tag === "Success") recordVisitForThread(threadRef, url);
1342 return result;
1343 });
13401344 },
13411345 [openPreview, threadRef],
13421346 );
apps/web/src/components/ChatView.tsx+41-4
......@@ -172,9 +172,14 @@ import {
172172 projectScriptIdFromCommand,
173173} from "~/projectScripts";
174174import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
175import { useBrowserHistoryStore } from "~/browserHistoryStore";
175176import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
176177import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
177import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings";
178import {
179 useClientSettings,
180 useClientSettingsHydrated,
181 useEnvironmentSettings,
182} from "../hooks/useSettings";
178183import { useNowMinute } from "../hooks/useNowMinute";
179184import { useNewThreadHandler } from "../hooks/useHandleNewThread";
180185import { resolveAppModelSelectionForInstance } from "../modelSelection";
......@@ -182,9 +187,11 @@ import { getTerminalFocusOwner } from "../lib/terminalFocus";
182187import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
183188import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
184189import {
190 derivePhysicalProjectKey,
185191 deriveLogicalProjectKeyFromSettings,
186192 selectProjectGroupingSettings,
187193} from "../logicalProject";
194import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping";
188195import { buildDraftThreadRouteParams } from "../threadRoutes";
189196import {
190197 type ComposerImageAttachment,
......@@ -1491,6 +1498,7 @@ function ChatViewContent(props: ChatViewProps) {
14911498 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
14921499 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
14931500 const activeThreadId = activeThread?.id ?? null;
1501 const activeThreadEnvironmentId = activeThread?.environmentId ?? null;
14941502 const runningTerminalIds = useThreadRunningTerminalIds({
14951503 environmentId: activeThread?.environmentId ?? null,
14961504 threadId: activeThreadId,
......@@ -1526,8 +1534,11 @@ function ChatViewContent(props: ChatViewProps) {
15261534 return labels;
15271535 }, [activeThreadKnownSessions]);
15281536 const activeThreadRef = useMemo(
1529 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
1530 [activeThread],
1537 () =>
1538 activeThreadEnvironmentId && activeThreadId
1539 ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId)
1540 : null,
1541 [activeThreadEnvironmentId, activeThreadId],
15311542 );
15321543 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
15331544 const [timelineAnchor, setTimelineAnchor] = useState<{
......@@ -1652,6 +1663,8 @@ function ChatViewContent(props: ChatViewProps) {
16521663 const activeProjectKey = activeProject
16531664 ? `${activeProject.environmentId}:${activeProject.workspaceRoot}`
16541665 : null;
1666 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1667 const clientSettingsHydrated = useClientSettingsHydrated();
16551668 const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState<
16561669 ReadonlyMap<string, ReadonlySet<string>>
16571670 >(() => new Map());
......@@ -1690,6 +1703,31 @@ function ChatViewContent(props: ChatViewProps) {
16901703 // drive the environment picker in BranchToolbar.
16911704 const allProjects = useProjects();
16921705 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
1706 useEffect(() => {
1707 if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return;
1708 // Reuse the sidebar's grouping so history follows the project rows the user
1709 // sees. Deriving the key from the active project alone would miss the
1710 // identity a duplicate row borrows from its siblings.
1711 const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({
1712 projects: allProjects,
1713 settings: projectGroupingSettings,
1714 primaryEnvironmentId,
1715 });
1716 useBrowserHistoryStore
1717 .getState()
1718 .registerThreadProject(
1719 activeThreadRef,
1720 logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(activeProject)) ??
1721 deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings),
1722 );
1723 }, [
1724 activeProject,
1725 activeThreadRef,
1726 allProjects,
1727 clientSettingsHydrated,
1728 primaryEnvironmentId,
1729 projectGroupingSettings,
1730 ]);
16931731 const activeEnvironment =
16941732 activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null);
16951733 const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available";
......@@ -1723,7 +1761,6 @@ function ChatViewContent(props: ChatViewProps) {
17231761 },
17241762 [retryEnvironment],
17251763 );
1726 const projectGroupingSettings = selectProjectGroupingSettings(settings);
17271764 const logicalProjectEnvironments = useMemo(() => {
17281765 if (!activeProject) return [];
17291766 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);
apps/web/src/components/preview/BrowserMockup.tsx+1-1
......@@ -1,6 +1,6 @@
11import { cn } from "~/lib/utils";
22
3/** Browser-window thumbnail glyph for the "Local" recommendation cards. */
3/** Browser-window thumbnail glyph for preview recommendation cards. */
44export function BrowserMockup({ className }: { className?: string }) {
55 return (
66 <div
apps/web/src/components/preview/PreviewEmptyState.test.tsx created+89
......@@ -0,0 +1,89 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it, vi } from "vite-plus/test";
4
5const mocks = vi.hoisted(() => ({
6 servers: [] as Array<{
7 host: string;
8 port: number;
9 url: string;
10 requestedUrl: string;
11 processName: string | null;
12 pid: number | null;
13 terminal: null;
14 source: "scanner";
15 listening: boolean;
16 }>,
17}));
18
19vi.mock("./useDiscoveredLocalServers", () => ({
20 useDiscoveredLocalServers: () => mocks.servers,
21}));
22
23import { PreviewEmptyState } from "./PreviewEmptyState";
24
25const environmentId = EnvironmentId.make("env-1");
26
27function server(port: number) {
28 return {
29 host: "localhost",
30 port,
31 url: `http://localhost:${port}`,
32 requestedUrl: `http://localhost:${port}`,
33 processName: "node",
34 pid: 1,
35 terminal: null,
36 source: "scanner" as const,
37 listening: true,
38 };
39}
40
41function render(recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>) {
42 return renderToStaticMarkup(
43 <PreviewEmptyState
44 environmentId={environmentId}
45 recentEntries={recentEntries}
46 onRemoveRecent={() => undefined}
47 onOpenUrl={() => undefined}
48 />,
49 );
50}
51
52describe("PreviewEmptyState", () => {
53 it("renders a history entry in both groups when its host:port matches a live server", () => {
54 mocks.servers = [server(5173)];
55 const html = render([
56 { url: "https://myapp.test/admin#users", lastVisitedAt: Date.now(), title: "Admin" },
57 { url: "http://localhost:5173/", lastVisitedAt: Date.now(), title: "Recent Local" },
58 ]);
59 expect(html).toContain("Recently used");
60 expect(html).toContain("Local servers");
61 expect(html).toContain("myapp.test/admin#users");
62 expect(html).toContain("Admin");
63 expect(html).toContain("Recent Local");
64 expect(html).toContain("node");
65 });
66
67 it("renders only the recents group when no servers are found", () => {
68 mocks.servers = [];
69 const html = render([{ url: "https://myapp.test/", lastVisitedAt: 0 }]);
70 expect(html).toContain("Recently used");
71 expect(html).not.toContain("Local servers");
72 });
73
74 it("keeps the original empty state when both groups are empty", () => {
75 mocks.servers = [];
76 const html = render([]);
77 expect(html).toContain("No preview yet");
78 });
79
80 it("renders an out-of-range lastVisitedAt entry without throwing", () => {
81 mocks.servers = [];
82 let html = "";
83 expect(() => {
84 html = render([{ url: "https://myapp.test/", lastVisitedAt: 1e20 }]);
85 }).not.toThrow();
86 expect(html).toContain("myapp.test");
87 expect(html).toContain("Remove");
88 });
89});
apps/web/src/components/preview/PreviewEmptyState.tsx+48-19
......@@ -1,15 +1,19 @@
11import type { EnvironmentId } from "@t3tools/contracts";
2import { Globe, RadioTower } from "lucide-react";
2import { Globe, History, RadioTower } from "lucide-react";
33
4import type { BrowserHistoryEntry } from "~/browserHistoryStore";
45import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty";
56
67import { PreviewLocalServerCard } from "./PreviewLocalServerCard";
8import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard";
79import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers";
810
911interface Props {
1012 environmentId: EnvironmentId;
1113 configuredUrls?: ReadonlyArray<string> | undefined;
1214 recentlySeenUrls?: ReadonlyArray<string> | undefined;
15 recentEntries: ReadonlyArray<BrowserHistoryEntry>;
16 onRemoveRecent: (url: string) => void;
1317 onOpenUrl: (url: string) => void;
1418}
1519
......@@ -17,6 +21,8 @@ export function PreviewEmptyState({
1721 environmentId,
1822 configuredUrls,
1923 recentlySeenUrls,
24 recentEntries,
25 onRemoveRecent,
2026 onOpenUrl,
2127}: Props) {
2228 const servers = useDiscoveredLocalServers({
......@@ -24,8 +30,9 @@ export function PreviewEmptyState({
2430 configuredUrls,
2531 recentlySeenUrls,
2632 });
33 const recents = recentEntries.filter((entry) => URL.canParse(entry.url)).slice(0, 8);
2734
28 if (servers.length === 0) {
35 if (servers.length === 0 && recents.length === 0) {
2936 return (
3037 <Empty>
3138 <EmptyMedia variant="icon">
......@@ -42,23 +49,45 @@ export function PreviewEmptyState({
4249
4350 return (
4451 <div className="flex h-full min-h-0 overflow-y-auto px-5 py-8">
45 <div className="m-auto flex w-full max-w-xl flex-col gap-3">
46 <div className="flex items-center gap-2 text-sm text-muted-foreground">
47 <RadioTower className="size-4 shrink-0" />
48 <h2 className="font-medium">Local servers</h2>
49 </div>
50 <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background">
51 {servers.map((server) => (
52 <PreviewLocalServerCard
53 key={`${server.host}:${server.port}`}
54 server={server}
55 onOpen={() => onOpenUrl(server.url)}
56 />
57 ))}
58 </div>
59 <p className="px-1 text-xs text-muted-foreground">
60 Select a listening port to open it in this browser tab.
61 </p>
52 <div className="m-auto flex w-full max-w-xl flex-col gap-6">
53 {recents.length > 0 ? (
54 <div className="flex flex-col gap-3">
55 <div className="flex items-center gap-2 text-sm text-muted-foreground">
56 <History className="size-4 shrink-0" />
57 <h2 className="font-medium">Recently used</h2>
58 </div>
59 <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background">
60 {recents.map((entry) => (
61 <PreviewRecentUrlCard
62 key={entry.url}
63 entry={entry}
64 onOpen={() => onOpenUrl(entry.url)}
65 onRemove={() => onRemoveRecent(entry.url)}
66 />
67 ))}
68 </div>
69 </div>
70 ) : null}
71 {servers.length > 0 ? (
72 <div className="flex flex-col gap-3">
73 <div className="flex items-center gap-2 text-sm text-muted-foreground">
74 <RadioTower className="size-4 shrink-0" />
75 <h2 className="font-medium">Local servers</h2>
76 </div>
77 <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background">
78 {servers.map((server) => (
79 <PreviewLocalServerCard
80 key={`${server.host}:${server.port}`}
81 server={server}
82 onOpen={() => onOpenUrl(server.requestedUrl)}
83 />
84 ))}
85 </div>
86 <p className="px-1 text-xs text-muted-foreground">
87 Select a listening port to open it in this browser tab.
88 </p>
89 </div>
90 ) : null}
6291 </div>
6392 </div>
6493 );
apps/web/src/components/preview/PreviewRecentUrlCard.tsx created+51
......@@ -0,0 +1,51 @@
1import { X } from "lucide-react";
2
3import { isValidHistoryTimestamp, type BrowserHistoryEntry } from "~/browserHistoryStore";
4import { useNowMinute } from "~/hooks/useNowMinute";
5import { formatRelativeTimeLabel } from "~/timestampFormat";
6
7import { BrowserMockup } from "./BrowserMockup";
8
9interface Props {
10 entry: BrowserHistoryEntry;
11 onOpen: () => void;
12 onRemove: () => void;
13}
14
15export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) {
16 const parsed = new URL(entry.url);
17 const path = parsed.pathname === "/" ? "" : parsed.pathname;
18 const label = `${parsed.host}${path}${parsed.search}${parsed.hash}`;
19 const visitedAt = isValidHistoryTimestamp(entry.lastVisitedAt)
20 ? formatRelativeTimeLabel(new Date(entry.lastVisitedAt).toISOString())
21 : "";
22 useNowMinute();
23 return (
24 <div className="group relative flex w-full items-center">
25 <button
26 type="button"
27 onClick={onOpen}
28 className="flex w-full items-center gap-3 px-3 py-3 pr-10 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
29 >
30 <BrowserMockup className="size-7 shrink-0" />
31 <div className="flex min-w-0 flex-1 flex-col">
32 <span className="truncate text-sm font-medium text-foreground">
33 {entry.title ?? label}
34 </span>
35 <span className="truncate text-xs text-muted-foreground">
36 {entry.title ? `${label} · ` : ""}
37 {visitedAt}
38 </span>
39 </div>
40 </button>
41 <button
42 type="button"
43 aria-label={`Remove ${label} from history`}
44 onClick={onRemove}
45 className="absolute right-3 rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100"
46 >
47 <X className="size-3.5" />
48 </button>
49 </div>
50 );
51}
apps/web/src/components/preview/PreviewView.test.tsx+39
......@@ -24,6 +24,17 @@ const mocks = vi.hoisted(() => ({
2424 toggleAnnotation: null as (() => void) | null,
2525 pictureInPicture: false,
2626 showEmptyState: false,
27 recordVisitForThread: vi.fn(),
28}));
29
30const EMPTY_HISTORY: never[] = [];
31
32vi.mock("~/browserHistoryStore", () => ({
33 recordVisitForThread: mocks.recordVisitForThread,
34 setTitleForThreadUrl: vi.fn(),
35 removeUrlForThread: vi.fn(),
36 BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT: 50,
37 useThreadRecentHistory: () => EMPTY_HISTORY,
2738}));
2839
2940vi.mock("~/state/session", () => ({
......@@ -232,6 +243,7 @@ describe("PreviewView navigation", () => {
232243 mocks.toggleAnnotation = null;
233244 mocks.pictureInPicture = false;
234245 mocks.showEmptyState = false;
246 mocks.recordVisitForThread.mockClear();
235247 });
236248
237249 it.each([
......@@ -267,6 +279,27 @@ describe("PreviewView navigation", () => {
267279 );
268280 });
269281
282 it("records a history visit with the normalized requested url on submit", async () => {
283 renderToStaticMarkup(
284 <PreviewView
285 threadRef={{
286 environmentId: EnvironmentId.make("environment-1"),
287 threadId: ThreadId.make("thread-1"),
288 }}
289 tabId="tab-1"
290 visible
291 />,
292 );
293
294 mocks.submittedUrl?.("localhost:3000/admin");
295 await vi.waitFor(() => {
296 expect(mocks.recordVisitForThread).toHaveBeenCalledWith(
297 expect.objectContaining({ threadId: expect.anything() }),
298 "http://localhost:3000/admin",
299 );
300 });
301 });
302
270303 it("maps an empty-state localhost server onto the WSL host", async () => {
271304 mocks.showEmptyState = true;
272305 renderToStaticMarkup(
......@@ -296,6 +329,12 @@ describe("PreviewView navigation", () => {
296329 },
297330 "http://172.25.85.75:5173/app?mode=test#top",
298331 );
332 await vi.waitFor(() =>
333 expect(mocks.recordVisitForThread).toHaveBeenCalledWith(
334 expect.objectContaining({ threadId: expect.anything() }),
335 "http://localhost:5173/app?mode=test#top",
336 ),
337 );
299338 });
300339
301340 it("opens and closes a thread-scoped floating preview for the active tab", async () => {
apps/web/src/components/preview/PreviewView.tsx+47-12
......@@ -11,6 +11,13 @@ import {
1111import { normalizePreviewUrl } from "@t3tools/shared/preview";
1212import { useCallback, useEffect, useRef, useState } from "react";
1313
14import {
15 BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,
16 recordVisitForThread,
17 removeUrlForThread,
18 setTitleForThreadUrl,
19 useThreadRecentHistory,
20} from "~/browserHistoryStore";
1421import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore";
1522import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";
1623import { ensureLocalApi } from "~/localApi";
......@@ -20,6 +27,7 @@ import {
2027 useThreadPreviewState,
2128} from "~/previewStateStore";
2229import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
30import { useEnvironmentHttpBaseUrl } from "~/state/environments";
2331import { previewEnvironment } from "~/state/preview";
2432import { useAtomCommand } from "~/state/use-atom-command";
2533import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
......@@ -83,12 +91,24 @@ export function PreviewView({
8391 const activeRecordingTabIds = useActiveBrowserRecordingTabIds();
8492 const pickActiveRef = useRef(false);
8593 const isMountedRef = useRef(true);
94 // Kept in sync so the title effect can depend on the stable thread key
95 // instead of the thread object, which is recreated on every update.
96 const threadRefRef = useRef(threadRef);
97 threadRefRef.current = threadRef;
8698 const previewState = useThreadPreviewState(threadRef);
99 const recentHistoryEntries = useThreadRecentHistory(
100 threadRef,
101 BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,
102 );
87103 const miniPlayer = usePreviewMiniPlayerStore((state) =>
88104 selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),
89105 );
90106 const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);
91107 const addImage = useComposerDraftStore((store) => store.addImage);
108 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId);
109 const environmentHostname = environmentHttpBaseUrl
110 ? new URL(environmentHttpBaseUrl).hostname
111 : null;
92112 const open = useAtomCommand(previewEnvironment.open);
93113 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");
94114
......@@ -128,20 +148,27 @@ export function PreviewView({
128148 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
129149 );
130150
151 const navUrl = navStatus._tag === "Success" ? navStatus.url : null;
152 const navTitle = navStatus._tag === "Success" ? navStatus.title : null;
153 const latestHistoryUrl = recentHistoryEntries[0]?.url;
154 const threadKey = scopedThreadKey(threadRef);
155 useEffect(() => {
156 if (!navUrl || !navTitle || !latestHistoryUrl) return;
157 // Agent-driven pages only enrich an existing requested URL.
158 setTitleForThreadUrl(threadRefRef.current, navUrl, navTitle, environmentHostname);
159 // threadKey stands in for threadRef, whose identity churns on every thread update.
160 }, [environmentHostname, latestHistoryUrl, navTitle, navUrl, threadKey]);
161
131162 const navigateToResolvedUrl = useCallback(
132163 async (resolvedUrl: string) => {
133164 if (runtimeTabId && previewBridge) {
134 // Drive the webview imperatively; `usePreviewBridge` mirrors the
135 // resolved URL back to the server so other clients stay in sync.
165 // The bridge mirrors the resolved URL back to the server.
136166 await previewBridge.navigate(runtimeTabId, resolvedUrl);
137167 rememberPreviewUrl(threadRef, resolvedUrl);
138 } else {
139 await openPreviewSession({
140 openPreview: open,
141 threadRef,
142 url: resolvedUrl,
143 });
168 return true;
144169 }
170 const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl });
171 return result._tag === "Success";
145172 },
146173 [open, runtimeTabId, threadRef],
147174 );
......@@ -149,23 +176,29 @@ export function PreviewView({
149176 const handleSubmitUrl = useCallback(
150177 async (next: string) => {
151178 try {
152 await navigateToResolvedUrl(normalizePreviewUrl(next));
179 const normalized = normalizePreviewUrl(next);
180 if (await navigateToResolvedUrl(normalized)) {
181 recordVisitForThread(threadRef, normalized);
182 }
153183 } catch {
154184 // Server-side `failed` event renders the unreachable view.
155185 }
156186 },
157 [navigateToResolvedUrl],
187 [navigateToResolvedUrl, threadRef],
158188 );
159189
160190 const handleOpenServerUrl = useCallback(
161191 async (next: string) => {
162192 try {
163 await navigateToResolvedUrl(resolveDiscoveredServerUrl(threadRef.environmentId, next));
193 const resolved = resolveDiscoveredServerUrl(threadRef.environmentId, next);
194 if (await navigateToResolvedUrl(resolved)) {
195 recordVisitForThread(threadRef, next);
196 }
164197 } catch {
165198 // Server-side `failed` event renders the unreachable view.
166199 }
167200 },
168 [navigateToResolvedUrl, threadRef.environmentId],
201 [navigateToResolvedUrl, threadRef],
169202 );
170203
171204 const handleRefresh = useCallback(() => {
......@@ -680,6 +713,8 @@ export function PreviewView({
680713 environmentId={threadRef.environmentId}
681714 configuredUrls={configuredUrls}
682715 recentlySeenUrls={previewState.recentlySeenUrls}
716 recentEntries={recentHistoryEntries}
717 onRemoveRecent={(url) => removeUrlForThread(threadRef, url)}
683718 onOpenUrl={(next) => void handleOpenServerUrl(next)}
684719 />
685720 ) : null}
apps/web/src/components/preview/openDiscoveredPort.ts+2
......@@ -6,6 +6,7 @@ import {
66
77import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
88import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
9import { recordVisitForThread } from "~/browserHistoryStore";
910import { useRightPanelStore } from "~/rightPanelStore";
1011import { openPreviewSession } from "./openPreviewSession";
1112
......@@ -21,6 +22,7 @@ export async function openDiscoveredPort<E>(input: {
2122 url: resolvedUrl,
2223 });
2324 return mapAtomCommandResult(result, (snapshot) => {
25 recordVisitForThread(input.threadRef, input.port.url);
2426 useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);
2527 });
2628}
apps/web/src/components/preview/openTerminalLinkInPreview.ts+2
......@@ -4,6 +4,7 @@ import { isPreviewableUrl } from "@t3tools/shared/preview";
44import * as Schema from "effect/Schema";
55
66import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
7import { recordVisitForThread } from "~/browserHistoryStore";
78import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";
89import { useRightPanelStore } from "~/rightPanelStore";
910
......@@ -98,6 +99,7 @@ export async function openTerminalLinkInPreview<E>(
9899 input.fallbackToBrowser();
99100 return;
100101 }
102 recordVisitForThread(input.threadRef, input.url);
101103 applyPreviewServerSnapshot(input.threadRef, result.value);
102104 useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId);
103105 return;
apps/web/src/components/preview/useDiscoveredLocalServers.test.ts+23-1
......@@ -3,10 +3,13 @@ import { describe, expect, it } from "vite-plus/test";
33
44import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers";
55
6const scannerServer = (overrides: Partial<DiscoveredLocalServer>): DiscoveredLocalServer => ({
6const scannerServer = (
7 overrides: Partial<DiscoveredLocalServer & { requestedUrl: string }>,
8): DiscoveredLocalServer & { requestedUrl: string } => ({
79 host: "localhost",
810 port: 5173,
911 url: "http://localhost:5173",
12 requestedUrl: overrides.url ?? "http://localhost:5173",
1013 processName: "vite",
1114 pid: 1234,
1215 terminal: null,
......@@ -24,6 +27,7 @@ describe("mergeServers", () => {
2427 expect(result[0]).toMatchObject({
2528 host: "localhost",
2629 port: 5173,
30 requestedUrl: "http://localhost:5173",
2731 source: "scanner",
2832 listening: true,
2933 processName: "vite",
......@@ -56,6 +60,7 @@ describe("mergeServers", () => {
5660 expect(result[0]).toMatchObject({
5761 source: "configured",
5862 listening: false,
63 requestedUrl: "http://localhost:5173/",
5964 });
6065 });
6166
......@@ -68,6 +73,7 @@ describe("mergeServers", () => {
6873 expect(result.map((s) => s.port)).toEqual([5173, 8080]);
6974 expect(result.find((s) => s.port === 5173)?.source).toBe("scanner");
7075 expect(result.find((s) => s.port === 8080)?.source).toBe("recent");
76 expect(result.find((s) => s.port === 8080)?.requestedUrl).toBe("http://localhost:8080/");
7177 });
7278
7379 it("ignores non-loopback URLs in configured/recent inputs", () => {
......@@ -102,6 +108,22 @@ describe("mergeServers", () => {
102108 });
103109 expect(result).toHaveLength(1);
104110 });
111
112 it("keeps a scanner entry's pre-resolution requestedUrl distinct from a resolved url", () => {
113 const result = mergeServers({
114 scanner: [
115 scannerServer({
116 port: 5173,
117 url: "https://env-42.example.dev:5173/",
118 requestedUrl: "http://localhost:5173/",
119 }),
120 ],
121 configuredUrls: [],
122 recentlySeenUrls: [],
123 });
124 expect(result[0]?.url).toBe("https://env-42.example.dev:5173/");
125 expect(result[0]?.requestedUrl).toBe("http://localhost:5173/");
126 });
105127});
106128
107129describe("PreviewableServer interface", () => {
apps/web/src/components/preview/useDiscoveredLocalServers.ts+9-1
......@@ -13,6 +13,11 @@ export interface PreviewableServer extends DiscoveredLocalServer {
1313 * `configured` entry can also be `listening` when the scan enriched it.
1414 */
1515 listening: boolean;
16 /**
17 * Pre-resolution loopback url. `url` is the resolved navigation target
18 * (volatile on a remote environment); history must key off this instead.
19 */
20 requestedUrl: string;
1621}
1722
1823interface UseDiscoveredLocalServersInput {
......@@ -36,6 +41,7 @@ export function useDiscoveredLocalServers(
3641 scanner: scannerSnapshot.map((server) => ({
3742 ...server,
3843 url: resolveDiscoveredServerUrl(input.environmentId, server.url),
44 requestedUrl: server.url,
3945 })),
4046 configuredUrls: input.configuredUrls ?? [],
4147 recentlySeenUrls: input.recentlySeenUrls ?? [],
......@@ -45,7 +51,7 @@ export function useDiscoveredLocalServers(
4551}
4652
4753export function mergeServers(input: {
48 scanner: ReadonlyArray<DiscoveredLocalServer>;
54 scanner: ReadonlyArray<DiscoveredLocalServer & { requestedUrl: string }>;
4955 configuredUrls: ReadonlyArray<string>;
5056 recentlySeenUrls: ReadonlyArray<string>;
5157}): ReadonlyArray<PreviewableServer> {
......@@ -60,6 +66,7 @@ export function mergeServers(input: {
6066 host: parsed.host,
6167 port: parsed.port,
6268 url: parsed.url,
69 requestedUrl: parsed.url,
6370 processName: null,
6471 pid: null,
6572 terminal: null,
......@@ -95,6 +102,7 @@ export function mergeServers(input: {
95102 host: parsed.host,
96103 port: parsed.port,
97104 url: parsed.url,
105 requestedUrl: parsed.url,
98106 processName: null,
99107 pid: null,
100108 terminal: null,
apps/web/src/environmentGrouping.test.ts+5
......@@ -279,6 +279,11 @@ describe("environment grouping", () => {
279279 expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe(
280280 repositoryIdentity.canonicalKey,
281281 );
282 // Deriving from the stale project alone misses the identity its sibling
283 // carries, so consumers must go through the map to match the sidebar.
284 expect(
285 deriveLogicalProjectKeyFromSettings(staleWithoutRepositoryIdentity, defaultGroupingSettings),
286 ).not.toBe(repositoryIdentity.canonicalKey);
282287 });
283288
284289 it("builds one picker entry per logical project and targets the preferred environment", () => {