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";...@@ -7,7 +7,8 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";
77
8import { readPreparedConnection } from "~/state/session";8import { 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
12const parseIpv4Address = (host: string): readonly number[] | null => {13const parseIpv4Address = (host: string): readonly number[] | null => {
13 const parts = normalizeHostname(host).split(".").map(Number);14 const parts = normalizeHostname(host).split(".").map(Number);
...@@ -17,7 +18,7 @@ const parseIpv4Address = (host: string): readonly number[] | null => {...@@ -17,7 +18,7 @@ const parseIpv4Address = (host: string): readonly number[] | null => {
17 : null;18 : null;
18};19};
1920
20const isLocalLoopbackHost = (host: string): boolean => {21export const isLocalLoopbackHost = (host: string): boolean => {
21 const normalized = normalizeHostname(host);22 const normalized = normalizeHostname(host);
22 if (normalized === "localhost" || normalized === "::1") return true;23 if (normalized === "localhost" || normalized === "::1") return true;
23 return parseIpv4Address(normalized)?.[0] === 127;24 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...@@ -52,6 +52,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsi
52import { ScrollArea } from "./ui/scroll-area";52import { ScrollArea } from "./ui/scroll-area";
53import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";53import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
54import { stackedThreadToast, toastManager } from "./ui/toast";54import { stackedThreadToast, toastManager } from "./ui/toast";
55import { recordVisitForThread } from "../browserHistoryStore";
55import { useOpenInPreferredEditor } from "../editorPreferences";56import { useOpenInPreferredEditor } from "../editorPreferences";
56import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";57import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
57import { fnv1a32 } from "../lib/diffRendering";58import { fnv1a32 } from "../lib/diffRendering";
...@@ -1336,7 +1337,10 @@ function ChatMarkdown({...@@ -1336,7 +1337,10 @@ function ChatMarkdown({
1336 ),1337 ),
1337 );1338 );
1338 }1339 }
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 });
1340 },1344 },
1341 [openPreview, threadRef],1345 [openPreview, threadRef],
1342 );1346 );
apps/web/src/components/ChatView.tsx+41-4
...@@ -172,9 +172,14 @@ import {...@@ -172,9 +172,14 @@ import {
172 projectScriptIdFromCommand,172 projectScriptIdFromCommand,
173} from "~/projectScripts";173} from "~/projectScripts";
174import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";174import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
175import { useBrowserHistoryStore } from "~/browserHistoryStore";
175import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";176import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
176import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";177import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
177import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings";178import {
179 useClientSettings,
180 useClientSettingsHydrated,
181 useEnvironmentSettings,
182} from "../hooks/useSettings";
178import { useNowMinute } from "../hooks/useNowMinute";183import { useNowMinute } from "../hooks/useNowMinute";
179import { useNewThreadHandler } from "../hooks/useHandleNewThread";184import { useNewThreadHandler } from "../hooks/useHandleNewThread";
180import { resolveAppModelSelectionForInstance } from "../modelSelection";185import { resolveAppModelSelectionForInstance } from "../modelSelection";
...@@ -182,9 +187,11 @@ import { getTerminalFocusOwner } from "../lib/terminalFocus";...@@ -182,9 +187,11 @@ import { getTerminalFocusOwner } from "../lib/terminalFocus";
182import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut";187import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
183import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";188import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
184import {189import {
190 derivePhysicalProjectKey,
185 deriveLogicalProjectKeyFromSettings,191 deriveLogicalProjectKeyFromSettings,
186 selectProjectGroupingSettings,192 selectProjectGroupingSettings,
187} from "../logicalProject";193} from "../logicalProject";
194import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping";
188import { buildDraftThreadRouteParams } from "../threadRoutes";195import { buildDraftThreadRouteParams } from "../threadRoutes";
189import {196import {
190 type ComposerImageAttachment,197 type ComposerImageAttachment,
...@@ -1491,6 +1498,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1491,6 +1498,7 @@ function ChatViewContent(props: ChatViewProps) {
1491 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;1498 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
1492 const canCheckoutPullRequestIntoThread = isLocalDraftThread;1499 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
1493 const activeThreadId = activeThread?.id ?? null;1500 const activeThreadId = activeThread?.id ?? null;
1501 const activeThreadEnvironmentId = activeThread?.environmentId ?? null;
1494 const runningTerminalIds = useThreadRunningTerminalIds({1502 const runningTerminalIds = useThreadRunningTerminalIds({
1495 environmentId: activeThread?.environmentId ?? null,1503 environmentId: activeThread?.environmentId ?? null,
1496 threadId: activeThreadId,1504 threadId: activeThreadId,
...@@ -1526,8 +1534,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1526,8 +1534,11 @@ function ChatViewContent(props: ChatViewProps) {
1526 return labels;1534 return labels;
1527 }, [activeThreadKnownSessions]);1535 }, [activeThreadKnownSessions]);
1528 const activeThreadRef = useMemo(1536 const activeThreadRef = useMemo(
1529 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),1537 () =>
1530 [activeThread],1538 activeThreadEnvironmentId && activeThreadId
1539 ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId)
1540 : null,
1541 [activeThreadEnvironmentId, activeThreadId],
1531 );1542 );
1532 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;1543 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1533 const [timelineAnchor, setTimelineAnchor] = useState<{1544 const [timelineAnchor, setTimelineAnchor] = useState<{
...@@ -1652,6 +1663,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1652,6 +1663,8 @@ function ChatViewContent(props: ChatViewProps) {
1652 const activeProjectKey = activeProject1663 const activeProjectKey = activeProject
1653 ? `${activeProject.environmentId}:${activeProject.workspaceRoot}`1664 ? `${activeProject.environmentId}:${activeProject.workspaceRoot}`
1654 : null;1665 : null;
1666 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1667 const clientSettingsHydrated = useClientSettingsHydrated();
1655 const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState<1668 const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState<
1656 ReadonlyMap<string, ReadonlySet<string>>1669 ReadonlyMap<string, ReadonlySet<string>>
1657 >(() => new Map());1670 >(() => new Map());
...@@ -1690,6 +1703,31 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1690,6 +1703,31 @@ function ChatViewContent(props: ChatViewProps) {
1690 // drive the environment picker in BranchToolbar.1703 // drive the environment picker in BranchToolbar.
1691 const allProjects = useProjects();1704 const allProjects = useProjects();
1692 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;1705 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 ]);
1693 const activeEnvironment =1731 const activeEnvironment =
1694 activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null);1732 activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null);
1695 const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available";1733 const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available";
...@@ -1723,7 +1761,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1723,7 +1761,6 @@ function ChatViewContent(props: ChatViewProps) {
1723 },1761 },
1724 [retryEnvironment],1762 [retryEnvironment],
1725 );1763 );
1726 const projectGroupingSettings = selectProjectGroupingSettings(settings);
1727 const logicalProjectEnvironments = useMemo(() => {1764 const logicalProjectEnvironments = useMemo(() => {
1728 if (!activeProject) return [];1765 if (!activeProject) return [];
1729 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);1766 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);
apps/web/src/components/preview/BrowserMockup.tsx+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { cn } from "~/lib/utils";1import { cn } from "~/lib/utils";
22
3/** Browser-window thumbnail glyph for the "Local" recommendation cards. */3/** Browser-window thumbnail glyph for preview recommendation cards. */
4export function BrowserMockup({ className }: { className?: string }) {4export function BrowserMockup({ className }: { className?: string }) {
5 return (5 return (
6 <div6 <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 @@...@@ -1,15 +1,19 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import { Globe, RadioTower } from "lucide-react";2import { Globe, History, RadioTower } from "lucide-react";
33
4import type { BrowserHistoryEntry } from "~/browserHistoryStore";
4import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty";5import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty";
56
6import { PreviewLocalServerCard } from "./PreviewLocalServerCard";7import { PreviewLocalServerCard } from "./PreviewLocalServerCard";
8import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard";
7import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers";9import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers";
810
9interface Props {11interface Props {
10 environmentId: EnvironmentId;12 environmentId: EnvironmentId;
11 configuredUrls?: ReadonlyArray<string> | undefined;13 configuredUrls?: ReadonlyArray<string> | undefined;
12 recentlySeenUrls?: ReadonlyArray<string> | undefined;14 recentlySeenUrls?: ReadonlyArray<string> | undefined;
15 recentEntries: ReadonlyArray<BrowserHistoryEntry>;
16 onRemoveRecent: (url: string) => void;
13 onOpenUrl: (url: string) => void;17 onOpenUrl: (url: string) => void;
14}18}
1519
...@@ -17,6 +21,8 @@ export function PreviewEmptyState({...@@ -17,6 +21,8 @@ export function PreviewEmptyState({
17 environmentId,21 environmentId,
18 configuredUrls,22 configuredUrls,
19 recentlySeenUrls,23 recentlySeenUrls,
24 recentEntries,
25 onRemoveRecent,
20 onOpenUrl,26 onOpenUrl,
21}: Props) {27}: Props) {
22 const servers = useDiscoveredLocalServers({28 const servers = useDiscoveredLocalServers({
...@@ -24,8 +30,9 @@ export function PreviewEmptyState({...@@ -24,8 +30,9 @@ export function PreviewEmptyState({
24 configuredUrls,30 configuredUrls,
25 recentlySeenUrls,31 recentlySeenUrls,
26 });32 });
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) {
29 return (36 return (
30 <Empty>37 <Empty>
31 <EmptyMedia variant="icon">38 <EmptyMedia variant="icon">
...@@ -42,23 +49,45 @@ export function PreviewEmptyState({...@@ -42,23 +49,45 @@ export function PreviewEmptyState({
4249
43 return (50 return (
44 <div className="flex h-full min-h-0 overflow-y-auto px-5 py-8">51 <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">52 <div className="m-auto flex w-full max-w-xl flex-col gap-6">
46 <div className="flex items-center gap-2 text-sm text-muted-foreground">53 {recents.length > 0 ? (
47 <RadioTower className="size-4 shrink-0" />54 <div className="flex flex-col gap-3">
48 <h2 className="font-medium">Local servers</h2>55 <div className="flex items-center gap-2 text-sm text-muted-foreground">
49 </div>56 <History className="size-4 shrink-0" />
50 <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background">57 <h2 className="font-medium">Recently used</h2>
51 {servers.map((server) => (58 </div>
52 <PreviewLocalServerCard59 <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background">
53 key={`${server.host}:${server.port}`}60 {recents.map((entry) => (
54 server={server}61 <PreviewRecentUrlCard
55 onOpen={() => onOpenUrl(server.url)}62 key={entry.url}
56 />63 entry={entry}
57 ))}64 onOpen={() => onOpenUrl(entry.url)}
58 </div>65 onRemove={() => onRemoveRecent(entry.url)}
59 <p className="px-1 text-xs text-muted-foreground">66 />
60 Select a listening port to open it in this browser tab.67 ))}
61 </p>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}
62 </div>91 </div>
63 </div>92 </div>
64 );93 );
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(() => ({...@@ -24,6 +24,17 @@ const mocks = vi.hoisted(() => ({
24 toggleAnnotation: null as (() => void) | null,24 toggleAnnotation: null as (() => void) | null,
25 pictureInPicture: false,25 pictureInPicture: false,
26 showEmptyState: false,26 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,
27}));38}));
2839
29vi.mock("~/state/session", () => ({40vi.mock("~/state/session", () => ({
...@@ -232,6 +243,7 @@ describe("PreviewView navigation", () => {...@@ -232,6 +243,7 @@ describe("PreviewView navigation", () => {
232 mocks.toggleAnnotation = null;243 mocks.toggleAnnotation = null;
233 mocks.pictureInPicture = false;244 mocks.pictureInPicture = false;
234 mocks.showEmptyState = false;245 mocks.showEmptyState = false;
246 mocks.recordVisitForThread.mockClear();
235 });247 });
236248
237 it.each([249 it.each([
...@@ -267,6 +279,27 @@ describe("PreviewView navigation", () => {...@@ -267,6 +279,27 @@ describe("PreviewView navigation", () => {
267 );279 );
268 });280 });
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
270 it("maps an empty-state localhost server onto the WSL host", async () => {303 it("maps an empty-state localhost server onto the WSL host", async () => {
271 mocks.showEmptyState = true;304 mocks.showEmptyState = true;
272 renderToStaticMarkup(305 renderToStaticMarkup(
...@@ -296,6 +329,12 @@ describe("PreviewView navigation", () => {...@@ -296,6 +329,12 @@ describe("PreviewView navigation", () => {
296 },329 },
297 "http://172.25.85.75:5173/app?mode=test#top",330 "http://172.25.85.75:5173/app?mode=test#top",
298 );331 );
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 );
299 });338 });
300339
301 it("opens and closes a thread-scoped floating preview for the active tab", async () => {340 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 {...@@ -11,6 +11,13 @@ import {
11import { normalizePreviewUrl } from "@t3tools/shared/preview";11import { normalizePreviewUrl } from "@t3tools/shared/preview";
12import { useCallback, useEffect, useRef, useState } from "react";12import { 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";
14import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore";21import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore";
15import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";22import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";
16import { ensureLocalApi } from "~/localApi";23import { ensureLocalApi } from "~/localApi";
...@@ -20,6 +27,7 @@ import {...@@ -20,6 +27,7 @@ import {
20 useThreadPreviewState,27 useThreadPreviewState,
21} from "~/previewStateStore";28} from "~/previewStateStore";
22import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";29import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
30import { useEnvironmentHttpBaseUrl } from "~/state/environments";
23import { previewEnvironment } from "~/state/preview";31import { previewEnvironment } from "~/state/preview";
24import { useAtomCommand } from "~/state/use-atom-command";32import { useAtomCommand } from "~/state/use-atom-command";
25import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";33import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
...@@ -83,12 +91,24 @@ export function PreviewView({...@@ -83,12 +91,24 @@ export function PreviewView({
83 const activeRecordingTabIds = useActiveBrowserRecordingTabIds();91 const activeRecordingTabIds = useActiveBrowserRecordingTabIds();
84 const pickActiveRef = useRef(false);92 const pickActiveRef = useRef(false);
85 const isMountedRef = useRef(true);93 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;
86 const previewState = useThreadPreviewState(threadRef);98 const previewState = useThreadPreviewState(threadRef);
99 const recentHistoryEntries = useThreadRecentHistory(
100 threadRef,
101 BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,
102 );
87 const miniPlayer = usePreviewMiniPlayerStore((state) =>103 const miniPlayer = usePreviewMiniPlayerStore((state) =>
88 selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),104 selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),
89 );105 );
90 const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);106 const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);
91 const addImage = useComposerDraftStore((store) => store.addImage);107 const addImage = useComposerDraftStore((store) => store.addImage);
108 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId);
109 const environmentHostname = environmentHttpBaseUrl
110 ? new URL(environmentHttpBaseUrl).hostname
111 : null;
92 const open = useAtomCommand(previewEnvironment.open);112 const open = useAtomCommand(previewEnvironment.open);
93 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");113 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");
94114
...@@ -128,20 +148,27 @@ export function PreviewView({...@@ -128,20 +148,27 @@ export function PreviewView({
128 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,148 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
129 );149 );
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
131 const navigateToResolvedUrl = useCallback(162 const navigateToResolvedUrl = useCallback(
132 async (resolvedUrl: string) => {163 async (resolvedUrl: string) => {
133 if (runtimeTabId && previewBridge) {164 if (runtimeTabId && previewBridge) {
134 // Drive the webview imperatively; `usePreviewBridge` mirrors the165 // The bridge mirrors the resolved URL back to the server.
135 // resolved URL back to the server so other clients stay in sync.
136 await previewBridge.navigate(runtimeTabId, resolvedUrl);166 await previewBridge.navigate(runtimeTabId, resolvedUrl);
137 rememberPreviewUrl(threadRef, resolvedUrl);167 rememberPreviewUrl(threadRef, resolvedUrl);
138 } else {168 return true;
139 await openPreviewSession({
140 openPreview: open,
141 threadRef,
142 url: resolvedUrl,
143 });
144 }169 }
170 const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl });
171 return result._tag === "Success";
145 },172 },
146 [open, runtimeTabId, threadRef],173 [open, runtimeTabId, threadRef],
147 );174 );
...@@ -149,23 +176,29 @@ export function PreviewView({...@@ -149,23 +176,29 @@ export function PreviewView({
149 const handleSubmitUrl = useCallback(176 const handleSubmitUrl = useCallback(
150 async (next: string) => {177 async (next: string) => {
151 try {178 try {
152 await navigateToResolvedUrl(normalizePreviewUrl(next));179 const normalized = normalizePreviewUrl(next);
180 if (await navigateToResolvedUrl(normalized)) {
181 recordVisitForThread(threadRef, normalized);
182 }
153 } catch {183 } catch {
154 // Server-side `failed` event renders the unreachable view.184 // Server-side `failed` event renders the unreachable view.
155 }185 }
156 },186 },
157 [navigateToResolvedUrl],187 [navigateToResolvedUrl, threadRef],
158 );188 );
159189
160 const handleOpenServerUrl = useCallback(190 const handleOpenServerUrl = useCallback(
161 async (next: string) => {191 async (next: string) => {
162 try {192 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 }
164 } catch {197 } catch {
165 // Server-side `failed` event renders the unreachable view.198 // Server-side `failed` event renders the unreachable view.
166 }199 }
167 },200 },
168 [navigateToResolvedUrl, threadRef.environmentId],201 [navigateToResolvedUrl, threadRef],
169 );202 );
170203
171 const handleRefresh = useCallback(() => {204 const handleRefresh = useCallback(() => {
...@@ -680,6 +713,8 @@ export function PreviewView({...@@ -680,6 +713,8 @@ export function PreviewView({
680 environmentId={threadRef.environmentId}713 environmentId={threadRef.environmentId}
681 configuredUrls={configuredUrls}714 configuredUrls={configuredUrls}
682 recentlySeenUrls={previewState.recentlySeenUrls}715 recentlySeenUrls={previewState.recentlySeenUrls}
716 recentEntries={recentHistoryEntries}
717 onRemoveRecent={(url) => removeUrlForThread(threadRef, url)}
683 onOpenUrl={(next) => void handleOpenServerUrl(next)}718 onOpenUrl={(next) => void handleOpenServerUrl(next)}
684 />719 />
685 ) : null}720 ) : null}
apps/web/src/components/preview/openDiscoveredPort.ts+2
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
66
7import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";7import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
8import type { OpenPreviewMutation } from "~/browser/openFileInPreview";8import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
9import { recordVisitForThread } from "~/browserHistoryStore";
9import { useRightPanelStore } from "~/rightPanelStore";10import { useRightPanelStore } from "~/rightPanelStore";
10import { openPreviewSession } from "./openPreviewSession";11import { openPreviewSession } from "./openPreviewSession";
1112
...@@ -21,6 +22,7 @@ export async function openDiscoveredPort<E>(input: {...@@ -21,6 +22,7 @@ export async function openDiscoveredPort<E>(input: {
21 url: resolvedUrl,22 url: resolvedUrl,
22 });23 });
23 return mapAtomCommandResult(result, (snapshot) => {24 return mapAtomCommandResult(result, (snapshot) => {
25 recordVisitForThread(input.threadRef, input.port.url);
24 useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);26 useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);
25 });27 });
26}28}
apps/web/src/components/preview/openTerminalLinkInPreview.ts+2
...@@ -4,6 +4,7 @@ import { isPreviewableUrl } from "@t3tools/shared/preview";...@@ -4,6 +4,7 @@ import { isPreviewableUrl } from "@t3tools/shared/preview";
4import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
55
6import type { OpenPreviewMutation } from "~/browser/openFileInPreview";6import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
7import { recordVisitForThread } from "~/browserHistoryStore";
7import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";8import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";
8import { useRightPanelStore } from "~/rightPanelStore";9import { useRightPanelStore } from "~/rightPanelStore";
910
...@@ -98,6 +99,7 @@ export async function openTerminalLinkInPreview<E>(...@@ -98,6 +99,7 @@ export async function openTerminalLinkInPreview<E>(
98 input.fallbackToBrowser();99 input.fallbackToBrowser();
99 return;100 return;
100 }101 }
102 recordVisitForThread(input.threadRef, input.url);
101 applyPreviewServerSnapshot(input.threadRef, result.value);103 applyPreviewServerSnapshot(input.threadRef, result.value);
102 useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId);104 useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId);
103 return;105 return;
apps/web/src/components/preview/useDiscoveredLocalServers.test.ts+23-1
...@@ -3,10 +3,13 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,10 +3,13 @@ import { describe, expect, it } from "vite-plus/test";
33
4import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers";4import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers";
55
6const scannerServer = (overrides: Partial<DiscoveredLocalServer>): DiscoveredLocalServer => ({6const scannerServer = (
7 overrides: Partial<DiscoveredLocalServer & { requestedUrl: string }>,
8): DiscoveredLocalServer & { requestedUrl: string } => ({
7 host: "localhost",9 host: "localhost",
8 port: 5173,10 port: 5173,
9 url: "http://localhost:5173",11 url: "http://localhost:5173",
12 requestedUrl: overrides.url ?? "http://localhost:5173",
10 processName: "vite",13 processName: "vite",
11 pid: 1234,14 pid: 1234,
12 terminal: null,15 terminal: null,
...@@ -24,6 +27,7 @@ describe("mergeServers", () => {...@@ -24,6 +27,7 @@ describe("mergeServers", () => {
24 expect(result[0]).toMatchObject({27 expect(result[0]).toMatchObject({
25 host: "localhost",28 host: "localhost",
26 port: 5173,29 port: 5173,
30 requestedUrl: "http://localhost:5173",
27 source: "scanner",31 source: "scanner",
28 listening: true,32 listening: true,
29 processName: "vite",33 processName: "vite",
...@@ -56,6 +60,7 @@ describe("mergeServers", () => {...@@ -56,6 +60,7 @@ describe("mergeServers", () => {
56 expect(result[0]).toMatchObject({60 expect(result[0]).toMatchObject({
57 source: "configured",61 source: "configured",
58 listening: false,62 listening: false,
63 requestedUrl: "http://localhost:5173/",
59 });64 });
60 });65 });
6166
...@@ -68,6 +73,7 @@ describe("mergeServers", () => {...@@ -68,6 +73,7 @@ describe("mergeServers", () => {
68 expect(result.map((s) => s.port)).toEqual([5173, 8080]);73 expect(result.map((s) => s.port)).toEqual([5173, 8080]);
69 expect(result.find((s) => s.port === 5173)?.source).toBe("scanner");74 expect(result.find((s) => s.port === 5173)?.source).toBe("scanner");
70 expect(result.find((s) => s.port === 8080)?.source).toBe("recent");75 expect(result.find((s) => s.port === 8080)?.source).toBe("recent");
76 expect(result.find((s) => s.port === 8080)?.requestedUrl).toBe("http://localhost:8080/");
71 });77 });
7278
73 it("ignores non-loopback URLs in configured/recent inputs", () => {79 it("ignores non-loopback URLs in configured/recent inputs", () => {
...@@ -102,6 +108,22 @@ describe("mergeServers", () => {...@@ -102,6 +108,22 @@ describe("mergeServers", () => {
102 });108 });
103 expect(result).toHaveLength(1);109 expect(result).toHaveLength(1);
104 });110 });
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 });
105});127});
106128
107describe("PreviewableServer interface", () => {129describe("PreviewableServer interface", () => {
apps/web/src/components/preview/useDiscoveredLocalServers.ts+9-1
...@@ -13,6 +13,11 @@ export interface PreviewableServer extends DiscoveredLocalServer {...@@ -13,6 +13,11 @@ export interface PreviewableServer extends DiscoveredLocalServer {
13 * `configured` entry can also be `listening` when the scan enriched it.13 * `configured` entry can also be `listening` when the scan enriched it.
14 */14 */
15 listening: boolean;15 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;
16}21}
1722
18interface UseDiscoveredLocalServersInput {23interface UseDiscoveredLocalServersInput {
...@@ -36,6 +41,7 @@ export function useDiscoveredLocalServers(...@@ -36,6 +41,7 @@ export function useDiscoveredLocalServers(
36 scanner: scannerSnapshot.map((server) => ({41 scanner: scannerSnapshot.map((server) => ({
37 ...server,42 ...server,
38 url: resolveDiscoveredServerUrl(input.environmentId, server.url),43 url: resolveDiscoveredServerUrl(input.environmentId, server.url),
44 requestedUrl: server.url,
39 })),45 })),
40 configuredUrls: input.configuredUrls ?? [],46 configuredUrls: input.configuredUrls ?? [],
41 recentlySeenUrls: input.recentlySeenUrls ?? [],47 recentlySeenUrls: input.recentlySeenUrls ?? [],
...@@ -45,7 +51,7 @@ export function useDiscoveredLocalServers(...@@ -45,7 +51,7 @@ export function useDiscoveredLocalServers(
45}51}
4652
47export function mergeServers(input: {53export function mergeServers(input: {
48 scanner: ReadonlyArray<DiscoveredLocalServer>;54 scanner: ReadonlyArray<DiscoveredLocalServer & { requestedUrl: string }>;
49 configuredUrls: ReadonlyArray<string>;55 configuredUrls: ReadonlyArray<string>;
50 recentlySeenUrls: ReadonlyArray<string>;56 recentlySeenUrls: ReadonlyArray<string>;
51}): ReadonlyArray<PreviewableServer> {57}): ReadonlyArray<PreviewableServer> {
...@@ -60,6 +66,7 @@ export function mergeServers(input: {...@@ -60,6 +66,7 @@ export function mergeServers(input: {
60 host: parsed.host,66 host: parsed.host,
61 port: parsed.port,67 port: parsed.port,
62 url: parsed.url,68 url: parsed.url,
69 requestedUrl: parsed.url,
63 processName: null,70 processName: null,
64 pid: null,71 pid: null,
65 terminal: null,72 terminal: null,
...@@ -95,6 +102,7 @@ export function mergeServers(input: {...@@ -95,6 +102,7 @@ export function mergeServers(input: {
95 host: parsed.host,102 host: parsed.host,
96 port: parsed.port,103 port: parsed.port,
97 url: parsed.url,104 url: parsed.url,
105 requestedUrl: parsed.url,
98 processName: null,106 processName: null,
99 pid: null,107 pid: null,
100 terminal: null,108 terminal: null,
apps/web/src/environmentGrouping.test.ts+5
...@@ -279,6 +279,11 @@ describe("environment grouping", () => {...@@ -279,6 +279,11 @@ describe("environment grouping", () => {
279 expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe(279 expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe(
280 repositoryIdentity.canonicalKey,280 repositoryIdentity.canonicalKey,
281 );281 );
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);
282 });287 });
283288
284 it("builds one picker entry per logical project and targets the preferred environment", () => {289 it("builds one picker entry per logical project and targets the preferred environment", () => {