diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 73376110d9a..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 @@ -26,6 +27,7 @@ github:notkainoa github:PatrickBauer github:realAhmedRoach github:shiroyasha9 +github:StiensWout github:Yash-Singh1 github:eggfriedrice24 github:Ymit24 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/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/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 ? ( ) : ( 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..4fa93306b82 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, @@ -213,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"; @@ -1254,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, ); @@ -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); @@ -3943,7 +3980,7 @@ function ChatViewContent(props: ChatViewProps) { ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? - primaryServerSettings.newWorktreesStartFromOrigin) + settingsServerSettings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, @@ -5779,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 } : {}), }); @@ -5791,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/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) { 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/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) { 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..19685ee5f19 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -17,10 +17,10 @@ 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"; +import { useSettingsEnvironmentId } from "../../state/settingsEnvironment"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; @@ -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, @@ -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/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/components/settings/StartHookFormDialog.tsx b/apps/web/src/components/settings/StartHookFormDialog.tsx new file mode 100644 index 00000000000..e865d9fc51d --- /dev/null +++ b/apps/web/src/components/settings/StartHookFormDialog.tsx @@ -0,0 +1,171 @@ +import type { StartHookForm } from "@t3tools/contracts"; +import { useCallback, useId, useMemo, useState } from "react"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import { isStartHookInputComponent, validateStartHookTextInput } from "./startHook"; + +interface StartHookFormDialogProps { + readonly environmentLabel: string; + readonly form: StartHookForm; + readonly submitting: boolean; + readonly onSubmit: (values: ReadonlyArray) => 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/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/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/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", () => { 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], ); } 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..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"; @@ -28,10 +29,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 { settingsServerSettingsAtom, serverEnvironment } from "~/state/server"; +import { useSettingsEnvironmentId } from "~/state/settingsEnvironment"; import { useAtomCommand } from "~/state/use-atom-command"; +import { useTheme } from "./useTheme"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -226,15 +234,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, + }); } /** @@ -272,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); } /** @@ -319,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); +} 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) { 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({