diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 9b201dbdbae..3c3be59b457 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -7,7 +7,8 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { readPreparedConnection } from "~/state/session"; -const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); +export const normalizeHostname = (host: string): string => + host.toLowerCase().replace(/^\[|\]$/g, ""); const parseIpv4Address = (host: string): readonly number[] | null => { const parts = normalizeHostname(host).split(".").map(Number); @@ -17,7 +18,7 @@ const parseIpv4Address = (host: string): readonly number[] | null => { : null; }; -const isLocalLoopbackHost = (host: string): boolean => { +export const isLocalLoopbackHost = (host: string): boolean => { const normalized = normalizeHostname(host); if (normalized === "localhost" || normalized === "::1") return true; return parseIpv4Address(normalized)?.[0] === 127; diff --git a/apps/web/src/browserHistoryStore.test.ts b/apps/web/src/browserHistoryStore.test.ts new file mode 100644 index 00000000000..29d27eb5535 --- /dev/null +++ b/apps/web/src/browserHistoryStore.test.ts @@ -0,0 +1,444 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +const { readPreparedConnection } = vi.hoisted(() => ({ + readPreparedConnection: vi.fn<() => { httpBaseUrl: string } | null>(() => null), +})); + +vi.mock("~/state/session", () => ({ readPreparedConnection })); + +import { + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, + BROWSER_HISTORY_MAX_PROJECTS, + BROWSER_HISTORY_MAX_TITLE_LENGTH, + type BrowserHistoryEntry, + evictExcessProjects, + mergeBrowserHistoryState, + migratePersistedBrowserHistoryState, + normalizeHistoryUrl, + recordVisitForThread, + removeUrlForThread, + resetBrowserHistoryForTests, + setTitleForThreadUrl, + upsertHistoryEntry, + useBrowserHistoryStore, +} from "./browserHistoryStore"; + +function entry(overrides: Partial = {}): BrowserHistoryEntry { + return { url: "http://localhost:3000/", lastVisitedAt: 1000, ...overrides }; +} + +beforeEach(() => readPreparedConnection.mockReturnValue(null)); +afterEach(() => vi.restoreAllMocks()); + +function spyOnPersistWrites() { + const storage = useBrowserHistoryStore.persist.getOptions().storage; + if (!storage) throw new Error("Browser history persistence storage is unavailable."); + return vi.spyOn(storage, "setItem"); +} + +describe("normalizeHistoryUrl", () => { + it("normalizes bare loopback hosts to http and keeps path/query", () => { + expect(normalizeHistoryUrl("localhost:3000/admin?tab=1")).toBe( + "http://localhost:3000/admin?tab=1", + ); + }); + + it("normalizes bare public hosts to https", () => { + expect(normalizeHistoryUrl("myapp.test")).toBe("https://myapp.test/"); + }); + + it("preserves hash routes and strips credentials", () => { + expect(normalizeHistoryUrl("http://localhost:3000/app#/route")).toBe( + "http://localhost:3000/app#/route", + ); + expect(normalizeHistoryUrl("https://user:secret@example.com/")).toBe("https://example.com/"); + }); + + it("rejects non-http(s), unparseable, and oversized urls", () => { + expect(normalizeHistoryUrl("ftp://example.com")).toBeNull(); + expect(normalizeHistoryUrl("")).toBeNull(); + expect(normalizeHistoryUrl(`http://localhost/${"a".repeat(2048)}`)).toBeNull(); + }); +}); + +describe("upsertHistoryEntry", () => { + it("prepends new urls", () => { + const next = upsertHistoryEntry([entry()], "http://localhost:5173/", 2000); + expect(next.map((e) => e.url)).toEqual(["http://localhost:5173/", "http://localhost:3000/"]); + expect(next[0]).toEqual({ url: "http://localhost:5173/", lastVisitedAt: 2000 }); + }); + + it("moves revisits to front, updates the timestamp, and keeps the title", () => { + const existing = [ + entry({ url: "http://a.test/", lastVisitedAt: 500, title: "A" }), + entry({ url: "http://b.test/", lastVisitedAt: 400 }), + ]; + const next = upsertHistoryEntry(existing, "http://b.test/", 3000); + expect(next.map((e) => e.url)).toEqual(["http://b.test/", "http://a.test/"]); + expect(next[0]?.lastVisitedAt).toBe(3000); + expect(next[1]?.title).toBe("A"); + }); + + it("caps the list at the per-project limit", () => { + const full = Array.from({ length: BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT }, (_, i) => + entry({ url: `http://localhost:${3000 + i}/`, lastVisitedAt: i }), + ); + const next = upsertHistoryEntry(full, "http://new.test/", 9999); + expect(next).toHaveLength(BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); + expect(next[0]?.url).toBe("http://new.test/"); + const lastPort = 3000 + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT - 1; + expect(next.some((e) => e.url === `http://localhost:${lastPort}/`)).toBe(false); + expect(next.some((e) => e.url === "http://localhost:3000/")).toBe(true); + }); + + it("with insertOrdered, slots an older entry below a newer one instead of prepending", () => { + const existing = [entry({ url: "http://newer.test/", lastVisitedAt: 2000 })]; + const next = upsertHistoryEntry(existing, "http://older.test/", 1000, { + insertOrdered: true, + }); + expect(next.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]); + }); + + it("with insertOrdered, replaying an older visit for an existing entry keeps its newer timestamp", () => { + const existing = [entry({ url: "http://a.test/", lastVisitedAt: 2000 })]; + const next = upsertHistoryEntry(existing, "http://a.test/", 1000, { insertOrdered: true }); + expect(next).toEqual([{ url: "http://a.test/", lastVisitedAt: 2000 }]); + }); +}); + +describe("evictExcessProjects", () => { + it("keeps the most recently visited projects when over the cap", () => { + const byProjectKey = Object.fromEntries( + Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 2 }, (_, i) => [ + `project-${i}`, + [entry({ lastVisitedAt: i })], + ]), + ); + const next = evictExcessProjects(byProjectKey); + expect(Object.keys(next)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS); + expect(next["project-0"]).toBeUndefined(); + expect(next["project-1"]).toBeUndefined(); + expect(next[`project-${BROWSER_HISTORY_MAX_PROJECTS + 1}`]).toBeDefined(); + }); +}); + +describe("migratePersistedBrowserHistoryState", () => { + it("drops malformed state and invalid entries", () => { + expect(migratePersistedBrowserHistoryState(null)).toEqual({ byProjectKey: {} }); + expect(migratePersistedBrowserHistoryState({ byProjectKey: 42 })).toEqual({ byProjectKey: {} }); + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [ + { url: "http://a.test/", lastVisitedAt: 100, title: "A" }, + { url: "", lastVisitedAt: 100 }, + { url: "ftp://ghost.test/", lastVisitedAt: 100 }, + { url: "http://b.test/", lastVisitedAt: Number.NaN }, + "junk", + ], + bad: "junk", + }, + }); + expect(migrated.byProjectKey["good"]).toEqual([ + { url: "http://a.test/", lastVisitedAt: 100, title: "A" }, + ]); + expect(migrated.byProjectKey["bad"]).toBeUndefined(); + }); + + it("normalizes persisted urls with the same rules as live writes", () => { + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [{ url: "a.test/path#section", lastVisitedAt: 100 }], + }, + }); + expect(migrated.byProjectKey["good"]).toEqual([ + { url: "https://a.test/path#section", lastVisitedAt: 100 }, + ]); + }); + + it("restores MRU ordering, deduplicates normalized urls, and enforces project bounds", () => { + const byProjectKey = Object.fromEntries( + Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 1 }, (_, index) => [ + `project-${index}`, + [{ url: `http://project-${index}.test/`, lastVisitedAt: index }], + ]), + ); + byProjectKey["project-1"] = [ + { url: "a.test/", lastVisitedAt: 1 }, + { url: "http://newer.test/", lastVisitedAt: 3 }, + { url: "https://a.test/", lastVisitedAt: 2 }, + ]; + + const migrated = migratePersistedBrowserHistoryState({ byProjectKey }); + + expect(Object.keys(migrated.byProjectKey)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS); + expect(migrated.byProjectKey["project-0"]).toBeUndefined(); + expect(migrated.byProjectKey["project-1"]).toEqual([ + { url: "http://newer.test/", lastVisitedAt: 3 }, + { url: "https://a.test/", lastVisitedAt: 2 }, + ]); + }); + + it("rejects a lastVisitedAt outside Date's valid range", () => { + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [ + { url: "http://a.test/", lastVisitedAt: 100 }, + { url: "http://b.test/", lastVisitedAt: 1e20 }, + ], + }, + }); + expect(migrated.byProjectKey["good"]).toEqual([{ url: "http://a.test/", lastVisitedAt: 100 }]); + }); + + it("truncates oversized persisted titles to the contract bound", () => { + const oversized = "x".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 100); + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [{ url: "http://a.test/", lastVisitedAt: 100, title: oversized }], + }, + }); + expect(migrated.byProjectKey["good"]?.[0]?.title).toHaveLength( + BROWSER_HISTORY_MAX_TITLE_LENGTH, + ); + expect(migrated.byProjectKey["good"]?.[0]?.title).toBe( + oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH), + ); + }); +}); + +const threadRef = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), +}; + +describe("useBrowserHistoryStore", () => { + beforeEach(() => { + resetBrowserHistoryForTests(); + }); + + it("records visits for registered threads under the project key", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "myapp.test/admin#section", 1234); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ + { url: "https://myapp.test/admin#section", lastVisitedAt: 1234 }, + ]); + }); + + it("does not persist when a thread is already registered to the same project", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + const persist = spyOnPersistWrites(); + + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + expect(persist).not.toHaveBeenCalled(); + }); + + it("ignores invalid urls whether queued pending or recorded post-registration", () => { + recordVisitForThread(threadRef, "ftp://a.test/", 1); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "ftp://a.test/", 2); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + }); + + it("sets titles update-only via the thread helper", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + setTitleForThreadUrl(threadRef, "http://a.test/", "Should not create"); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + recordVisitForThread(threadRef, "http://a.test/#/settings", 1); + setTitleForThreadUrl(threadRef, "http://a.test/#/settings", "My App"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("My App"); + }); + + it("does not persist when the title is already set", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + const persist = spyOnPersistWrites(); + const byProjectKey = useBrowserHistoryStore.getState().byProjectKey; + + setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + + expect(useBrowserHistoryStore.getState().byProjectKey).toBe(byProjectKey); + expect(persist).not.toHaveBeenCalled(); + }); + + it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/community", 1); + setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ + url: "http://a.test/community", + title: "Community", + }); + + useBrowserHistoryStore.setState({ byProjectKey: {} }); + recordVisitForThread(threadRef, "http://a.test/community/", 1); + setTitleForThreadUrl(threadRef, "http://a.test/community", "Community"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ + url: "http://a.test/community/", + title: "Community", + }); + }); + + it("matches a requested localhost URL to the resolved environment host", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://localhost:5173/app", 1); + setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); + }); + + it("deduplicates loopback aliases and the resolved environment host", () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" }); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://localhost:5173/app", 1); + recordVisitForThread(threadRef, "http://127.0.0.1:5173/app", 2); + recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 3); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ + { url: "http://localhost:5173/app", lastVisitedAt: 3 }, + ]); + + useBrowserHistoryStore.setState({ byProjectKey: {} }); + recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 4); + recordVisitForThread(threadRef, "http://localhost:5173/app", 5); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ + { url: "http://localhost:5173/app", lastVisitedAt: 5 }, + ]); + }); + + it("does not match a genuinely different path via the trailing-slash comparison", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/community", 1); + setTitleForThreadUrl(threadRef, "http://a.test/community/foo", "Foo"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBeUndefined(); + }); + + it("updates only the most recent entry when several share a trailing-slash comparison key", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/community/", 1); + recordVisitForThread(threadRef, "http://a.test/community", 2); + setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); + const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; + expect(entries?.[0]).toMatchObject({ url: "http://a.test/community", title: "Community" }); + expect(entries?.[1]).toMatchObject({ url: "http://a.test/community/" }); + expect(entries?.[1]?.title).toBeUndefined(); + }); + + it("truncates oversized titles to the contract bound", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + const oversized = "y".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 50); + setTitleForThreadUrl(threadRef, "http://a.test/", oversized); + const title = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title; + expect(title).toHaveLength(BROWSER_HISTORY_MAX_TITLE_LENGTH); + expect(title).toBe(oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH)); + }); + + it("removes entries", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + recordVisitForThread(threadRef, "http://b.test/", 2); + removeUrlForThread(threadRef, "http://a.test/"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([ + "http://b.test/", + ]); + }); +}); + +describe("pendingVisitsByThreadKey", () => { + beforeEach(() => { + resetBrowserHistoryForTests(); + }); + + it("queues a visit recorded before registration and drains it in order on registration", () => { + recordVisitForThread(threadRef, "http://a.test/", 1); + recordVisitForThread(threadRef, "http://b.test/", 2); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([ + "http://b.test/", + "http://a.test/", + ]); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.lastVisitedAt).toBe(2); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[1]?.lastVisitedAt).toBe(1); + expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({}); + }); + + it("caps the per-thread pending list at 10, dropping the oldest", () => { + for (let i = 0; i < 12; i++) { + recordVisitForThread(threadRef, `http://a.test/${i}`, i); + } + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + const urls = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url); + expect(urls).toHaveLength(10); + expect(urls).not.toContain("http://a.test/0"); + expect(urls).not.toContain("http://a.test/1"); + expect(urls?.[0]).toBe("http://a.test/11"); + }); + + it("slots a replayed visit by timestamp instead of hoisting it above a newer live visit", () => { + const otherThreadRef = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-2"), + }; + useBrowserHistoryStore.getState().registerThreadProject(otherThreadRef, "proj-a"); + recordVisitForThread(otherThreadRef, "http://newer.test/", 2000); + recordVisitForThread(threadRef, "http://older.test/", 1000); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; + expect(entries?.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]); + // `entries[0]` being the most recent is the invariant `evictExcessProjects` relies on. + expect(entries?.[0]?.lastVisitedAt).toBe(2000); + }); +}); + +describe("pendingTitlesByThreadKey", () => { + beforeEach(() => { + resetBrowserHistoryForTests(); + }); + + it("buffers a title set before registration and applies it once the matching visit drains", () => { + recordVisitForThread(threadRef, "http://a.test/", 1); + setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; + expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" }); + expect(useBrowserHistoryStore.getState().pendingTitlesByThreadKey).toEqual({}); + }); + + it("preserves environment host matching while a title is pending", () => { + recordVisitForThread(threadRef, "http://localhost:5173/app", 1); + setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); + }); +}); + +describe("mergeBrowserHistoryState", () => { + it("sanitizes same-version corrupt persisted data and preserves actions", () => { + // `migrate` only runs when versions differ; `merge` runs on every rehydrate. + const current = useBrowserHistoryStore.getState(); + const merged = mergeBrowserHistoryState( + { + byProjectKey: { + a: [{ url: "ftp://bad.test/", lastVisitedAt: 1 }], + b: [{ url: "http://ok.test/", lastVisitedAt: 5 }], + }, + projectKeyByThreadKey: { good: "b", stale: "a", malformed: 42 }, + }, + current, + ); + expect(merged.byProjectKey).toEqual({ + b: [{ url: "http://ok.test/", lastVisitedAt: 5 }], + }); + expect(typeof merged.recordVisit).toBe("function"); + expect(merged.projectKeyByThreadKey).toEqual({ good: "b" }); + expect(merged.pendingVisitsByThreadKey).toEqual({}); + expect(merged.pendingTitlesByThreadKey).toEqual({}); + }); +}); diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts new file mode 100644 index 00000000000..4c0a560817b --- /dev/null +++ b/apps/web/src/browserHistoryStore.ts @@ -0,0 +1,398 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { useShallow } from "zustand/react/shallow"; + +import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { readPreparedConnection } from "~/state/session"; + +import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver"; +import { resolveStorage } from "./lib/storage"; + +export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: string }; + +export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50; +export const BROWSER_HISTORY_MAX_PROJECTS = 20; +export const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; +export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512; +const MAX_VALID_DATE_MS = 8_640_000_000_000_000; + +export function isValidHistoryTimestamp(value: unknown): value is number { + return ( + typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_VALID_DATE_MS + ); +} + +export function normalizeHistoryUrl(raw: string): string | null { + let parsed: URL; + try { + parsed = new URL(normalizePreviewUrl(raw)); + } catch { + return null; + } + parsed.username = parsed.password = ""; + return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href; +} + +export function titleLookupKey(normalized: string, environmentHostname?: string | null): string { + const parsed = new URL(visitLookupKey(normalized, environmentHostname)); + if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) + parsed.pathname = parsed.pathname.slice(0, -1); + return parsed.href; +} + +function visitLookupKey(normalized: string, environmentHostname?: string | null): string { + const parsed = new URL(normalized); + const host = normalizeHostname(parsed.hostname); + const environmentHost = environmentHostname && normalizeHostname(environmentHostname); + if (isLocalLoopbackHost(host) || host === "0.0.0.0" || host === environmentHost) + parsed.hostname = "local"; + return parsed.href; +} + +function isStableLocalUrl(normalized: string): boolean { + const host = normalizeHostname(new URL(normalized).hostname); + return isLocalLoopbackHost(host) || host === "0.0.0.0"; +} + +export function upsertHistoryEntry( + entries: ReadonlyArray, + url: string, + at: number, + options?: { insertOrdered?: boolean; environmentHostname?: string | null }, +): BrowserHistoryEntry[] { + const key = visitLookupKey(url, options?.environmentHostname); + const existing = entries.find( + (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) === key, + ); + const rest = entries.filter( + (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) !== key, + ); + const visitedAt = + options?.insertOrdered && existing && existing.lastVisitedAt > at ? existing.lastVisitedAt : at; + const storedUrl = + existing && (isStableLocalUrl(existing.url) || !isStableLocalUrl(url)) ? existing.url : url; + const entry: BrowserHistoryEntry = existing + ? { ...existing, url: storedUrl, lastVisitedAt: visitedAt } + : { url, lastVisitedAt: visitedAt }; + if (!options?.insertOrdered) + return [entry, ...rest].slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); + const index = rest.findIndex((candidate) => candidate.lastVisitedAt < entry.lastVisitedAt); + const next = index === -1 ? [...rest, entry] : rest.toSpliced(index, 0, entry); + return next.slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); +} + +export function evictExcessProjects( + byProjectKey: Record, +): Record { + const keys = Object.keys(byProjectKey); + if (keys.length <= BROWSER_HISTORY_MAX_PROJECTS) return byProjectKey; + const kept = keys + .toSorted( + (a, b) => + (byProjectKey[b]?.[0]?.lastVisitedAt ?? 0) - (byProjectKey[a]?.[0]?.lastVisitedAt ?? 0), + ) + .slice(0, BROWSER_HISTORY_MAX_PROJECTS); + return Object.fromEntries(kept.map((key) => [key, byProjectKey[key] ?? []])); +} + +export function migratePersistedBrowserHistoryState(persistedState: unknown): { + byProjectKey: Record; +} { + if (!persistedState || typeof persistedState !== "object") return { byProjectKey: {} }; + const raw = (persistedState as { byProjectKey?: unknown }).byProjectKey; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byProjectKey: {} }; + const byProjectKey: Record = {}; + for (const [projectKey, value] of Object.entries(raw as Record)) { + if (!Array.isArray(value)) continue; + const seenUrls = new Set(); + const entries = value + .flatMap((candidate) => { + if (!candidate || typeof candidate !== "object") return []; + const { url, lastVisitedAt, title } = candidate as Record; + if (typeof url !== "string") return []; + const normalizedUrl = normalizeHistoryUrl(url); + if (!normalizedUrl) return []; + if (!isValidHistoryTimestamp(lastVisitedAt)) return []; + return [ + { + url: normalizedUrl, + lastVisitedAt, + ...(typeof title === "string" && title.length > 0 + ? { title: title.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH) } + : {}), + }, + ]; + }) + .toSorted((a, b) => b.lastVisitedAt - a.lastVisitedAt) + .filter((entry) => { + const key = visitLookupKey(entry.url); + if (seenUrls.has(key)) return false; + seenUrls.add(key); + return true; + }) + .slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); + if (entries.length > 0) byProjectKey[projectKey] = entries; + } + return { byProjectKey: evictExcessProjects(byProjectKey) }; +} + +const BROWSER_HISTORY_STORAGE_KEY = "t3code:browser-history:v1"; + +const PENDING_MAX_PER_THREAD = 10; +const PENDING_MAX_THREADS = 20; + +type PendingVisit = { url: string; at: number; environmentHostname: string | null }; +type PendingTitle = { url: string; title: string; environmentHostname: string | null | undefined }; + +interface BrowserHistoryStoreState { + byProjectKey: Record; + projectKeyByThreadKey: Record; + pendingVisitsByThreadKey: Record; + pendingTitlesByThreadKey: Record; + recordVisit: ( + projectKey: string, + url: string, + at: number, + options?: { insertOrdered?: boolean; environmentHostname?: string | null }, + ) => void; + setTitleForUrl: ( + projectKey: string, + url: string, + title: string, + environmentHostname?: string | null, + ) => void; + removeUrl: (projectKey: string, url: string) => void; + registerThreadProject: (ref: ScopedThreadRef, projectKey: string) => void; +} + +function addPendingByThread( + pendingByThreadKey: Record, + threadKey: string, + item: T, +): Record { + const existing = pendingByThreadKey[threadKey] ?? []; + const next = { ...pendingByThreadKey }; + next[threadKey] = [...existing, item].slice(-PENDING_MAX_PER_THREAD); + const keys = Object.keys(next); + if (keys.length > PENDING_MAX_THREADS) { + const oldestKey = keys[0]; + if (oldestKey !== undefined && oldestKey !== threadKey) delete next[oldestKey]; + } + return next; +} + +export const useBrowserHistoryStore = create()( + persist( + (set, get) => ({ + byProjectKey: {}, + projectKeyByThreadKey: {}, + pendingVisitsByThreadKey: {}, + pendingTitlesByThreadKey: {}, + recordVisit: (projectKey, url, at, options) => { + const normalized = normalizeHistoryUrl(url); + if (!normalized) return; + set((state) => { + return { + byProjectKey: evictExcessProjects({ + ...state.byProjectKey, + [projectKey]: upsertHistoryEntry( + state.byProjectKey[projectKey] ?? [], + normalized, + at, + options, + ), + }), + }; + }); + }, + setTitleForUrl: (projectKey, url, title, environmentHostname) => { + const normalized = normalizeHistoryUrl(url); + const state = get(); + const entries = state.byProjectKey[projectKey]; + const trimmed = title.trim().slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH); + if (!normalized || !entries || trimmed.length === 0) return; + const key = titleLookupKey(normalized, environmentHostname); + const index = entries.findIndex( + (candidate) => titleLookupKey(candidate.url, environmentHostname) === key, + ); + if (index === -1 || entries[index]?.title === trimmed) return; + set({ + byProjectKey: { + ...state.byProjectKey, + [projectKey]: entries.map((candidate, candidateIndex) => + candidateIndex === index ? { ...candidate, title: trimmed } : candidate, + ), + }, + }); + }, + removeUrl: (projectKey, url) => { + const normalized = normalizeHistoryUrl(url); + const state = get(); + const entries = state.byProjectKey[projectKey]; + if (!normalized || !entries) return; + const next = entries.filter((candidate) => candidate.url !== normalized); + if (next.length === entries.length) return; + if (next.length === 0) { + const { [projectKey]: _removed, ...rest } = state.byProjectKey; + set({ byProjectKey: rest }); + return; + } + set({ byProjectKey: { ...state.byProjectKey, [projectKey]: next } }); + }, + registerThreadProject: (ref, projectKey) => { + const threadKey = scopedThreadKey(ref); + const state = get(); + const pendingVisits = state.pendingVisitsByThreadKey[threadKey]; + const pendingTitles = state.pendingTitlesByThreadKey[threadKey]; + if ( + state.projectKeyByThreadKey[threadKey] === projectKey && + !pendingVisits && + !pendingTitles + ) { + return; + } + const nextPendingVisits = { ...state.pendingVisitsByThreadKey }; + const nextPendingTitles = { ...state.pendingTitlesByThreadKey }; + delete nextPendingVisits[threadKey]; + delete nextPendingTitles[threadKey]; + set({ + projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey }, + pendingVisitsByThreadKey: nextPendingVisits, + pendingTitlesByThreadKey: nextPendingTitles, + }); + for (const visit of pendingVisits ?? []) + get().recordVisit(projectKey, visit.url, visit.at, { + insertOrdered: true, + environmentHostname: visit.environmentHostname, + }); + for (const pendingTitle of pendingTitles ?? []) + get().setTitleForUrl( + projectKey, + pendingTitle.url, + pendingTitle.title, + pendingTitle.environmentHostname, + ); + }, + }), + { + name: BROWSER_HISTORY_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ + byProjectKey: state.byProjectKey, + projectKeyByThreadKey: state.projectKeyByThreadKey, + }), + migrate: migratePersistedBrowserHistoryState, + merge: mergeBrowserHistoryState, + }, + ), +); + +export function mergeBrowserHistoryState( + persistedState: unknown, + currentState: BrowserHistoryStoreState, +): BrowserHistoryStoreState { + const migrated = migratePersistedBrowserHistoryState(persistedState); + return { + ...currentState, + ...migrated, + projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey), + pendingVisitsByThreadKey: {}, + pendingTitlesByThreadKey: {}, + }; +} + +function migratePersistedThreadProjectKeys( + persistedState: unknown, + byProjectKey: Record, +): Record { + if (!persistedState || typeof persistedState !== "object") return {}; + const raw = (persistedState as { projectKeyByThreadKey?: unknown }).projectKeyByThreadKey; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return Object.fromEntries( + Object.entries(raw as Record) + .filter( + (entry): entry is [string, string] => + typeof entry[1] === "string" && entry[1] in byProjectKey, + ) + .slice(-100), + ); +} + +export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: number): void { + const threadKey = scopedThreadKey(ref); + const state = useBrowserHistoryStore.getState(); + const projectKey = state.projectKeyByThreadKey[threadKey]; + const visitAt = at ?? Date.now(); + const connection = readPreparedConnection(ref.environmentId); + const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null; + if (!projectKey) { + useBrowserHistoryStore.setState({ + pendingVisitsByThreadKey: addPendingByThread(state.pendingVisitsByThreadKey, threadKey, { + url, + at: visitAt, + environmentHostname, + }), + }); + return; + } + state.recordVisit(projectKey, url, visitAt, { environmentHostname }); +} + +export function setTitleForThreadUrl( + ref: ScopedThreadRef, + url: string, + title: string, + environmentHostname?: string | null, +): void { + const threadKey = scopedThreadKey(ref); + const state = useBrowserHistoryStore.getState(); + const projectKey = state.projectKeyByThreadKey[threadKey]; + if (!projectKey) { + useBrowserHistoryStore.setState({ + pendingTitlesByThreadKey: addPendingByThread(state.pendingTitlesByThreadKey, threadKey, { + url, + title, + environmentHostname, + }), + }); + return; + } + state.setTitleForUrl(projectKey, url, title, environmentHostname); +} + +export function removeUrlForThread(ref: ScopedThreadRef, url: string): void { + const state = useBrowserHistoryStore.getState(); + const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; + if (!projectKey) return; + state.removeUrl(projectKey, url); +} + +const EMPTY_HISTORY: ReadonlyArray = []; + +export function useThreadRecentHistory( + ref: ScopedThreadRef, + limit: number, +): ReadonlyArray { + return useBrowserHistoryStore( + useShallow((state) => { + const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; + const entries = projectKey ? state.byProjectKey[projectKey] : undefined; + return entries && entries.length > 0 ? entries.slice(0, limit) : EMPTY_HISTORY; + }), + ); +} + +export function resetBrowserHistoryForTests(): void { + useBrowserHistoryStore.setState({ + byProjectKey: {}, + projectKeyByThreadKey: {}, + pendingVisitsByThreadKey: {}, + pendingTitlesByThreadKey: {}, + }); + useBrowserHistoryStore.persist.clearStorage(); +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1335e6bb05b..b5d33facc96 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -52,6 +52,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsi import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { recordVisitForThread } from "../browserHistoryStore"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; @@ -1336,7 +1337,10 @@ function ChatMarkdown({ ), ); } - return openUrlInPreview({ threadRef, url, openPreview }); + return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { + if (result._tag === "Success") recordVisitForThread(threadRef, url); + return result; + }); }, [openPreview, threadRef], ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8b2b6f61357..cfbe1ac8d96 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -172,9 +172,14 @@ import { projectScriptIdFromCommand, } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { + useClientSettings, + useClientSettingsHydrated, + useEnvironmentSettings, +} from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -182,9 +187,11 @@ import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { + derivePhysicalProjectKey, deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; +import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -1491,6 +1498,7 @@ function ChatViewContent(props: ChatViewProps) { const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; + const activeThreadEnvironmentId = activeThread?.environmentId ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, @@ -1526,8 +1534,11 @@ function ChatViewContent(props: ChatViewProps) { return labels; }, [activeThreadKnownSessions]); const activeThreadRef = useMemo( - () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), - [activeThread], + () => + activeThreadEnvironmentId && activeThreadId + ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) + : null, + [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ @@ -1652,6 +1663,8 @@ function ChatViewContent(props: ChatViewProps) { const activeProjectKey = activeProject ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` : null; + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const clientSettingsHydrated = useClientSettingsHydrated(); const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< ReadonlyMap> >(() => new Map()); @@ -1690,6 +1703,31 @@ function ChatViewContent(props: ChatViewProps) { // drive the environment picker in BranchToolbar. const allProjects = useProjects(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + useEffect(() => { + if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; + // Reuse the sidebar's grouping so history follows the project rows the user + // sees. Deriving the key from the active project alone would miss the + // identity a duplicate row borrows from its siblings. + const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({ + projects: allProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }); + useBrowserHistoryStore + .getState() + .registerThreadProject( + activeThreadRef, + logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(activeProject)) ?? + deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings), + ); + }, [ + activeProject, + activeThreadRef, + allProjects, + clientSettingsHydrated, + primaryEnvironmentId, + projectGroupingSettings, + ]); const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; @@ -1723,7 +1761,6 @@ function ChatViewContent(props: ChatViewProps) { }, [retryEnvironment], ); - const projectGroupingSettings = selectProjectGroupingSettings(settings); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); diff --git a/apps/web/src/components/preview/BrowserMockup.tsx b/apps/web/src/components/preview/BrowserMockup.tsx index 3b1882bbda9..35cfbb421e7 100644 --- a/apps/web/src/components/preview/BrowserMockup.tsx +++ b/apps/web/src/components/preview/BrowserMockup.tsx @@ -1,6 +1,6 @@ import { cn } from "~/lib/utils"; -/** Browser-window thumbnail glyph for the "Local" recommendation cards. */ +/** Browser-window thumbnail glyph for preview recommendation cards. */ export function BrowserMockup({ className }: { className?: string }) { return (
({ + servers: [] as Array<{ + host: string; + port: number; + url: string; + requestedUrl: string; + processName: string | null; + pid: number | null; + terminal: null; + source: "scanner"; + listening: boolean; + }>, +})); + +vi.mock("./useDiscoveredLocalServers", () => ({ + useDiscoveredLocalServers: () => mocks.servers, +})); + +import { PreviewEmptyState } from "./PreviewEmptyState"; + +const environmentId = EnvironmentId.make("env-1"); + +function server(port: number) { + return { + host: "localhost", + port, + url: `http://localhost:${port}`, + requestedUrl: `http://localhost:${port}`, + processName: "node", + pid: 1, + terminal: null, + source: "scanner" as const, + listening: true, + }; +} + +function render(recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>) { + return renderToStaticMarkup( + undefined} + onOpenUrl={() => undefined} + />, + ); +} + +describe("PreviewEmptyState", () => { + it("renders a history entry in both groups when its host:port matches a live server", () => { + mocks.servers = [server(5173)]; + const html = render([ + { url: "https://myapp.test/admin#users", lastVisitedAt: Date.now(), title: "Admin" }, + { url: "http://localhost:5173/", lastVisitedAt: Date.now(), title: "Recent Local" }, + ]); + expect(html).toContain("Recently used"); + expect(html).toContain("Local servers"); + expect(html).toContain("myapp.test/admin#users"); + expect(html).toContain("Admin"); + expect(html).toContain("Recent Local"); + expect(html).toContain("node"); + }); + + it("renders only the recents group when no servers are found", () => { + mocks.servers = []; + const html = render([{ url: "https://myapp.test/", lastVisitedAt: 0 }]); + expect(html).toContain("Recently used"); + expect(html).not.toContain("Local servers"); + }); + + it("keeps the original empty state when both groups are empty", () => { + mocks.servers = []; + const html = render([]); + expect(html).toContain("No preview yet"); + }); + + it("renders an out-of-range lastVisitedAt entry without throwing", () => { + mocks.servers = []; + let html = ""; + expect(() => { + html = render([{ url: "https://myapp.test/", lastVisitedAt: 1e20 }]); + }).not.toThrow(); + expect(html).toContain("myapp.test"); + expect(html).toContain("Remove"); + }); +}); diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 12126c66408..3b9aacf4dfd 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -1,15 +1,19 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { Globe, RadioTower } from "lucide-react"; +import { Globe, History, RadioTower } from "lucide-react"; +import type { BrowserHistoryEntry } from "~/browserHistoryStore"; import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; +import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; interface Props { environmentId: EnvironmentId; configuredUrls?: ReadonlyArray | undefined; recentlySeenUrls?: ReadonlyArray | undefined; + recentEntries: ReadonlyArray; + onRemoveRecent: (url: string) => void; onOpenUrl: (url: string) => void; } @@ -17,6 +21,8 @@ export function PreviewEmptyState({ environmentId, configuredUrls, recentlySeenUrls, + recentEntries, + onRemoveRecent, onOpenUrl, }: Props) { const servers = useDiscoveredLocalServers({ @@ -24,8 +30,9 @@ export function PreviewEmptyState({ configuredUrls, recentlySeenUrls, }); + const recents = recentEntries.filter((entry) => URL.canParse(entry.url)).slice(0, 8); - if (servers.length === 0) { + if (servers.length === 0 && recents.length === 0) { return ( @@ -42,23 +49,45 @@ export function PreviewEmptyState({ return (
-
-
- -

Local servers

-
-
- {servers.map((server) => ( - onOpenUrl(server.url)} - /> - ))} -
-

- Select a listening port to open it in this browser tab. -

+
+ {recents.length > 0 ? ( +
+
+ +

Recently used

+
+
+ {recents.map((entry) => ( + onOpenUrl(entry.url)} + onRemove={() => onRemoveRecent(entry.url)} + /> + ))} +
+
+ ) : null} + {servers.length > 0 ? ( +
+
+ +

Local servers

+
+
+ {servers.map((server) => ( + onOpenUrl(server.requestedUrl)} + /> + ))} +
+

+ Select a listening port to open it in this browser tab. +

+
+ ) : null}
); diff --git a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx new file mode 100644 index 00000000000..892ff579d1d --- /dev/null +++ b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx @@ -0,0 +1,51 @@ +import { X } from "lucide-react"; + +import { isValidHistoryTimestamp, type BrowserHistoryEntry } from "~/browserHistoryStore"; +import { useNowMinute } from "~/hooks/useNowMinute"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { BrowserMockup } from "./BrowserMockup"; + +interface Props { + entry: BrowserHistoryEntry; + onOpen: () => void; + onRemove: () => void; +} + +export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) { + const parsed = new URL(entry.url); + const path = parsed.pathname === "/" ? "" : parsed.pathname; + const label = `${parsed.host}${path}${parsed.search}${parsed.hash}`; + const visitedAt = isValidHistoryTimestamp(entry.lastVisitedAt) + ? formatRelativeTimeLabel(new Date(entry.lastVisitedAt).toISOString()) + : ""; + useNowMinute(); + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 4121b72602f..d9671e2f2d9 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -24,6 +24,17 @@ const mocks = vi.hoisted(() => ({ toggleAnnotation: null as (() => void) | null, pictureInPicture: false, showEmptyState: false, + recordVisitForThread: vi.fn(), +})); + +const EMPTY_HISTORY: never[] = []; + +vi.mock("~/browserHistoryStore", () => ({ + recordVisitForThread: mocks.recordVisitForThread, + setTitleForThreadUrl: vi.fn(), + removeUrlForThread: vi.fn(), + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT: 50, + useThreadRecentHistory: () => EMPTY_HISTORY, })); vi.mock("~/state/session", () => ({ @@ -232,6 +243,7 @@ describe("PreviewView navigation", () => { mocks.toggleAnnotation = null; mocks.pictureInPicture = false; mocks.showEmptyState = false; + mocks.recordVisitForThread.mockClear(); }); it.each([ @@ -267,6 +279,27 @@ describe("PreviewView navigation", () => { ); }); + it("records a history visit with the normalized requested url on submit", async () => { + renderToStaticMarkup( + , + ); + + mocks.submittedUrl?.("localhost:3000/admin"); + await vi.waitFor(() => { + expect(mocks.recordVisitForThread).toHaveBeenCalledWith( + expect.objectContaining({ threadId: expect.anything() }), + "http://localhost:3000/admin", + ); + }); + }); + it("maps an empty-state localhost server onto the WSL host", async () => { mocks.showEmptyState = true; renderToStaticMarkup( @@ -296,6 +329,12 @@ describe("PreviewView navigation", () => { }, "http://172.25.85.75:5173/app?mode=test#top", ); + await vi.waitFor(() => + expect(mocks.recordVisitForThread).toHaveBeenCalledWith( + expect.objectContaining({ threadId: expect.anything() }), + "http://localhost:5173/app?mode=test#top", + ), + ); }); it("opens and closes a thread-scoped floating preview for the active tab", async () => { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index a2435627c62..6979a1a4006 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -11,6 +11,13 @@ import { import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { useCallback, useEffect, useRef, useState } from "react"; +import { + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, + recordVisitForThread, + removeUrlForThread, + setTitleForThreadUrl, + useThreadRecentHistory, +} from "~/browserHistoryStore"; import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; @@ -20,6 +27,7 @@ import { useThreadPreviewState, } from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; @@ -83,12 +91,24 @@ export function PreviewView({ const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); const pickActiveRef = useRef(false); const isMountedRef = useRef(true); + // Kept in sync so the title effect can depend on the stable thread key + // instead of the thread object, which is recreated on every update. + const threadRefRef = useRef(threadRef); + threadRefRef.current = threadRef; const previewState = useThreadPreviewState(threadRef); + const recentHistoryEntries = useThreadRecentHistory( + threadRef, + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, + ); const miniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), ); const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); const addImage = useComposerDraftStore((store) => store.addImage); + const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); + const environmentHostname = environmentHttpBaseUrl + ? new URL(environmentHttpBaseUrl).hostname + : null; const open = useAtomCommand(previewEnvironment.open); const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); @@ -128,20 +148,27 @@ export function PreviewView({ runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, ); + const navUrl = navStatus._tag === "Success" ? navStatus.url : null; + const navTitle = navStatus._tag === "Success" ? navStatus.title : null; + const latestHistoryUrl = recentHistoryEntries[0]?.url; + const threadKey = scopedThreadKey(threadRef); + useEffect(() => { + if (!navUrl || !navTitle || !latestHistoryUrl) return; + // Agent-driven pages only enrich an existing requested URL. + setTitleForThreadUrl(threadRefRef.current, navUrl, navTitle, environmentHostname); + // threadKey stands in for threadRef, whose identity churns on every thread update. + }, [environmentHostname, latestHistoryUrl, navTitle, navUrl, threadKey]); + const navigateToResolvedUrl = useCallback( async (resolvedUrl: string) => { if (runtimeTabId && previewBridge) { - // Drive the webview imperatively; `usePreviewBridge` mirrors the - // resolved URL back to the server so other clients stay in sync. + // The bridge mirrors the resolved URL back to the server. await previewBridge.navigate(runtimeTabId, resolvedUrl); rememberPreviewUrl(threadRef, resolvedUrl); - } else { - await openPreviewSession({ - openPreview: open, - threadRef, - url: resolvedUrl, - }); + return true; } + const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); + return result._tag === "Success"; }, [open, runtimeTabId, threadRef], ); @@ -149,23 +176,29 @@ export function PreviewView({ const handleSubmitUrl = useCallback( async (next: string) => { try { - await navigateToResolvedUrl(normalizePreviewUrl(next)); + const normalized = normalizePreviewUrl(next); + if (await navigateToResolvedUrl(normalized)) { + recordVisitForThread(threadRef, normalized); + } } catch { // Server-side `failed` event renders the unreachable view. } }, - [navigateToResolvedUrl], + [navigateToResolvedUrl, threadRef], ); const handleOpenServerUrl = useCallback( async (next: string) => { try { - await navigateToResolvedUrl(resolveDiscoveredServerUrl(threadRef.environmentId, next)); + const resolved = resolveDiscoveredServerUrl(threadRef.environmentId, next); + if (await navigateToResolvedUrl(resolved)) { + recordVisitForThread(threadRef, next); + } } catch { // Server-side `failed` event renders the unreachable view. } }, - [navigateToResolvedUrl, threadRef.environmentId], + [navigateToResolvedUrl, threadRef], ); const handleRefresh = useCallback(() => { @@ -680,6 +713,8 @@ export function PreviewView({ environmentId={threadRef.environmentId} configuredUrls={configuredUrls} recentlySeenUrls={previewState.recentlySeenUrls} + recentEntries={recentHistoryEntries} + onRemoveRecent={(url) => removeUrlForThread(threadRef, url)} onOpenUrl={(next) => void handleOpenServerUrl(next)} /> ) : null} diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index 664c2e33a5c..a49acbd8610 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -6,6 +6,7 @@ import { import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { recordVisitForThread } from "~/browserHistoryStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -21,6 +22,7 @@ export async function openDiscoveredPort(input: { url: resolvedUrl, }); return mapAtomCommandResult(result, (snapshot) => { + recordVisitForThread(input.threadRef, input.port.url); useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); }); } diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 312eab9eb35..f4e0373a73c 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -4,6 +4,7 @@ import { isPreviewableUrl } from "@t3tools/shared/preview"; import * as Schema from "effect/Schema"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { recordVisitForThread } from "~/browserHistoryStore"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -98,6 +99,7 @@ export async function openTerminalLinkInPreview( input.fallbackToBrowser(); return; } + recordVisitForThread(input.threadRef, input.url); applyPreviewServerSnapshot(input.threadRef, result.value); useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); return; diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts index bb3b7cd6fa8..cdc92714025 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -3,10 +3,13 @@ import { describe, expect, it } from "vite-plus/test"; import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers"; -const scannerServer = (overrides: Partial): DiscoveredLocalServer => ({ +const scannerServer = ( + overrides: Partial, +): DiscoveredLocalServer & { requestedUrl: string } => ({ host: "localhost", port: 5173, url: "http://localhost:5173", + requestedUrl: overrides.url ?? "http://localhost:5173", processName: "vite", pid: 1234, terminal: null, @@ -24,6 +27,7 @@ describe("mergeServers", () => { expect(result[0]).toMatchObject({ host: "localhost", port: 5173, + requestedUrl: "http://localhost:5173", source: "scanner", listening: true, processName: "vite", @@ -56,6 +60,7 @@ describe("mergeServers", () => { expect(result[0]).toMatchObject({ source: "configured", listening: false, + requestedUrl: "http://localhost:5173/", }); }); @@ -68,6 +73,7 @@ describe("mergeServers", () => { expect(result.map((s) => s.port)).toEqual([5173, 8080]); expect(result.find((s) => s.port === 5173)?.source).toBe("scanner"); expect(result.find((s) => s.port === 8080)?.source).toBe("recent"); + expect(result.find((s) => s.port === 8080)?.requestedUrl).toBe("http://localhost:8080/"); }); it("ignores non-loopback URLs in configured/recent inputs", () => { @@ -102,6 +108,22 @@ describe("mergeServers", () => { }); expect(result).toHaveLength(1); }); + + it("keeps a scanner entry's pre-resolution requestedUrl distinct from a resolved url", () => { + const result = mergeServers({ + scanner: [ + scannerServer({ + port: 5173, + url: "https://env-42.example.dev:5173/", + requestedUrl: "http://localhost:5173/", + }), + ], + configuredUrls: [], + recentlySeenUrls: [], + }); + expect(result[0]?.url).toBe("https://env-42.example.dev:5173/"); + expect(result[0]?.requestedUrl).toBe("http://localhost:5173/"); + }); }); describe("PreviewableServer interface", () => { diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts index 118a56b9068..77491a93c10 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -13,6 +13,11 @@ export interface PreviewableServer extends DiscoveredLocalServer { * `configured` entry can also be `listening` when the scan enriched it. */ listening: boolean; + /** + * Pre-resolution loopback url. `url` is the resolved navigation target + * (volatile on a remote environment); history must key off this instead. + */ + requestedUrl: string; } interface UseDiscoveredLocalServersInput { @@ -36,6 +41,7 @@ export function useDiscoveredLocalServers( scanner: scannerSnapshot.map((server) => ({ ...server, url: resolveDiscoveredServerUrl(input.environmentId, server.url), + requestedUrl: server.url, })), configuredUrls: input.configuredUrls ?? [], recentlySeenUrls: input.recentlySeenUrls ?? [], @@ -45,7 +51,7 @@ export function useDiscoveredLocalServers( } export function mergeServers(input: { - scanner: ReadonlyArray; + scanner: ReadonlyArray; configuredUrls: ReadonlyArray; recentlySeenUrls: ReadonlyArray; }): ReadonlyArray { @@ -60,6 +66,7 @@ export function mergeServers(input: { host: parsed.host, port: parsed.port, url: parsed.url, + requestedUrl: parsed.url, processName: null, pid: null, terminal: null, @@ -95,6 +102,7 @@ export function mergeServers(input: { host: parsed.host, port: parsed.port, url: parsed.url, + requestedUrl: parsed.url, processName: null, pid: null, terminal: null, diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 17d86ca0912..9029f1204d3 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -279,6 +279,11 @@ describe("environment grouping", () => { expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe( repositoryIdentity.canonicalKey, ); + // Deriving from the stale project alone misses the identity its sibling + // carries, so consumers must go through the map to match the sidebar. + expect( + deriveLogicalProjectKeyFromSettings(staleWithoutRepositoryIdentity, defaultGroupingSettings), + ).not.toBe(repositoryIdentity.canonicalKey); }); it("builds one picker entry per logical project and targets the preferred environment", () => {