From e2cd2383cec150ea674030d408f2a99a411c94fc Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:29:50 -0400 Subject: [PATCH 1/9] chore(ci): vouch StiensWout (#5637) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 73376110d9a..90806ab0604 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -26,6 +26,7 @@ github:notkainoa github:PatrickBauer github:realAhmedRoach github:shiroyasha9 +github:StiensWout github:Yash-Singh1 github:eggfriedrice24 github:Ymit24 From 72d673a855c730536f0cf3bb964ba523e0af9e2e Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 7 Aug 2026 19:45:56 +0100 Subject: [PATCH 2/9] feat(desktop): remember recently used sites in the Browser panel (#5270) --- apps/web/src/browser/browserTargetResolver.ts | 5 +- apps/web/src/browserHistoryStore.test.ts | 444 ++++++++++++++++++ apps/web/src/browserHistoryStore.ts | 398 ++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 6 +- apps/web/src/components/ChatView.tsx | 45 +- .../src/components/preview/BrowserMockup.tsx | 2 +- .../preview/PreviewEmptyState.test.tsx | 89 ++++ .../components/preview/PreviewEmptyState.tsx | 67 ++- .../preview/PreviewRecentUrlCard.tsx | 51 ++ .../components/preview/PreviewView.test.tsx | 39 ++ .../src/components/preview/PreviewView.tsx | 59 ++- .../components/preview/openDiscoveredPort.ts | 2 + .../preview/openTerminalLinkInPreview.ts | 2 + .../preview/useDiscoveredLocalServers.test.ts | 24 +- .../preview/useDiscoveredLocalServers.ts | 10 +- apps/web/src/environmentGrouping.test.ts | 5 + 16 files changed, 1207 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/browserHistoryStore.test.ts create mode 100644 apps/web/src/browserHistoryStore.ts create mode 100644 apps/web/src/components/preview/PreviewEmptyState.test.tsx create mode 100644 apps/web/src/components/preview/PreviewRecentUrlCard.tsx 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", () => { From 82406bce99c2cf46d1d8fbad26bfefc2ab3169ce Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:01:39 -0400 Subject: [PATCH 3/9] chore: vouch chrisdeeming (#5641) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 90806ab0604..c74a0dc48ff 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -11,6 +11,7 @@ # Keep entries sorted alphabetically. github:adityavardhansharma github:binbandit +github:chrisdeeming github:chuks-qua github:cursoragent github:gbarros-dev From f0fb406acf419c1ab3d37d5f42093c07921484b2 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 7 Aug 2026 15:41:59 -0400 Subject: [PATCH 4/9] feat(web): make sidebar artwork theme-aware (#5636) --- .../components/settings/ThemeEditorPanel.tsx | 41 +++++++++++++- apps/web/src/hooks/useSettings.test.ts | 31 ++++++++++ apps/web/src/hooks/useSettings.ts | 32 ++++++++++- apps/web/src/themePalette.test.ts | 56 ++++++++++++++++++- apps/web/src/themePalette.ts | 31 +++++++++- 5 files changed, 185 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 0074ac89304..f015fce03d0 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -165,6 +165,7 @@ export function ThemeEditorPanel({ const isEditing = editingTheme !== null; const [name, setName] = useState(""); const [activeAppearance, setActiveAppearance] = useState(initialAppearance); + const [sidebarArtwork, setSidebarArtwork] = useState(false); const [isAdvanced, setIsAdvanced] = useState(false); const [colorsByAppearance, setColorsByAppearance] = useState(() => getThemeEditorColorsByAppearance(), @@ -178,6 +179,7 @@ export function ThemeEditorPanel({ const [isInspecting, setIsInspecting] = useState(false); const [selectedRole, setSelectedRole] = useState(null); const [usageCount, setUsageCount] = useState(null); + const previousMergeTargetIdRef = useRef(null); // Null parks the panel at its default corner; a value is a dragged spot, // kept clamped so the header can always be grabbed again. const [position, setPosition] = useState<{ x: number; y: number } | null>(null); @@ -259,6 +261,9 @@ export function ThemeEditorPanel({ setName(editingTheme?.label ?? seedName ?? ""); setActiveAppearance(nextAppearance); + // Artwork is opt-in for new themes, including duplicates. Editing keeps + // the theme's existing choice. + setSidebarArtwork(editingTheme?.sidebarArtwork === true); // Themes saved by the guided editor carry the managed flag; anything // else (imports, hand-edited files, older saves) opens in advanced mode // so guided regeneration cannot silently discard hand-tuned colors. A @@ -315,6 +320,18 @@ export function ThemeEditorPanel({ // an explanation instead. const mergeTargetId = mergeTarget?.id ?? null; const takenAppearancesKey = takenAppearances.join(","); + useEffect(() => { + if (previousMergeTargetIdRef.current === mergeTargetId) return; + previousMergeTargetIdRef.current = mergeTargetId; + // A matching name makes that existing theme the surviving merge target. + // Seed theme-level options from it so adding a palette or renaming onto it + // does not silently reset them. Leaving the merge restores the edited + // theme's option (or the off-by-default choice for a new theme). + setSidebarArtwork( + mergeTarget ? mergeTarget.sidebarArtwork === true : editingTheme?.sidebarArtwork === true, + ); + }, [editingTheme, mergeTarget, mergeTargetId]); + useEffect(() => { if (isEditing || mergeTargetId === null) return; const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[]; @@ -330,8 +347,8 @@ export function ThemeEditorPanel({ // comes back when the editor closes, including on cancel. useEffect(() => { if (!open || !isDraftSeeded) return; - applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance); - }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]); + applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance, sidebarArtwork); + }, [activeAppearance, colorsByAppearance, isDraftSeeded, open, sidebarArtwork]); useEffect(() => { if (!open) return; @@ -657,6 +674,7 @@ export function ThemeEditorPanel({ ...mergeTarget.variants, ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])), }, + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), }), ); @@ -687,6 +705,7 @@ export function ThemeEditorPanel({ ...(getThemeModes(editingTheme).length > 1 ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } } : {}), + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(isAdvanced ? {} : { managed: true }), }), ); @@ -713,6 +732,7 @@ export function ThemeEditorPanel({ ...mergeTarget.variants, [activeAppearance]: colorsForSave[activeAppearance], }, + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), }), ); @@ -723,6 +743,7 @@ export function ThemeEditorPanel({ name, appearance: activeAppearance, colors: colorsForSave[activeAppearance], + ...(sidebarArtwork ? { sidebarArtwork: true } : {}), ...(isAdvanced ? {} : { managed: true }), }), ); @@ -773,6 +794,7 @@ export function ThemeEditorPanel({ name, onOpenChange, onSaved, + sidebarArtwork, simpleColorsDirtyByAppearance, takenAppearances, ]); @@ -832,6 +854,20 @@ export function ThemeEditorPanel({
); + const renderSidebarArtworkToggle = () => ( + + ); + const renderColorsHeader = () => (
@@ -1088,6 +1124,7 @@ export function ThemeEditorPanel({

) : null} {renderAppearanceButtons()} + {renderSidebarArtworkToggle()}
{renderColorsHeader()} {renderColorFields()} diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 741579661e7..b332fe13c2f 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -17,6 +17,37 @@ describe("resolveEnvironmentIdentificationMode", () => { "pill", ); }); + + it("uses a pill instead of artwork with a palette theme", () => { + expect( + resolveEnvironmentIdentificationMode({ + mode: "artwork", + settingsHydrated: true, + paletteThemeActive: true, + }), + ).toBe("pill"); + }); + + it("respects none with a palette theme", () => { + expect( + resolveEnvironmentIdentificationMode({ + mode: "none", + settingsHydrated: true, + paletteThemeActive: true, + }), + ).toBe("none"); + }); + + it("keeps artwork when the palette theme opts into it", () => { + expect( + resolveEnvironmentIdentificationMode({ + mode: "artwork", + settingsHydrated: true, + paletteThemeActive: true, + paletteThemeAllowsArtwork: true, + }), + ).toBe("artwork"); + }); }); describe("mergeEnvironmentSettings", () => { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index e58876b19f7..f4797bb775d 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -28,10 +28,17 @@ import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { APP_STAGE_LABEL } from "~/branding"; import { resolveSidebarV2Enabled } from "~/branding.logic"; import { ensureLocalApi } from "~/localApi"; +import { + getThemeDefinition, + getThemePreviewSidebarArtwork, + resolveThemeHalf, + subscribeToThemePreview, +} from "~/themePalette"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { usePrimaryEnvironment } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; +import { useTheme } from "./useTheme"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -226,15 +233,36 @@ export function useClientSettings( export function resolveEnvironmentIdentificationMode(input: { mode: EnvironmentIdentificationMode; settingsHydrated: boolean; + paletteThemeActive?: boolean; + paletteThemeAllowsArtwork?: boolean; }): EnvironmentIdentificationMode { // Avoid briefly rendering the default artwork before a persisted pill/none choice loads. - return input.settingsHydrated ? input.mode : "none"; + if (!input.settingsHydrated) return "none"; + // Stage artwork has fixed colors that can clash with palette themes. Keep an + // explicit "none", but use the theme-aware pill in place of artwork. + return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork" + ? "pill" + : input.mode; } export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode { const settingsHydrated = useClientSettingsHydrated(); const mode = useClientSettingsValue().environmentIdentificationMode; - return resolveEnvironmentIdentificationMode({ mode, settingsHydrated }); + const { resolvedTheme, theme, themeHalves } = useTheme(); + const previewSidebarArtwork = useSyncExternalStore( + subscribeToThemePreview, + getThemePreviewSidebarArtwork, + () => null, + ); + const activeTheme = resolveThemeHalf(theme, themeHalves, resolvedTheme); + const activeThemeDefinition = getThemeDefinition(activeTheme); + return resolveEnvironmentIdentificationMode({ + mode, + settingsHydrated, + paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null, + paletteThemeAllowsArtwork: + previewSidebarArtwork ?? activeThemeDefinition?.sidebarArtwork === true, + }); } /** diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 2ed4ff3891d..671b5dbb76d 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { + applyThemeColorPreview, + applyThemePalette, getThemeColorsForMode, getThemeDefinition, getThemeModes, + getThemePreviewSidebarArtwork, getThemePreferenceMode, isKnownThemePreference, getCustomThemes, @@ -15,6 +18,7 @@ import { resolveDesktopTheme, resolveThemeAppearance, serializeThemeFile, + subscribeToThemePreview, subscribeToCustomThemes, T3_CHAT_THEME, EMBER_THEME, @@ -200,6 +204,49 @@ describe("theme files", () => { }); }); + it("keeps sidebar artwork opt-in through theme files", () => { + const withoutArtwork = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Plain sidebar", + appearance: "light", + colors: { accent: "#5b6cff" }, + }); + const withArtwork = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Art sidebar", + appearance: "light", + colors: { accent: "#5b6cff" }, + sidebarArtwork: true, + }); + + expect(withoutArtwork.sidebarArtwork).toBeUndefined(); + expect(withArtwork.sidebarArtwork).toBe(true); + expect(JSON.parse(serializeThemeFile(withArtwork)).sidebarArtwork).toBe(true); + }); + + it("publishes sidebar artwork changes from the live theme preview", () => { + const listener = vi.fn(); + const unsubscribe = subscribeToThemePreview(listener); + vi.stubGlobal("document", { + documentElement: { + classList: { toggle: vi.fn() }, + dataset: {}, + style: { removeProperty: vi.fn(), setProperty: vi.fn() }, + }, + }); + + applyThemeColorPreview(T3_CHAT_THEME.colors, "light", true); + expect(getThemePreviewSidebarArtwork()).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + + applyThemePalette("system"); + expect(getThemePreviewSidebarArtwork()).toBeNull(); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + vi.unstubAllGlobals(); + }); + it("keeps optional light and dark palettes under one theme id", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, @@ -387,6 +434,7 @@ describe("theme files", () => { name: "Aurora", appearance: "light", colors: { canvas: "#f8fbff", accent: "#5b6cff" }, + sidebarArtwork: true, }), ); const updatedTheme = updateCustomTheme({ @@ -395,11 +443,17 @@ describe("theme files", () => { colors: { ...createdTheme.colors, accent: "#7c3aed" }, }); - expect(updatedTheme).toMatchObject({ id: "aurora", label: "Aurora Night" }); + expect(updatedTheme).toMatchObject({ + id: "aurora", + label: "Aurora Night", + sidebarArtwork: true, + }); + invalidateCustomThemes(); expect(getCustomThemes()).toEqual([updatedTheme]); expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({ id: "aurora", label: "Aurora Night", + sidebarArtwork: true, }); vi.unstubAllGlobals(); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index dafc5dbf457..2f6fb043454 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -96,6 +96,8 @@ export type ThemeDefinition = Readonly<{ appearance: ThemeAppearance; colors: ThemeColors; variants?: ThemeVariants; + /** Allows fixed Dev/Nightly artwork to render over this theme's sidebar. */ + sidebarArtwork?: boolean; /** True when the palette was generated by the guided editor from its * canvas and accent; such themes reopen in guided mode. */ managed?: boolean; @@ -107,6 +109,7 @@ export type ThemeFile = Readonly<{ appearance: ThemeAppearance; colors: ThemeColorOverrides; variants?: ThemeVariantOverrides; + sidebarArtwork?: boolean; managed?: boolean; }>; @@ -128,6 +131,23 @@ const RESERVED_THEME_IDS = new Set([ const customThemeListeners = new Set<() => void>(); let customThemesSnapshot: ReadonlyArray | null = null; +const themePreviewListeners = new Set<() => void>(); +let themePreviewSidebarArtwork: boolean | null = null; + +export function getThemePreviewSidebarArtwork(): boolean | null { + return themePreviewSidebarArtwork; +} + +export function subscribeToThemePreview(listener: () => void): () => void { + themePreviewListeners.add(listener); + return () => themePreviewListeners.delete(listener); +} + +function setThemePreviewSidebarArtwork(next: boolean | null): void { + if (themePreviewSidebarArtwork === next) return; + themePreviewSidebarArtwork = next; + for (const listener of themePreviewListeners) listener(); +} function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -203,6 +223,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { appearance: value.appearance, colors, ...(variants ? { variants } : {}), + ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), ...(value.managed === true ? { managed: true } : {}), }; } @@ -1541,6 +1562,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition { appearance, colors: { ...fallback, ...overrides }, ...(Object.keys(variants).length > 0 ? { variants } : {}), + ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), ...(value.managed === true ? { managed: true } : {}), }; } @@ -1553,6 +1575,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string { appearance: theme.appearance, colors: theme.colors, ...(theme.variants ? { variants: theme.variants } : {}), + ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}), ...(theme.managed ? { managed: true } : {}), }; return `${JSON.stringify(file, null, 2)}\n`; @@ -1630,11 +1653,16 @@ export const THEME_PREVIEW_ID = "__preview"; * can be judged against the real interface instead of a miniature. Callers * restore the stored theme (refreshTheme) when the draft goes away. */ -export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void { +export function applyThemeColorPreview( + colors: ThemeColors, + appearance: ThemeAppearance, + sidebarArtwork = false, +): void { if (typeof document === "undefined") return; const root = document.documentElement; if (!root?.style) return; + setThemePreviewSidebarArtwork(sidebarArtwork); root.dataset.themeId = THEME_PREVIEW_ID; root.classList.toggle("dark", appearance === "dark"); for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) { @@ -1649,6 +1677,7 @@ export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppe const root = document.documentElement; if (!root?.style) return; + setThemePreviewSidebarArtwork(null); const palette = getThemeDefinition(theme); if (palette) { From 7a84f6cf10a5040d29c6392ad216d976458363cd Mon Sep 17 00:00:00 2001 From: Henry Zhang <113233555+caezium@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:03:31 +0800 Subject: [PATCH 5/9] fix(web): reconnect the composer seam for remote non-Git projects (#5633) --- apps/web/src/components/BranchToolbar.tsx | 2 +- apps/web/src/components/BranchToolbarEnvModeSelector.tsx | 2 +- .../src/components/BranchToolbarEnvironmentSelector.tsx | 7 ++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 440f48d7c90..5ceec813187 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -126,7 +126,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - + {triggerContent} ); diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index ca778daad31..64bcd8c57cb 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -50,7 +50,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( - + {activeWorktreePath ? ( <> diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 2cf99547752..56fb91fb4b8 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -41,9 +41,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir [availableEnvironments], ); + // The static label carries the xs control's height (h-7 sm:h-6) as well as + // its padding: the composer context strip has no min-height of its own, and + // the glass seam joining it to the composer assumes a fixed strip height, so + // a shorter label would drag the seam out of line whenever this label is the + // only thing in the strip. if (envLocked || onEnvironmentChange === undefined) { return ( - + {activeEnvironment?.isPrimary ? ( ) : ( From 45d9aa90baab8f2d6b13c7ae3cf2f97128edaf7b Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Fri, 7 Aug 2026 23:03:52 +0200 Subject: [PATCH 6/9] fix(web): show Stop button while input is pending (#5554) --- .../chat/ComposerPrimaryActions.test.ts | 78 ++++++++++++++++++- .../chat/ComposerPrimaryActions.tsx | 32 ++++---- 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts index b7624db0a8f..ba416e9fce3 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts @@ -1,6 +1,64 @@ -import { describe, expect, it } from "vite-plus/test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; +vi.mock("~/hooks/useSettings", () => ({ + useEnvironmentIdentificationMode: () => "none", +})); +vi.mock("../SidebarStageBackdrop", () => ({ + StageBackdropButtonArt: () => null, + useSidebarStageBackdropVariant: () => null, +})); + +import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; + +function renderPendingActions(isRunning: boolean) { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: { + questionIndex: 0, + isLastQuestion: true, + canAdvance: true, + isResponding: false, + isComplete: true, + }, + isRunning, + showPlanFollowUpPrompt: false, + promptHasText: false, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: false, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} + +function renderStandaloneStop() { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: null, + isRunning: true, + showPlanFollowUpPrompt: false, + promptHasText: false, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: false, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} describe("formatPendingPrimaryActionLabel", () => { it("returns 'Submitting...' while responding", () => { @@ -91,3 +149,19 @@ describe("formatPendingPrimaryActionLabel", () => { ).toBe("Submit answers"); }); }); + +describe("ComposerPrimaryActions", () => { + it("offers Stop generation while a running turn is waiting for user input", () => { + expect(renderPendingActions(true)).toContain('aria-label="Stop generation"'); + }); + + it("does not offer Stop generation for a pending request without a running turn", () => { + expect(renderPendingActions(false)).not.toContain('aria-label="Stop generation"'); + }); + + it("matches the small pending action size without changing the standalone size", () => { + expect(renderPendingActions(true)).toContain("size-8 sm:size-7"); + expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8"); + expect(renderStandaloneStop()).not.toContain("sm:size-7"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index cc211f318f1..52d2556bbf9 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -75,9 +75,27 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ : undefined; const isSendDisabled = sendDisabledReason !== null; + const renderStopGenerationButton = (insidePendingAction: boolean) => ( + + ); + if (pendingAction) { return (
+ {isRunning ? renderStopGenerationButton(true) : null} {pendingAction.questionIndex > 0 ? ( compact ? ( - ); + return renderStopGenerationButton(false); } if (showPlanFollowUpPrompt) { From 31a9067b3909c90f736f00c9f1b354c1e728a7a2 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 8 Aug 2026 10:31:31 +0000 Subject: [PATCH 7/9] fix(web): point settings panels at a connected device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings panels read and write server settings through the primary environment, which only resolves via a `PrimaryConnectionTarget`. The hosted app never has one — every device is paired remotely — so the panels degraded silently there: - `primaryServerProvidersAtom` returned no providers, leaving the text generation model picker with nothing to offer ("No models found"), so the model backing thread titles, commit messages, change request content and branch names could not be changed at all. - `useUpdatePrimarySettings` resolved to a null environment id, and `useUpdateSettingsTarget` no-ops on null, so every write from these panels was dropped without an error. - Reads fell back to `DEFAULT_SERVER_SETTINGS`, presenting the schema default as if it were the user's configuration. Resolve the settings target to the primary device when there is one and the first connected device otherwise. Sessions with a primary device are unaffected. Rename the hook pair to `useGlobalSettings` / `useUpdateGlobalSettings`, since the module header documents the primary-only scoping as deliberate and it no longer holds. Left alone: the primary-scoped atoms behind the primary-device update notification, sidebar and command palette, and the diagnostics panel, whose RPCs target the primary environment. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/settings/SettingsPanels.tsx | 50 +++++++++--------- .../settings/SourceControlSettings.tsx | 6 +-- .../settings/SourceControlWritingSettings.tsx | 10 ++-- apps/web/src/hooks/useSettings.ts | 19 +++---- apps/web/src/state/server.ts | 19 +++++++ .../web/src/state/settingsEnvironment.test.ts | 24 +++++++++ apps/web/src/state/settingsEnvironment.ts | 51 +++++++++++++++++++ 7 files changed, 137 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/state/settingsEnvironment.test.ts create mode 100644 apps/web/src/state/settingsEnvironment.ts diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5b9347f0307..ae3f95d5580 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -60,7 +60,7 @@ import { useTheme, } from "../../hooks/useTheme"; import { useLocalStorage } from "../../hooks/useLocalStorage"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { useGlobalSettings, useUpdateGlobalSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { @@ -74,7 +74,7 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; +import { primaryServerObservabilityAtom, settingsServerProvidersAtom } from "../../state/server"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; @@ -433,8 +433,8 @@ export function useSettingsRestore(onRestored?: () => void) { clearThemeHalves, themeHalves, } = useTheme(); - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useGlobalSettings(); + const updateSettings = useUpdateGlobalSettings(); const isTextGenerationModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, @@ -644,8 +644,8 @@ function BackgroundActivityAdvancedDialog({ readonly open: boolean; readonly onOpenChange: (open: boolean) => void; }) { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useGlobalSettings(); + const updateSettings = useUpdateGlobalSettings(); const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); const activeProfile = resolvedBackgroundActivity.profile; const automaticGitFetchIntervalSeconds = durationToSeconds( @@ -916,8 +916,8 @@ export function AppearanceSettingsPanel() { } = useTheme(); const customThemes = useCustomThemes(); const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useGlobalSettings(); + const updateSettings = useUpdateGlobalSettings(); const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; @@ -1042,7 +1042,7 @@ export function AppearanceSettingsPanel() { } function useFontDefaultFamilies() { - const settings = usePrimarySettings(); + const settings = useGlobalSettings(); // An unset preference shows the font it resolves to on this machine; the // default stacks are the platform's own faces, so the name is probed, not // hardcoded. @@ -1062,8 +1062,8 @@ function useFontDefaultFamilies() { } function InterfaceFontRow({ preview }: { preview?: ReactNode }) { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useGlobalSettings(); + const updateSettings = useUpdateGlobalSettings(); const defaults = useFontDefaultFamilies(); return ( } /> @@ -1505,14 +1505,14 @@ function FontFamilySettingsRow({ } export function GeneralSettingsPanel() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useGlobalSettings(); + const updateSettings = useUpdateGlobalSettings(); const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), ); const observability = useAtomValue(primaryServerObservabilityAtom); - const serverProviders = useAtomValue(primaryServerProvidersAtom); + const serverProviders = useAtomValue(settingsServerProvidersAtom); const diagnosticsDescription = formatDiagnosticsDescription({ localTracingEnabled: observability?.localTracingEnabled ?? false, otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 10b54f6d7af..1449a87d0af 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -17,7 +17,7 @@ import { resolveServerBackgroundActivitySettings, } from "@t3tools/shared/backgroundActivitySettings"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { useGlobalSettings, useUpdateGlobalSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { usePrimaryEnvironment } from "../../state/environments"; import { useEnvironmentQuery } from "../../state/query"; @@ -347,8 +347,8 @@ function DiscoveryItemRow({ } function GitFetchIntervalSettings() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useGlobalSettings(); + const updateSettings = useUpdateGlobalSettings(); const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); const automaticGitFetchIntervalSeconds = durationToSeconds( resolvedBackgroundActivity.automaticGitFetchInterval, diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index d7c094af372..bf1544b1ef0 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -5,7 +5,7 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { useGlobalSettings, useUpdateGlobalSettings } from "../../hooks/useSettings"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -15,7 +15,7 @@ import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, } from "../../modelSelection"; -import { primaryServerProvidersAtom } from "../../state/server"; +import { settingsServerProvidersAtom } from "../../state/server"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; @@ -41,9 +41,9 @@ const MODE_OPTIONS: Record(null); const style = settings.sourceControlWritingStyle; const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle; diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index f4797bb775d..3eb01849e4d 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -5,9 +5,10 @@ * `settings.json` on the server, fetched via `server.getConfig`) and * client-only settings (persisted in localStorage). * - * Live server settings always require an environment id. Primary-environment + * Live server settings always require an environment id. Environment-scoped * access is intentionally named as such so environment-sensitive consumers - * cannot silently read the wrong server's settings. + * cannot silently read the wrong server's settings; the global settings UI + * uses the `useGlobalSettings` pair, which resolves its own target. */ import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useAtomValue } from "@effect/atom-react"; @@ -35,8 +36,8 @@ import { subscribeToThemePreview, } from "~/themePalette"; import * as Struct from "effect/Struct"; -import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; -import { usePrimaryEnvironment } from "~/state/environments"; +import { settingsServerSettingsAtom, serverEnvironment } from "~/state/server"; +import { useSettingsEnvironmentId } from "~/state/settingsEnvironment"; import { useAtomCommand } from "~/state/use-atom-command"; import { useTheme } from "./useTheme"; @@ -300,11 +301,11 @@ export function useEnvironmentSettings( return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector); } -/** Primary-only settings access for the settings UI and other explicitly global surfaces. */ -export function usePrimarySettings( +/** Settings access for the settings UI and other explicitly global surfaces. */ +export function useGlobalSettings( selector?: (settings: UnifiedSettings) => T, ): T { - return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); + return useMergedSettings(useAtomValue(settingsServerSettingsAtom), selector); } /** @@ -347,8 +348,8 @@ export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { return useUpdateSettingsTarget(environmentId); } -export function useUpdatePrimarySettings() { - return useUpdateSettingsTarget(usePrimaryEnvironment()?.environmentId ?? null); +export function useUpdateGlobalSettings() { + return useUpdateSettingsTarget(useSettingsEnvironmentId()); } export function useUpdateClientSettings() { diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 1071d8209df..60f588dea70 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -17,6 +17,7 @@ import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { environmentSession } from "./session"; +import { settingsEnvironmentIdAtom } from "./settingsEnvironment"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, @@ -80,6 +81,24 @@ export const primaryServerProvidersAtom = Atom.make( get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, ).pipe(Atom.withLabel("web-primary-server-providers")); +/** + * Server config for the environment the global settings UI edits. Distinct + * from `primaryServerConfigAtom`: it resolves to a connected device even when + * the session has no primary one. See `settingsEnvironmentIdAtom`. + */ +export const settingsServerConfigAtom = Atom.make((get): ServerConfig | null => + get(serverEnvironment.configValueAtom(get(settingsEnvironmentIdAtom))), +).pipe(Atom.withLabel("web-settings-server-config")); + +export const settingsServerSettingsAtom = Atom.make( + (get): ServerSettings => get(settingsServerConfigAtom)?.settings ?? DEFAULT_SERVER_SETTINGS, +).pipe(Atom.withLabel("web-settings-server-settings")); + +export const settingsServerProvidersAtom = Atom.make( + (get): ReadonlyArray => + get(settingsServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, +).pipe(Atom.withLabel("web-settings-server-providers")); + export const primaryServerKeybindingsAtom = Atom.make( (get): ServerConfig["keybindings"] => get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS, diff --git a/apps/web/src/state/settingsEnvironment.test.ts b/apps/web/src/state/settingsEnvironment.test.ts new file mode 100644 index 00000000000..4fda51371db --- /dev/null +++ b/apps/web/src/state/settingsEnvironment.test.ts @@ -0,0 +1,24 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveSettingsEnvironmentId } from "./settingsEnvironment"; + +const primary = EnvironmentId.make("primary"); +const relayA = EnvironmentId.make("relay-a"); +const relayB = EnvironmentId.make("relay-b"); + +describe("resolveSettingsEnvironmentId", () => { + it("prefers the primary environment when the session has one", () => { + expect(resolveSettingsEnvironmentId(primary, [relayA, primary])).toBe(primary); + }); + + it("falls back to a connected device when no primary connection exists", () => { + // The hosted app has no PrimaryConnectionTarget; without this fallback the + // settings panels read schema defaults and drop every write. + expect(resolveSettingsEnvironmentId(null, [relayA, relayB])).toBe(relayA); + }); + + it("returns null before the catalog hydrates", () => { + expect(resolveSettingsEnvironmentId(null, [])).toBeNull(); + }); +}); diff --git a/apps/web/src/state/settingsEnvironment.ts b/apps/web/src/state/settingsEnvironment.ts new file mode 100644 index 00000000000..2d23bd26683 --- /dev/null +++ b/apps/web/src/state/settingsEnvironment.ts @@ -0,0 +1,51 @@ +/** + * Environment targeted by the global settings UI. + * + * @module state/settingsEnvironment + */ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; + +/** + * Environment whose server settings the global settings panels read and write. + * + * Prefers the primary device, but falls back to the first catalog entry when + * no `PrimaryConnectionTarget` exists. The hosted app is exactly that case — + * every device is paired remotely — and a primary-only lookup degrades badly + * there: server-backed rows render against schema defaults, provider-driven + * controls like the text generation model picker have no instances to offer + * ("No models found"), and every write is dropped on the floor because + * `useUpdateSettingsTarget` no-ops on a null environment id. + * + * Only the zero-primary case changes; a session with a primary device still + * resolves to it. When several remote devices are connected this picks one + * arbitrarily, matching how `resolveSelectedProviderEnvironmentId` seeds the + * Providers panel's device selector. + */ +export function resolveSettingsEnvironmentId( + primaryEnvironmentId: EnvironmentId | null, + catalogEnvironmentIds: Iterable, +): EnvironmentId | null { + if (primaryEnvironmentId !== null) { + return primaryEnvironmentId; + } + for (const environmentId of catalogEnvironmentIds) { + return environmentId; + } + return null; +} + +export const settingsEnvironmentIdAtom = Atom.make((get): EnvironmentId | null => + resolveSettingsEnvironmentId( + get(primaryEnvironmentIdAtom), + get(environmentCatalog.catalogValueAtom).entries.keys(), + ), +).pipe(Atom.withLabel("web-settings-environment-id")); + +export function useSettingsEnvironmentId(): EnvironmentId | null { + return useAtomValue(settingsEnvironmentIdAtom); +} From 646f549eb2ea8a9ef4b73afbd99dcea54be34eed Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 8 Aug 2026 10:54:17 +0000 Subject: [PATCH 8/9] fix(web): finish the settings-environment migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consumers were left on the primary environment after the settings panels moved to the resolved settings environment, so on a hosted session with no primary device they disagreed with the panels that write them. - New-thread defaults (`defaultThreadEnvMode`, `newWorktreesStartFromOrigin`) read `primaryServerSettingsAtom` in `useNewThreadHandler` and `ChatView`, so the General controls saved and re-displayed while new drafts kept the schema defaults. Both now read `settingsServerSettingsAtom` — the environment those controls write. - `SourceControlSettingsPanel` still resolved its discovery target and gated `SourceControlWritingSettingsSection` on `usePrimaryEnvironment`, so the writer-model and fetch-interval controls never mounted there. It now uses `useSettingsEnvironmentId`, matching the section's own hooks. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/ChatView.tsx | 16 +++++++-------- .../settings/SourceControlSettings.tsx | 7 +++++-- apps/web/src/hooks/useHandleNewThread.ts | 20 ++++++++++--------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cfbe1ac8d96..4fa93306b82 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -220,8 +220,8 @@ import { useEnvironmentQuery } from "../state/query"; import { primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, - primaryServerSettingsAtom, serverEnvironment, + settingsServerSettingsAtom, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; @@ -1261,10 +1261,10 @@ function ChatViewContent(props: ChatViewProps) { }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); - // New-thread defaults live in the primary environment's settings.json (the - // settings UI never writes to remote environments), so read them from the - // primary server rather than the thread's environment. - const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); + // New-thread defaults live in the settings environment's settings.json — + // the one the settings UI writes to — so read them from there rather than + // from the thread's own environment. + const settingsServerSettings = useAtomValue(settingsServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -3980,7 +3980,7 @@ function ChatViewContent(props: ChatViewProps) { ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? - primaryServerSettings.newWorktreesStartFromOrigin) + settingsServerSettings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, @@ -5816,7 +5816,7 @@ function ChatViewContent(props: ChatViewProps) { envMode: mode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: settingsServerSettings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); @@ -5828,7 +5828,7 @@ function ChatViewContent(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, - primaryServerSettings.newWorktreesStartFromOrigin, + settingsServerSettings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 1449a87d0af..19685ee5f19 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -19,8 +19,8 @@ import { import { useGlobalSettings, useUpdateGlobalSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; -import { usePrimaryEnvironment } from "../../state/environments"; import { useEnvironmentQuery } from "../../state/query"; +import { useSettingsEnvironmentId } from "../../state/settingsEnvironment"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; @@ -508,7 +508,10 @@ function EmptySourceControlDiscovery({ } export function SourceControlSettingsPanel() { - const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + // The same environment the writing settings below read and write, so the + // discovery scan and the writer controls agree about which device they + // describe — and so both still mount on a session with no primary device. + const environmentId = useSettingsEnvironmentId(); const discovery = useEnvironmentQuery( environmentId === null ? null diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 547d8287012..3f9268ecd0e 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -22,7 +22,7 @@ import { } from "../logicalProject"; import { readThreadShell, useProjects, useThread } from "../state/entities"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; -import { primaryServerSettingsAtom } from "../state/server"; +import { settingsServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; @@ -30,11 +30,13 @@ import { useClientSettings } from "./useSettings"; export function useNewThreadHandler() { const projects = useProjects(); // New-thread defaults are a user preference, and the settings UI only ever - // edits the primary environment's settings.json. Reading the target + // edits the settings environment's settings.json. Reading the target // environment's own settings here would silently reset remote projects to // the decoded defaults ("local" mode, current branch), since nothing can - // set those values on a remote server. - const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); + // set those values on a remote server. This must stay the same environment + // the settings panels write to, or the General controls would save and + // re-display values that new threads never pick up. + const settingsServerSettings = useAtomValue(settingsServerSettingsAtom); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { @@ -146,7 +148,7 @@ export function useNewThreadHandler() { // preserved. When the draft is already open and no options were // passed, leave it alone entirely — the user may have just picked a // branch in the composer. - const defaultEnvMode = primaryServerSettings.defaultThreadEnvMode; + const defaultEnvMode = settingsServerSettings.defaultThreadEnvMode; const workspaceContext = hasExplicitWorkspaceOption ? { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), @@ -162,7 +164,7 @@ export function useNewThreadHandler() { envMode: defaultEnvMode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: defaultEnvMode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: settingsServerSettings.newWorktreesStartFromOrigin, }), }; if (workspaceContext) { @@ -244,7 +246,7 @@ export function useNewThreadHandler() { const draftId = newDraftId(); const threadId = newThreadId(); const createdAt = new Date().toISOString(); - const initialEnvMode = options?.envMode ?? primaryServerSettings.defaultThreadEnvMode; + const initialEnvMode = options?.envMode ?? settingsServerSettings.defaultThreadEnvMode; return (async () => { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { threadId, @@ -256,7 +258,7 @@ export function useNewThreadHandler() { options?.startFromOrigin ?? resolveNewDraftStartFromOrigin({ envMode: initialEnvMode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: settingsServerSettings.newWorktreesStartFromOrigin, }), runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), @@ -278,7 +280,7 @@ export function useNewThreadHandler() { }); })(); }, - [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, projects, router], + [getCurrentRouteTarget, settingsServerSettings, projectGroupingSettings, projects, router], ); } From f4ab9bd5c02669ecc4bf6bf0eeef5eddcba2dea5 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Mon, 10 Aug 2026 09:32:44 +0000 Subject: [PATCH 9/9] feat(web): start and stop managed VM instances through hooks VM management solutions had no way to boot or shut down the machine hosting a T3 server from inside T3 Code, so users had to open the management app before every remote session. The server's settings gain nullable startHookUrl/stopHookUrl, delivered to clients inside the cached server config. On Settings -> Connections, Connect now POSTs the start hook first, renders its component form when the endpoint asks for input, polls until the instance reports ready with a 204, then runs the normal connect flow. Connected environments with a stop hook show a Stop button backed by a new server.runStopHook RPC; the server DELETEs the endpoint and clears the setting on a 404 so clients drop the control. Done by Claude Fable 5 on Claude Code. Co-Authored-By: Claude Fable 5 --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/instanceHooks.test.ts | 102 ++++++++ apps/server/src/instanceHooks.ts | 37 +++ apps/server/src/ws.ts | 20 +- .../settings/ConnectionsSettings.tsx | 234 ++++++++++++++++-- .../settings/StartHookFormDialog.tsx | 171 +++++++++++++ .../src/components/settings/startHook.test.ts | 167 +++++++++++++ apps/web/src/components/settings/startHook.ts | 183 ++++++++++++++ docs/internals/glossary.md | 11 + docs/internals/instance-hooks.md | 106 ++++++++ docs/user/remote-access.md | 15 ++ packages/client-runtime/src/state/server.ts | 8 + packages/contracts/src/index.ts | 1 + packages/contracts/src/instanceHooks.ts | 100 ++++++++ packages/contracts/src/rpc.ts | 9 + packages/contracts/src/settings.ts | 11 + 16 files changed, 1156 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/instanceHooks.test.ts create mode 100644 apps/server/src/instanceHooks.ts create mode 100644 apps/web/src/components/settings/StartHookFormDialog.tsx create mode 100644 apps/web/src/components/settings/startHook.test.ts create mode 100644 apps/web/src/components/settings/startHook.ts create mode 100644 docs/internals/instance-hooks.md create mode 100644 packages/contracts/src/instanceHooks.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2aa057ee0ca..8767093575c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -39,6 +39,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope, + [WS_METHODS.serverRunStopHook]: AuthOrchestrationOperateScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, diff --git a/apps/server/src/instanceHooks.test.ts b/apps/server/src/instanceHooks.test.ts new file mode 100644 index 00000000000..de2faed4eb4 --- /dev/null +++ b/apps/server/src/instanceHooks.test.ts @@ -0,0 +1,102 @@ +import { ServerStopHookError } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as InstanceHooks from "./instanceHooks.ts"; +import * as ServerSettings from "./serverSettings.ts"; + +interface RecordedHookRequest { + readonly method: string; + readonly url: string; +} + +const makeHookEndpointLayer = (requests: Array, status: number) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push({ method: request.method, url: request.url }); + return HttpClientResponse.fromWeb(request, new Response(null, { status })); + }), + ), + ); + +const isStopHookError = Schema.is(ServerStopHookError); + +it.effect("DELETEs the stop hook and reports the instance as stopping on 204", () => + Effect.gen(function* () { + const requests: Array = []; + const result = yield* InstanceHooks.runStopHook.pipe( + Effect.provide( + Layer.mergeAll( + makeHookEndpointLayer(requests, 204), + ServerSettings.layerTest({ stopHookUrl: "https://mgmt.example.test/instances/1/stop" }), + ), + ), + ); + assert.deepEqual(result, { outcome: "stopped" }); + assert.deepEqual(requests, [ + { method: "DELETE", url: "https://mgmt.example.test/instances/1/stop" }, + ]); + }), +); + +it.effect("clears the stop hook setting when the endpoint is gone", () => + Effect.gen(function* () { + const requests: Array = []; + const settingsLayer = ServerSettings.layerTest({ + stopHookUrl: "https://mgmt.example.test/instances/1/stop", + }); + const result = yield* Effect.gen(function* () { + const outcome = yield* InstanceHooks.runStopHook.pipe( + Effect.provide(makeHookEndpointLayer(requests, 404)), + ); + const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; + return { outcome, stopHookUrl: settings.stopHookUrl }; + }).pipe(Effect.provide(settingsLayer)); + assert.deepEqual(result.outcome, { outcome: "gone" }); + assert.equal(result.stopHookUrl, null); + }), +); + +it.effect("fails when no stop hook is configured", () => + Effect.gen(function* () { + const requests: Array = []; + const failure = yield* InstanceHooks.runStopHook.pipe( + Effect.provide( + Layer.mergeAll(makeHookEndpointLayer(requests, 204), ServerSettings.layerTest()), + ), + Effect.flip, + ); + assert.isTrue(isStopHookError(failure)); + assert.equal(isStopHookError(failure) ? failure.reason : null, "not-configured"); + assert.deepEqual(requests, []); + }), +); + +it.effect("surfaces unexpected statuses without clearing the hook", () => + Effect.gen(function* () { + const requests: Array = []; + const settingsLayer = ServerSettings.layerTest({ + stopHookUrl: "https://mgmt.example.test/instances/1/stop", + }); + const result = yield* Effect.gen(function* () { + const failure = yield* InstanceHooks.runStopHook.pipe( + Effect.provide(makeHookEndpointLayer(requests, 500)), + Effect.flip, + ); + const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; + return { failure, stopHookUrl: settings.stopHookUrl }; + }).pipe(Effect.provide(settingsLayer)); + assert.isTrue(isStopHookError(result.failure)); + assert.equal( + isStopHookError(result.failure) ? result.failure.reason : null, + "unexpected-status", + ); + assert.equal(result.stopHookUrl, "https://mgmt.example.test/instances/1/stop"); + }), +); diff --git a/apps/server/src/instanceHooks.ts b/apps/server/src/instanceHooks.ts new file mode 100644 index 00000000000..10c0418ad1f --- /dev/null +++ b/apps/server/src/instanceHooks.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { ServerStopHookError, type ServerStopHookResult } from "@t3tools/contracts"; + +import * as ServerSettings from "./serverSettings.ts"; + +const STOP_HOOK_TIMEOUT = "20 seconds"; + +/** + * Run the configured stop hook: DELETE the management endpoint that stops + * this instance. A 204 reports the instance as stopping. A 404 means the + * hook no longer exists, so the setting is cleared and clients drop their + * stop controls with it. + */ +export const runStopHook = Effect.gen(function* () { + const serverSettings = yield* ServerSettings.ServerSettingsService; + const httpClient = yield* HttpClient.HttpClient; + const settings = yield* serverSettings.getSettings; + if (settings.stopHookUrl === null) { + return yield* new ServerStopHookError({ reason: "not-configured" }); + } + const response = yield* httpClient.execute(HttpClientRequest.delete(settings.stopHookUrl)).pipe( + Effect.timeout(STOP_HOOK_TIMEOUT), + Effect.mapError( + (error) => new ServerStopHookError({ reason: "request-failed", detail: String(error) }), + ), + ); + if (response.status === 204) { + return { outcome: "stopped" } satisfies ServerStopHookResult; + } + if (response.status === 404) { + yield* serverSettings.updateSettings({ stopHookUrl: null }); + return { outcome: "gone" } satisfies ServerStopHookResult; + } + return yield* new ServerStopHookError({ reason: "unexpected-status", status: response.status }); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a6b155c296f..b5864d4d7ff 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -59,7 +59,12 @@ import { WsRpcGroup, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; -import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; +import { + HttpClient, + HttpRouter, + HttpServerRequest, + HttpServerRespondable, +} from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; @@ -83,6 +88,7 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as InstanceHooks from "./instanceHooks.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; @@ -372,6 +378,7 @@ const makeWsRpcLayer = ( const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; + const stopHookHttpClient = yield* HttpClient.HttpClient; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -1490,6 +1497,17 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverRunStopHook]: (_input) => + observeRpcEffect( + WS_METHODS.serverRunStopHook, + InstanceHooks.runStopHook.pipe( + Effect.provideService(ServerSettings.ServerSettingsService, serverSettings), + Effect.provideService(HttpClient.HttpClient, stopHookHttpClient), + ), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( WS_METHODS.serverDiscoverSourceControl, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 300c71a338f..0afcc7b0be6 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -6,7 +6,7 @@ import { TerminalIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; +import { type ReactNode, memo, useCallback, useId, useMemo, useRef, useState } from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, @@ -27,6 +27,7 @@ import { type DesktopServerExposureState, type DesktopWslState, type EnvironmentId, + type StartHookForm, } from "@t3tools/contracts"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { @@ -40,6 +41,13 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; +import { StartHookFormDialog } from "./StartHookFormDialog"; +import { + type StartHookStep, + pollStartHookUntilReady, + requestStartHook, + submitStartHookForm, +} from "./startHook"; import { applyWslEnableSelection, isQrShareableEndpoint, @@ -1331,23 +1339,43 @@ function NetworkAccessDescription({ ); } +type StartHookRunState = { + environmentId: EnvironmentId; + url: string; + label: string; + phase: "requesting" | "form" | "starting"; + form: StartHookForm | null; + submitting: boolean; +}; + type SavedBackendListRowProps = { environment: EnvironmentPresentation; removingEnvironmentId: EnvironmentId | null; + startingEnvironmentId: EnvironmentId | null; + stoppingEnvironmentId: EnvironmentId | null; onConnect: (environmentId: EnvironmentId) => void; + onCancelStart: (environmentId: EnvironmentId) => void; + onStop: (environmentId: EnvironmentId) => void; onRemove: (environmentId: EnvironmentId) => void; }; function SavedBackendListRow({ environment, removingEnvironmentId, + startingEnvironmentId, + stoppingEnvironmentId, onConnect, + onCancelStart, + onStop, onRemove, }: SavedBackendListRowProps) { const environmentId = environment.environmentId; const connectionState = environment.connection.phase; const isConnected = connectionState === "connected"; const isConnecting = connectionState === "connecting" || connectionState === "reconnecting"; + const isStarting = startingEnvironmentId === environmentId; + const isStopping = stoppingEnvironmentId === environmentId; + const stopHookConfigured = environment.serverConfig?.settings.stopHookUrl != null; const stateDotClassName = connectionState === "connected" ? "bg-success" @@ -1490,28 +1518,54 @@ function SavedBackendListRow({ ) : null} - + {isConnected && stopHookConfigured ? ( + + void onStop(environmentId)} + > + {isStopping ? "Stopping…" : "Stop"} + + } + /> + + Ask the service managing this environment to stop the instance. + + + ) : null} + {isStarting ? ( + + ) : ( + + )} )}
@@ -1734,6 +1788,9 @@ export function ConnectionsSettings() { }); const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); + const runStopHookCommand = useAtomCommand(serverEnvironment.runStopHook, { + reportFailure: false, + }); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const primarySessionState = usePrimarySessionState(); const currentSessionScopes = desktopBridge @@ -1811,6 +1868,10 @@ export function ConnectionsSettings() { const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = useState(null); + const [stoppingSavedEnvironmentId, setStoppingSavedEnvironmentId] = + useState(null); + const [startHookRun, setStartHookRun] = useState(null); + const startHookAbortRef = useRef(null); const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false); const [isUpdatingTailscaleServe, setIsUpdatingTailscaleServe] = useState(false); @@ -2226,7 +2287,7 @@ export function ConnectionsSettings() { savedBackendSshUsername, ]); - const handleConnectSavedBackend = useCallback( + const connectSavedBackendNow = useCallback( async (environmentId: EnvironmentId) => { setSavedBackendError(null); const result = await retryEnvironment(environmentId); @@ -2246,6 +2307,128 @@ export function ConnectionsSettings() { [retryEnvironment], ); + const finishStartHook = useCallback(() => { + startHookAbortRef.current = null; + setStartHookRun(null); + }, []); + + const failStartHook = useCallback( + (error: unknown, aborted: boolean) => { + finishStartHook(); + if (aborted) return; + const message = error instanceof Error ? error.message : "Failed to start the instance."; + setSavedBackendError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start instance", + description: message, + }), + ); + }, + [finishStartHook], + ); + + const settleStartHookStep = useCallback( + async ( + run: Pick, + step: StartHookStep, + controller: AbortController, + ) => { + if (step.kind === "form") { + setStartHookRun({ ...run, phase: "form", form: step.form, submitting: false }); + return; + } + if (step.kind === "poll") { + setStartHookRun({ ...run, phase: "starting", form: null, submitting: false }); + await pollStartHookUntilReady(step.poll, { signal: controller.signal }); + } + finishStartHook(); + await connectSavedBackendNow(run.environmentId); + }, + [connectSavedBackendNow, finishStartHook], + ); + + const handleConnectSavedBackend = useCallback( + async (environmentId: EnvironmentId) => { + const environment = environments.find((entry) => entry.environmentId === environmentId); + const startHookUrl = environment?.serverConfig?.settings.startHookUrl ?? null; + if (startHookUrl === null) { + await connectSavedBackendNow(environmentId); + return; + } + startHookAbortRef.current?.abort(); + const controller = new AbortController(); + startHookAbortRef.current = controller; + const run = { environmentId, url: startHookUrl, label: environment?.label ?? "environment" }; + setSavedBackendError(null); + setStartHookRun({ ...run, phase: "requesting", form: null, submitting: false }); + try { + const step = await requestStartHook(startHookUrl, { signal: controller.signal }); + await settleStartHookStep(run, step, controller); + } catch (error) { + failStartHook(error, controller.signal.aborted); + } + }, + [environments, connectSavedBackendNow, settleStartHookStep, failStartHook], + ); + + const handleCancelStartHook = useCallback(() => { + startHookAbortRef.current?.abort(); + finishStartHook(); + }, [finishStartHook]); + + const handleSubmitStartHookForm = useCallback( + async (values: ReadonlyArray) => { + const run = startHookRun; + const controller = startHookAbortRef.current; + if (run === null || controller === null) return; + setStartHookRun({ ...run, submitting: true }); + try { + const step = await submitStartHookForm(run.url, values, { signal: controller.signal }); + await settleStartHookStep(run, step, controller); + } catch (error) { + failStartHook(error, controller.signal.aborted); + } + }, + [startHookRun, settleStartHookStep, failStartHook], + ); + + const handleStopSavedBackend = useCallback( + async (environmentId: EnvironmentId) => { + setStoppingSavedEnvironmentId(environmentId); + const result = await runStopHookCommand({ environmentId, input: {} }); + setStoppingSavedEnvironmentId(null); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + const message = error instanceof Error ? error.message : "Failed to stop the instance."; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not stop instance", + description: message, + }), + ); + return; + } + toastManager.add( + result.value.outcome === "gone" + ? { + type: "success", + title: "Stop hook removed", + description: "The management service no longer offers this stop hook.", + } + : { + type: "success", + title: "Stop requested", + description: "The management service is stopping this instance.", + }, + ); + }, + [runStopHookCommand], + ); + const handleRemoveSavedBackend = useCallback( async (environmentId: EnvironmentId) => { setRemovingSavedEnvironmentId(environmentId); @@ -3422,10 +3605,23 @@ export function ConnectionsSettings() { key={environment.environmentId} environment={environment} removingEnvironmentId={removingSavedEnvironmentId} + startingEnvironmentId={startHookRun?.environmentId ?? null} + stoppingEnvironmentId={stoppingSavedEnvironmentId} onConnect={handleConnectSavedBackend} + onCancelStart={handleCancelStartHook} + onStop={handleStopSavedBackend} onRemove={handleRemoveSavedBackend} /> ))} + {startHookRun !== null && startHookRun.phase === "form" && startHookRun.form !== null ? ( + void handleSubmitStartHookForm(values)} + onCancel={handleCancelStartHook} + /> + ) : null} ) => void; + readonly onCancel: () => void; +} + +/** + * Renders the component form a start hook returns with a 400: the management + * solution needs input (instance size, region, …) before it starts the + * instance. Submitting sends the resolved values back as a JSON array. + */ +export function StartHookFormDialog({ + environmentLabel, + form, + submitting, + onSubmit, + onCancel, +}: StartHookFormDialogProps) { + const fieldIdPrefix = useId(); + const initialValues = useMemo(() => { + const values: Record = {}; + form.components.forEach((component, index) => { + if (isStartHookInputComponent(component)) { + values[index] = component.type === "select" ? component.defaultValue : ""; + } + }); + return values; + }, [form]); + const [values, setValues] = useState>(initialValues); + const [errors, setErrors] = useState>({}); + // The endpoint may re-prompt with a different form after a submission; + // reset the collected input when that happens. + const [renderedForm, setRenderedForm] = useState(form); + if (renderedForm !== form) { + setRenderedForm(form); + setValues(initialValues); + setErrors({}); + } + // Hook forms carry no component ids, so the position doubles as identity; + // the title/text only disambiguates the key for readability. + const componentEntries = useMemo( + () => + form.components.map((component, index) => ({ + component, + index, + key: `${index}:${isStartHookInputComponent(component) ? component.title : component.text}`, + })), + [form], + ); + + const handleSubmit = useCallback(() => { + const nextErrors: Record = {}; + const resolved: Array = []; + form.components.forEach((component, index) => { + if (!isStartHookInputComponent(component)) return; + const value = values[index] ?? ""; + if (component.type === "text") { + const validationError = validateStartHookTextInput(component, value); + if (validationError !== null) { + nextErrors[index] = validationError; + } + } + resolved.push(value); + }); + setErrors(nextErrors); + if (Object.keys(nextErrors).length === 0) { + onSubmit(resolved); + } + }, [form, onSubmit, values]); + + return ( + (open ? null : onCancel())}> + + + Start {environmentLabel} + + The service managing this environment needs a few details before it starts the instance. + + + + {componentEntries.map(({ component, index, key }) => { + if (!isStartHookInputComponent(component)) { + return ( +

+ {component.text} +

+ ); + } + const fieldId = `${fieldIdPrefix}-${index}`; + return ( +
+ + {component.description ? ( +

{component.description}

+ ) : null} + {component.type === "select" ? ( + + ) : ( + <> + + setValues((current) => ({ ...current, [index]: event.target.value })) + } + /> + {errors[index] ? ( +

{errors[index]}

+ ) : null} + + )} +
+ ); + })} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/startHook.test.ts b/apps/web/src/components/settings/startHook.test.ts new file mode 100644 index 00000000000..4272da21af0 --- /dev/null +++ b/apps/web/src/components/settings/startHook.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + StartHookError, + isStartHookInputComponent, + pollStartHookUntilReady, + requestStartHook, + submitStartHookForm, + validateStartHookTextInput, +} from "./startHook"; + +interface RecordedRequest { + readonly url: string; + readonly method: string; + readonly body: string | null; +} + +function makeFetch(responses: Array, requests: Array = []) { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: typeof init?.body === "string" ? init.body : null, + }); + const next = responses.shift(); + if (next === undefined) throw new Error("No stubbed response left."); + return next; + }) as typeof fetch; +} + +const noSleep = () => Promise.resolve(); + +const pollResponse = () => + new Response(JSON.stringify({ poll_url: "https://mgmt.test/poll/1", retry_secs: 5 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + +describe("requestStartHook", () => { + it("POSTs with no content and returns the poll state", async () => { + const requests: Array = []; + const step = await requestStartHook("https://mgmt.test/start", { + fetchImpl: makeFetch([pollResponse()], requests), + }); + expect(step).toEqual({ + kind: "poll", + poll: { poll_url: "https://mgmt.test/poll/1", retry_secs: 5 }, + }); + expect(requests).toEqual([{ url: "https://mgmt.test/start", method: "POST", body: null }]); + }); + + it("returns the form on a 400 component response", async () => { + const form = { + button_text: "Boot", + components: [ + { text: "Pick a size." }, + { + type: "select", + title: "Size", + description: "Instance size", + defaultValue: "small", + values: [ + { userTitle: "Small", userDescription: "2 vCPU", content: "small" }, + { userTitle: "Large", userDescription: "8 vCPU", content: "large" }, + ], + }, + ], + }; + const step = await requestStartHook("https://mgmt.test/start", { + fetchImpl: makeFetch([ + new Response(JSON.stringify(form), { + status: 400, + headers: { "content-type": "application/json" }, + }), + ]), + }); + expect(step.kind).toBe("form"); + if (step.kind === "form") { + expect(step.form.button_text).toBe("Boot"); + expect(step.form.components).toHaveLength(2); + expect(step.form.components.filter(isStartHookInputComponent)).toHaveLength(1); + } + }); + + it("treats an immediate 204 as already running", async () => { + const step = await requestStartHook("https://mgmt.test/start", { + fetchImpl: makeFetch([new Response(null, { status: 204 })]), + }); + expect(step).toEqual({ kind: "ready" }); + }); + + it("fails on unexpected statuses", async () => { + await expect( + requestStartHook("https://mgmt.test/start", { + fetchImpl: makeFetch([new Response("nope", { status: 503 })]), + }), + ).rejects.toBeInstanceOf(StartHookError); + }); +}); + +describe("submitStartHookForm", () => { + it("POSTs the resolved values as a JSON array", async () => { + const requests: Array = []; + const step = await submitStartHookForm("https://mgmt.test/start", ["large", "my-vm"], { + fetchImpl: makeFetch([pollResponse()], requests), + }); + expect(step.kind).toBe("poll"); + expect(requests).toEqual([ + { url: "https://mgmt.test/start", method: "POST", body: '["large","my-vm"]' }, + ]); + }); +}); + +describe("pollStartHookUntilReady", () => { + it("polls until a 204 arrives", async () => { + const requests: Array = []; + await pollStartHookUntilReady( + { poll_url: "https://mgmt.test/poll/1", retry_secs: 5 }, + { + fetchImpl: makeFetch( + [ + new Response(null, { status: 200 }), + new Response(null, { status: 202 }), + new Response(null, { status: 204 }), + ], + requests, + ), + sleep: noSleep, + }, + ); + expect(requests).toHaveLength(3); + expect(requests.every((request) => request.method === "GET")).toBe(true); + }); + + it("fails when polling returns an error status", async () => { + await expect( + pollStartHookUntilReady( + { poll_url: "https://mgmt.test/poll/1", retry_secs: 5 }, + { fetchImpl: makeFetch([new Response(null, { status: 500 })]), sleep: noSleep }, + ), + ).rejects.toBeInstanceOf(StartHookError); + }); +}); + +describe("validateStartHookTextInput", () => { + const component = { + type: "text", + title: "Name", + description: "Instance name", + regex: "^[a-z-]+$", + validationError: "Lowercase letters and dashes only.", + } as const; + + it("accepts matching input", () => { + expect(validateStartHookTextInput(component, "my-vm")).toBeNull(); + }); + + it("returns the component's validation error for mismatches", () => { + expect(validateStartHookTextInput(component, "My VM")).toBe( + "Lowercase letters and dashes only.", + ); + }); + + it("does not lock the user out on an unparsable pattern", () => { + expect(validateStartHookTextInput({ ...component, regex: "(" }, "anything")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/startHook.ts b/apps/web/src/components/settings/startHook.ts new file mode 100644 index 00000000000..40a311b9ed1 --- /dev/null +++ b/apps/web/src/components/settings/startHook.ts @@ -0,0 +1,183 @@ +import { + StartHookForm, + type StartHookFormComponent, + StartHookPollState, + type StartHookSelectComponent, + type StartHookTextComponent, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +// Client side of the start hook protocol (see contracts/instanceHooks.ts). +// The management endpoint is a third-party origin, so it must allow CORS for +// the app origin; a preflight failure surfaces here as a network error. + +const decodePollState = Schema.decodeUnknownSync(StartHookPollState); +const decodeForm = Schema.decodeUnknownSync(StartHookForm); + +const MIN_POLL_INTERVAL_MS = 1_000; +const MAX_POLL_INTERVAL_MS = 60_000; +const POLL_DEADLINE_MS = 10 * 60_000; + +export type StartHookStep = + | { readonly kind: "ready" } + | { readonly kind: "poll"; readonly poll: StartHookPollState } + | { readonly kind: "form"; readonly form: StartHookForm }; + +export class StartHookError extends Error {} + +export type StartHookInputComponent = StartHookSelectComponent | StartHookTextComponent; + +export function isStartHookInputComponent( + component: StartHookFormComponent, +): component is StartHookInputComponent { + return "type" in component; +} + +export function validateStartHookTextInput( + component: StartHookTextComponent, + value: string, +): string | null { + let pattern: RegExp; + try { + pattern = new RegExp(component.regex); + } catch { + // An unparsable pattern is the management solution's bug; do not let it + // lock the user out of starting the instance. + return null; + } + return pattern.test(value) ? null : component.validationError; +} + +export interface StartHookRequestOptions { + readonly signal?: AbortSignal; + readonly fetchImpl?: typeof fetch; + readonly sleep?: (ms: number, signal?: AbortSignal) => Promise; +} + +function resolveFetch(options: StartHookRequestOptions): typeof fetch { + return options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init)); +} + +async function interpretStartHookResponse(response: Response): Promise { + if (response.status === 204) { + return { kind: "ready" }; + } + let body: unknown; + const readBody = async () => { + try { + body = await response.json(); + } catch (error) { + throw new StartHookError( + `The start hook returned status ${response.status} without a readable JSON body: ${String(error)}`, + ); + } + }; + if (response.status === 200) { + await readBody(); + try { + return { kind: "poll", poll: decodePollState(body) }; + } catch (error) { + throw new StartHookError( + `The start hook returned a malformed poll response: ${String(error)}`, + ); + } + } + if (response.status === 400) { + await readBody(); + try { + return { kind: "form", form: decodeForm(body) }; + } catch (error) { + throw new StartHookError( + `The start hook returned a malformed form response: ${String(error)}`, + ); + } + } + throw new StartHookError(`The start hook responded with unexpected status ${response.status}.`); +} + +async function postStartHook( + url: string, + body: string | null, + options: StartHookRequestOptions, +): Promise { + const fetchImpl = resolveFetch(options); + let response: Response; + try { + response = await fetchImpl(url, { + method: "POST", + signal: options.signal ?? null, + ...(body === null ? {} : { body, headers: { "content-type": "application/json" } }), + }); + } catch (error) { + if (options.signal?.aborted) throw error; + throw new StartHookError(`Could not reach the start hook: ${String(error)}`); + } + return interpretStartHookResponse(response); +} + +/** POST the start hook with no content, per the protocol's opening request. */ +export function requestStartHook( + url: string, + options: StartHookRequestOptions = {}, +): Promise { + return postStartHook(url, null, options); +} + +/** + * POST the resolved component values back as a JSON array, in component + * order, input components only. + */ +export function submitStartHookForm( + url: string, + values: ReadonlyArray, + options: StartHookRequestOptions = {}, +): Promise { + return postStartHook(url, JSON.stringify(values), options); +} + +const defaultSleep = (ms: number, signal?: AbortSignal) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); + +/** + * Poll until the instance reports ready with a 204. Any error status fails + * the run; the management endpoint signals "still starting" with a non-204 + * success response. + */ +export async function pollStartHookUntilReady( + poll: StartHookPollState, + options: StartHookRequestOptions = {}, +): Promise { + const fetchImpl = resolveFetch(options); + const sleep = options.sleep ?? defaultSleep; + const intervalMs = Math.min( + Math.max(poll.retry_secs * 1_000, MIN_POLL_INTERVAL_MS), + MAX_POLL_INTERVAL_MS, + ); + for (let elapsedMs = 0; elapsedMs <= POLL_DEADLINE_MS; elapsedMs += intervalMs) { + let response: Response; + try { + response = await fetchImpl(poll.poll_url, { method: "GET", signal: options.signal ?? null }); + } catch (error) { + if (options.signal?.aborted) throw error; + throw new StartHookError(`Could not poll the start hook: ${String(error)}`); + } + if (response.status === 204) { + return; + } + if (response.status >= 400) { + throw new StartHookError(`Polling the start hook failed with status ${response.status}.`); + } + await sleep(intervalMs, options.signal); + } + throw new StartHookError("The instance did not report ready in time."); +} diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339..e20c29c0e16 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -11,6 +11,7 @@ This is a living glossary for T3 Code. It explains what common terms mean in thi - [Orchestration](#orchestration) - [Provider runtime](#provider-runtime) - [Checkpointing](#checkpointing) +- [Instance hooks](#instance-hooks) ## Concepts @@ -140,6 +141,16 @@ The patch difference between two checkpoints. Query logic lives in [CheckpointDi The file patch and changed-file summary for one turn. It is usually computed in [CheckpointDiffQuery.ts][20], represented in [the contracts][1], and recorded into thread state by [projector.ts][4]. +### Instance hooks + +#### Start hook + +A management-solution HTTP endpoint, stored as `startHookUrl` in server settings, that a client POSTs before connecting so the managing service can boot the instance first. The client polls until the instance reports ready, optionally after collecting user input through a component form. See [instance-hooks.md](./instance-hooks.md). + +#### Stop hook + +A management-solution HTTP endpoint, stored as `stopHookUrl` in server settings, that the server DELETEs when the user clicks Stop on a connected environment. A 404 clears the setting so clients drop the control. See [instance-hooks.md](./instance-hooks.md). + ## Practical Shortcuts - If you see `requested`, think "intent recorded". diff --git a/docs/internals/instance-hooks.md b/docs/internals/instance-hooks.md new file mode 100644 index 00000000000..56ff36243d9 --- /dev/null +++ b/docs/internals/instance-hooks.md @@ -0,0 +1,106 @@ +# Instance Lifecycle Hooks + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +Instance hooks let a VM management solution start and stop the machine hosting a T3 server from +inside T3 Code, so the user never has to open the management app. The management solution owns two +HTTP endpoints; T3 Code calls them at the right moments. + +## Configuration + +Both hooks live in the server's settings (`settings.json`, schema in +[`packages/contracts/src/settings.ts`](../../packages/contracts/src/settings.ts)): + +```json +{ + "startHookUrl": "https://mgmt.example.com/instances/42/start", + "stopHookUrl": "https://mgmt.example.com/instances/42/stop" +} +``` + +A management solution that provisions the VM writes these when it installs T3 Code, or sets them +later through `server.updateSettings`. Both are nullable and null by default. + +Clients receive the URLs inside `ServerConfig.settings` and cache the config per environment +(IndexedDB on web). That cache is what makes the start hook usable: when the instance is off, the +server is unreachable, but the client still knows the start hook URL from the last time it was +connected. The cache is only dropped when the user removes the environment. + +## Start hook + +Runs client-side, on the Connections settings page, when the user clicks Connect on a saved +environment whose cached settings carry a `startHookUrl`. The protocol +([`packages/contracts/src/instanceHooks.ts`](../../packages/contracts/src/instanceHooks.ts), client +in [`apps/web/src/components/settings/startHook.ts`](../../apps/web/src/components/settings/startHook.ts)): + +1. `POST ` with no body. +2. The endpoint answers one of: + - `204` — the instance is already running; connect immediately. + - `200` with `{ "poll_url": "", "retry_secs": 5 }` — the instance is starting. + - `400` with a component form (below) — the endpoint needs user input first. +3. On a form response, the client renders the components in a dialog and POSTs the resolved values + back to the start hook URL as a JSON array (input components only, in component order). The + response is again interpreted per step 2, so an endpoint can re-prompt with another `400`. +4. On a poll response, the client GETs `poll_url` every `retry_secs` seconds (clamped to 1–60s, + overall deadline 10 minutes) until a `204` says the instance is up. Error statuses (>= 400) fail + the run; "still starting" is any other success status. +5. The normal connect flow runs. + +The form response shape: + +```json +{ + "button_text": "Start", + "components": [ + { "text": "Informational copy rendered as-is." }, + { + "type": "select", + "title": "Size", + "description": "Instance size to boot.", + "defaultValue": "small", + "values": [ + { "userTitle": "Small", "userDescription": "2 vCPU", "content": "small" }, + { "userTitle": "Large", "userDescription": "8 vCPU", "content": "large" } + ] + }, + { + "type": "text", + "title": "Region", + "description": "Where to boot.", + "regex": "^[a-z]{2}-[a-z]+$", + "validationError": "Use a region id like eu-west." + } + ] +} +``` + +A select resolves to the chosen value's `content`; a text input resolves to the entered string, +validated against `regex` client-side with `validationError` shown on mismatch. + +Because the browser calls the management endpoint directly, that endpoint must allow CORS for the +app origin (including the hosted app origin when connecting from `app.t3.codes`). + +The start hook runs only on an explicit Connect click. The connection supervisor's automatic +retries never call it, so a stopped instance is not restarted by background reconnect attempts. + +## Stop hook + +Runs server-side. The Connections page shows a Stop button on connected environments whose settings +carry a `stopHookUrl`; clicking it calls the `server.runStopHook` RPC (scope +`orchestration:operate`). The server DELETEs the stop hook URL +([`apps/server/src/instanceHooks.ts`](../../apps/server/src/instanceHooks.ts)): + +- `204` — the instance is stopping; the RPC reports `outcome: "stopped"`. +- `404` — the hook no longer exists. The server clears `stopHookUrl` from its settings (which + streams to clients and removes the Stop button) and reports `outcome: "gone"`. +- Anything else fails the RPC with `ServerStopHookError`. + +After a stop, the saved environment keeps its registration, credentials, and cached config; the +connection drops like any other server that went away, and the next Connect click runs the start +hook again. + +## Surfaces + +Web owns the UI today; desktop wraps web and gets both hooks with it. Mobile can dispatch +`server.runStopHook` through the shared client-runtime command but has no start-hook UI yet — the +connect gate belongs in its Connections screen when mobile picks this up. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 05418022d23..4c0a7ed8a99 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -206,6 +206,21 @@ Do not use hosted pairing for plain HTTP LAN URLs such as `http://192.168.x.y:37 Hosted pairing does not proxy traffic through T3 Code. The browser still connects directly to the backend URL in the pairing link. +## Starting and Stopping a Managed Instance + +If your server runs on a VM managed by a service that supports T3 Code instance hooks, you can +start and stop the machine from **Settings** → **Connections** without opening the management app. + +- **Start.** Clicking **Connect** on the saved environment first asks the management service to + boot the instance, waits until it reports ready, then connects as usual. The service may ask a + few questions first — for example the instance size — in a short form. +- **Stop.** Connected environments show a **Stop** button when the management service offers one. + Click it when work is done and the service shuts the instance down. Your saved environment stays + paired; the next **Connect** starts the instance again. + +The management service configures this by setting `startHookUrl` and `stopHookUrl` in the server's +settings. If you don't use a managed VM, nothing changes. + ## Managing Access Later Use `t3 auth` to manage access after the initial pairing flow. diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 8c61a939e9e..7c20c345326 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -749,6 +749,14 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), + runStopHook: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:run-stop-hook", + tag: WS_METHODS.serverRunStopHook, + concurrency: { + mode: "singleFlight", + key: ({ environmentId }) => environmentId, + }, + }), signalProcess: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:signal-process", tag: WS_METHODS.serverSignalProcess, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f0ee1889177..eb4bddf4afe 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -15,6 +15,7 @@ export * from "./model.ts"; export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; +export * from "./instanceHooks.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; diff --git a/packages/contracts/src/instanceHooks.ts b/packages/contracts/src/instanceHooks.ts new file mode 100644 index 00000000000..3d3bfe28bcd --- /dev/null +++ b/packages/contracts/src/instanceHooks.ts @@ -0,0 +1,100 @@ +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +// ── Instance lifecycle hooks ───────────────────────────────────────── +// +// A VM management solution that hosts a T3 server can set `startHookUrl` and +// `stopHookUrl` in the server's settings so users manage the instance from +// T3 Code instead of opening the management app. Clients cache the server +// settings with the rest of the server config, which keeps the start hook +// reachable while the instance is off. +// +// Start hook (client → management endpoint): `POST ` with no +// body. A `200` carries `StartHookPollState`; poll `poll_url` every +// `retry_secs` seconds until a `204`, then run the normal connect flow. A +// `400` carries `StartHookForm`; render the components, POST back a JSON +// array of the resolved component values, and expect `StartHookPollState`. +// +// Stop hook (server → management endpoint): `DELETE `. A `204` +// means the instance is stopping. A `404` means the hook no longer exists; +// the server clears the setting so clients drop the stop control. + +export const StartHookPollState = Schema.Struct({ + poll_url: TrimmedNonEmptyString, + retry_secs: Schema.Number, +}); +export type StartHookPollState = typeof StartHookPollState.Type; + +/** Informational text rendered above or between input components. */ +export const StartHookTextResponse = Schema.Struct({ + text: Schema.String, +}); +export type StartHookTextResponse = typeof StartHookTextResponse.Type; + +export const StartHookSelectValue = Schema.Struct({ + userTitle: Schema.String, + userDescription: Schema.String, + content: Schema.String, +}); +export type StartHookSelectValue = typeof StartHookSelectValue.Type; + +export const StartHookSelectComponent = Schema.Struct({ + type: Schema.Literal("select"), + title: Schema.String, + description: Schema.String, + defaultValue: Schema.String, + values: Schema.Array(StartHookSelectValue), +}); +export type StartHookSelectComponent = typeof StartHookSelectComponent.Type; + +export const StartHookTextComponent = Schema.Struct({ + type: Schema.Literal("text"), + title: Schema.String, + description: Schema.String, + regex: Schema.String, + validationError: Schema.String, +}); +export type StartHookTextComponent = typeof StartHookTextComponent.Type; + +export const StartHookFormComponent = Schema.Union([ + StartHookSelectComponent, + StartHookTextComponent, + StartHookTextResponse, +]); +export type StartHookFormComponent = typeof StartHookFormComponent.Type; + +export const StartHookForm = Schema.Struct({ + button_text: Schema.String, + components: Schema.Array(StartHookFormComponent), +}); +export type StartHookForm = typeof StartHookForm.Type; + +// ── Stop hook RPC shapes ───────────────────────────────────────────── + +export const ServerStopHookOutcome = Schema.Literals(["stopped", "gone"]); +export type ServerStopHookOutcome = typeof ServerStopHookOutcome.Type; + +export const ServerStopHookResult = Schema.Struct({ + outcome: ServerStopHookOutcome, +}); +export type ServerStopHookResult = typeof ServerStopHookResult.Type; + +export class ServerStopHookError extends Schema.TaggedErrorClass()( + "ServerStopHookError", + { + reason: Schema.Literals(["not-configured", "request-failed", "unexpected-status"]), + status: Schema.optional(Schema.Number), + detail: Schema.optional(Schema.String), + }, +) { + override get message(): string { + switch (this.reason) { + case "not-configured": + return "No stop hook is configured on this server."; + case "request-failed": + return `The stop hook request failed${this.detail === undefined ? "" : `: ${this.detail}`}.`; + case "unexpected-status": + return `The stop hook responded with unexpected status ${this.status ?? 0}.`; + } + } +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index db40b10fed9..eebfe03779a 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -153,6 +153,7 @@ import { ResourceTelemetrySnapshot, } from "./resourceTelemetry.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { ServerStopHookError, ServerStopHookResult } from "./instanceHooks.ts"; import { SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -234,6 +235,7 @@ export const WS_METHODS = { serverRemoveKeybinding: "server.removeKeybinding", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", + serverRunStopHook: "server.runStopHook", serverDiscoverSourceControl: "server.discoverSourceControl", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", @@ -339,6 +341,12 @@ export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSetting error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); +export const WsServerRunStopHookRpc = Rpc.make(WS_METHODS.serverRunStopHook, { + payload: Schema.Struct({}), + success: ServerStopHookResult, + error: Schema.Union([ServerStopHookError, ServerSettingsError, EnvironmentAuthorizationError]), +}); + export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { payload: Schema.Struct({}), success: SourceControlDiscoveryResult, @@ -813,6 +821,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsServerRunStopHookRpc, WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4b477227f26..f283b5cbb7e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -563,6 +563,15 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(true)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + // Instance lifecycle hooks, set by a VM management solution hosting this + // server. Clients POST the start hook before connecting and ask the server + // to DELETE the stop hook when work is done. See instanceHooks.ts. + startHookUrl: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + stopHookUrl: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ @@ -717,6 +726,8 @@ export const ServerSettingsPatch = Schema.Struct({ defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), + startHookUrl: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + stopHookUrl: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( Schema.Struct({