diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 3c95b266bc18..50f82ac30261 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -63,6 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => calls.setDockIcon.push(iconPath); }), appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts new file mode 100644 index 000000000000..107ee21d2328 --- /dev/null +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -0,0 +1,123 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +describe("DesktopLifecycle", () => { + for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray) { + it.effect(`lets the updater's quit event proceed on ${platform}`, () => { + const appListeners = new Map void>(); + + const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: () => Effect.succeed(true), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: (listener) => + Effect.acquireRelease( + Effect.sync(() => { + appListeners.set("before-quit-for-update", listener); + }), + () => + Effect.sync(() => { + appListeners.delete("before-quit-for-update"); + }), + ).pipe(Effect.asVoid), + on: (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + appListeners.set( + eventName, + listener as unknown as (...args: readonly unknown[]) => void, + ); + }), + () => + Effect.sync(() => { + appListeners.delete(eventName); + }), + ).pipe(Effect.asVoid), + } satisfies ElectronApp.ElectronApp["Service"]); + + const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, + }); + + const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected window creation"), + ensureMain: Effect.die("unexpected window creation"), + revealOrCreateMain: Effect.die("unexpected window creation"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + dispatchMenuAction: () => Effect.void, + syncAppearance: Effect.void, + }); + + const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform, + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + + const layer = DesktopLifecycle.layer.pipe( + Layer.provideMerge(electronAppLayer), + Layer.provideMerge(electronThemeLayer), + Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + ); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + appListeners.get("before-quit-for-update")?.(); + + let prevented = false; + const event = { + preventDefault: () => { + prevented = true; + }, + } as Electron.Event; + appListeners.get("before-quit")?.(event); + + assert.isFalse( + prevented, + "cancelling this event prevents the updater from completing its relaunch", + ); + + const state = yield* DesktopState.DesktopState; + assert.isTrue(yield* Ref.get(state.quitting)); + }), + ).pipe(Effect.provide(layer)); + }); + } +}); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index f8e05915718e..ab03d18f38d4 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -176,16 +176,28 @@ export const make = DesktopLifecycle.of({ const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); let quitAllowed = false; + let updaterQuitAllowed = false; yield* electronTheme.onUpdated(() => { void runEffect( desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), ); }); + yield* electronApp.onBeforeQuitForUpdate(() => { + // Electron's updater owns the remaining quit/install/relaunch sequence. + // Cancelling the following app "before-quit" event breaks that sequence, + // most visibly on macOS where the native updater performs the relaunch. + updaterQuitAllowed = true; + void runEffect( + logLifecycleInfo("allowing updater-controlled quit").pipe( + Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"), + ), + ); + }); yield* electronApp.on("before-quit", (event: Electron.Event) => { handleBeforeQuit( event, runEffect, - () => quitAllowed, + () => quitAllowed || updaterQuitAllowed, () => { quitAllowed = true; }, diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index f3ce3b4b5f43..077b343959ca 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -4,6 +4,8 @@ import { beforeEach, vi } from "vite-plus/test"; const { appendSwitchMock, + autoUpdaterOnMock, + autoUpdaterRemoveListenerMock, exitMock, getAppPathMock, getVersionMock, @@ -23,6 +25,8 @@ const { whenReadyMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), + autoUpdaterOnMock: vi.fn(), + autoUpdaterRemoveListenerMock: vi.fn(), exitMock: vi.fn(), getAppPathMock: vi.fn(() => "/app"), getVersionMock: vi.fn(() => "1.2.3"), @@ -43,6 +47,10 @@ const { })); vi.mock("electron", () => ({ + autoUpdater: { + on: autoUpdaterOnMock, + removeListener: autoUpdaterRemoveListenerMock, + }, app: { commandLine: { appendSwitch: appendSwitchMock, @@ -77,6 +85,8 @@ import * as ElectronApp from "./ElectronApp.ts"; describe("ElectronApp", () => { beforeEach(() => { appendSwitchMock.mockClear(); + autoUpdaterOnMock.mockClear(); + autoUpdaterRemoveListenerMock.mockClear(); exitMock.mockClear(); onMock.mockClear(); quitMock.mockClear(); @@ -153,4 +163,22 @@ describe("ElectronApp", () => { assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); }).pipe(Effect.provide(ElectronApp.layer)), ); + + it.effect("scopes native updater quit listeners", () => + Effect.gen(function* () { + const listener = vi.fn(); + + yield* Effect.scoped( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.onBeforeQuitForUpdate(listener); + }), + ); + + assert.deepEqual(autoUpdaterOnMock.mock.calls, [["before-quit-for-update", listener]]); + assert.deepEqual(autoUpdaterRemoveListenerMock.mock.calls, [ + ["before-quit-for-update", listener], + ]); + }).pipe(Effect.provide(ElectronApp.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 0af8691f6c45..933f40e17058 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -66,6 +66,9 @@ export class ElectronApp extends Context.Service< readonly setDesktopName: (desktopName: string) => Effect.Effect; readonly setDockIcon: (iconPath: string) => Effect.Effect; readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; + readonly onBeforeQuitForUpdate: ( + listener: () => void, + ) => Effect.Effect; readonly on: >( eventName: string, listener: (...args: Args) => void, @@ -178,6 +181,16 @@ export const make = ElectronApp.of({ } Electron.app.commandLine.appendSwitch(switchName, value); }), + onBeforeQuitForUpdate: (listener) => + Effect.acquireRelease( + Effect.sync(() => { + Electron.autoUpdater.on("before-quit-for-update", listener); + }), + () => + Effect.sync(() => { + Electron.autoUpdater.removeListener("before-quit-for-update", listener); + }), + ).pipe(Effect.asVoid), on: addScopedAppListener, }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index a740fbafeee5..8d76ea83a33e 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,6 +18,7 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, + environmentIdentificationMode: "artwork", favorites: [], glassOpacity: 80, providerModelPreferences: {}, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 168846466ed7..0d48ab04ceb4 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -45,6 +45,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 608d43b2acb8..173d093d8495 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -24,13 +24,7 @@ import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironment import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; -/** - * "T3 Connect" section: every environment published to the signed-in account, - * with connect switches, availability status, refresh, and loading/error - * states. Shared between the Settings environments screen and the T3 Connect - * onboarding sheet. - */ -export function CloudEnvironmentRows(props: { +interface CloudEnvironmentRowsProps { readonly connectedCloudEnvironments: ReadonlyArray; readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; readonly showcaseAvailableEnvironments?: ReadonlyArray; @@ -41,8 +35,31 @@ export function CloudEnvironmentRows(props: { * pull-to-refresh). */ readonly showHeader?: boolean; -}) { +} + +/** + * "T3 Connect" section: every environment published to the signed-in account, + * with connect switches, availability status, refresh, and loading/error + * states. Shared between the Settings environments screen and the T3 Connect + * onboarding sheet. + */ +export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) { + // Showcase captures run without a Clerk publishable key, so `ClerkProvider` + // is never mounted and any `useAuth` call throws — the fixture states whether + // the rows are signed in instead of asking Clerk. + if (props.showcaseSignedIn !== undefined) { + return props.showcaseSignedIn ? : null; + } + return ; +} + +function SignedInCloudEnvironmentRows(props: CloudEnvironmentRowsProps) { const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + if (!isSignedIn) return null; + return ; +} + +function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) { const controller = useConnectionController(); const iconColor = useThemeColor("--color-icon"); const availableCloudEnvironments = @@ -67,8 +84,6 @@ export function CloudEnvironmentRows(props: { const showHeader = props.showHeader ?? true; - if (!(props.showcaseSignedIn ?? isSignedIn)) return null; - return ( {showHeader ? ( diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 22c0a31bda2f..0263e74eade5 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -36,12 +36,13 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "../threads/thread-list-items"; -import { ThreadListV2Row } from "../threads/thread-list-v2-items"; +import { ThreadListV2PendingRow, ThreadListV2Row } from "../threads/thread-list-v2-items"; import { buildThreadListV2Items, + buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2Item, + type ThreadListV2ListItem, } from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { @@ -549,50 +550,100 @@ export function HomeScreen(props: HomeScreenProps) { // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout // range) the boundary string is identical and the chain would die. }, [nextSnoozeWakeAt, snoozeWakeTick]); - const threadListV2Items = threadListV2Layout.items; + // Queued tasks are not thread shells, so the v2 partition never sees them; + // they are spliced in below the active block and stay visible and deletable + // while their environment is offline. Same environment scope and search + // filter as the list itself. + const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); + const v2PendingTasks = useMemo( + () => + props.pendingTasks.filter( + (pendingTask) => + (props.selectedEnvironmentId === null || + pendingTask.message.environmentId === props.selectedEnvironmentId) && + (v2ScopedProjectKeys === null || + v2ScopedProjectKeys.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + )) && + (v2SearchQuery.length === 0 || + pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), + ), + [props.pendingTasks, props.selectedEnvironmentId, v2ScopedProjectKeys, v2SearchQuery], + ); + const threadListV2Items = useMemo( + () => + buildThreadListV2ListItems({ + items: threadListV2Layout.items, + pendingTasks: v2PendingTasks, + }), + [threadListV2Layout.items, v2PendingTasks], + ); const renderV2Item = useCallback( - ({ item }: { readonly item: ThreadListV2Item }) => ( - - provider.instanceId === - (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId), - )?.driver ?? null - } - environmentLabel={ - Object.keys(props.savedConnectionsById).length > 1 - ? (props.savedConnectionsById[item.thread.environmentId]?.environmentLabel ?? null) - : null - } - onSelectThread={props.onSelectThread} - onDeleteThread={handleDeleteThread} - onArchiveThread={props.onArchiveThread} - settlementSupported={settlementEnvironmentIds.has(item.thread.environmentId)} - onSettleThread={handleSettleThread} - onUnsettleThread={handleUnsettleThread} - onChangeRequestState={handleChangeRequestState} - projectCwd={ - projectCwdByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? - null - } - onSwipeableClose={handleSwipeableClose} - onSwipeableWillOpen={handleSwipeableWillOpen} - /> - ), + ({ item }: { readonly item: ThreadListV2ListItem }) => { + if (item.type === "v2-pending") { + const pendingScopeKey = scopedProjectKey( + item.pendingTask.message.environmentId, + item.pendingTask.creation.projectId, + ); + return ( + 1 + ? (props.savedConnectionsById[item.pendingTask.message.environmentId] + ?.environmentLabel ?? null) + : null + } + showPendingDivider={item.showPendingDivider} + onSelectPendingTask={props.onSelectPendingTask} + onDeletePendingTask={props.onDeletePendingTask} + /> + ); + } + const thread = item.item.thread; + return ( + + provider.instanceId === + (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), + )?.driver ?? null + } + environmentLabel={ + Object.keys(props.savedConnectionsById).length > 1 + ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) + : null + } + onSelectThread={props.onSelectThread} + onDeleteThread={handleDeleteThread} + onArchiveThread={props.onArchiveThread} + settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} + onSettleThread={handleSettleThread} + onUnsettleThread={handleUnsettleThread} + onChangeRequestState={handleChangeRequestState} + projectCwd={ + projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null + } + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + /> + ); + }, [ handleChangeRequestState, handleDeleteThread, @@ -603,6 +654,8 @@ export function HomeScreen(props: HomeScreenProps) { projectByKey, projectCwdByKey, props.onArchiveThread, + props.onDeletePendingTask, + props.onSelectPendingTask, props.onSelectThread, props.savedConnectionsById, serverConfigs, @@ -610,10 +663,7 @@ export function HomeScreen(props: HomeScreenProps) { v2ProjectTitleByProjectKey, ], ); - const v2KeyExtractor = useCallback( - (item: ThreadListV2Item) => `${item.thread.environmentId}:${item.thread.id}`, - [], - ); + const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); const extraData = useMemo( () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), @@ -788,41 +838,9 @@ export function HomeScreen(props: HomeScreenProps) { ); - // v2 renders queued offline tasks above the thread cards — they are not - // thread shells, so the v2 item builder never sees them, but they must - // stay visible and deletable while their environment is offline. They - // respect the same environment scope and search filter as the list. - const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); - const v2PendingTasks = props.pendingTasks.filter( - (pendingTask) => - (props.selectedEnvironmentId === null || - pendingTask.message.environmentId === props.selectedEnvironmentId) && - (v2ScopedProjectKeys === null || - v2ScopedProjectKeys.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), - )) && - (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), - ); // Project scoping lives in the header filter menu (no inline chip row on // mobile — the menu is the one filter surface). - const v2ListHeader = ( - <> - {listHeader} - {v2PendingTasks.map((pendingTask, index) => ( - - ))} - - ); + const v2ListHeader = listHeader; const listEmpty = !hasResults ? ( hasSearchQuery ? ( @@ -846,37 +864,33 @@ export function HomeScreen(props: HomeScreenProps) { // is empty. Search outranks the scope — "No results" names the actionable // fact when a query is active. Snoozed threads outrank the rest: "No // threads yet" over an inbox that is merely all-snoozed reads as data - // loss. Pending tasks render in the header, so the list showing them - // isn't empty in the user's eyes. + // loss. const v2SnoozedCount = threadListV2Layout.snoozedCount; - const v2ListEmpty = - v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( - v2SnoozedCount > 0 ? ( - // The snoozed threads already passed this search filter: "No - // results" would claim nothing matched when matches are merely - // parked. - - ) : ( - - ) - ) : v2SnoozedCount > 0 ? ( + const v2ListEmpty = hasSearchQuery ? ( + v2SnoozedCount > 0 ? ( + // The snoozed threads already passed this search filter: "No + // results" would claim nothing matched when matches are merely + // parked. - ) : v2ScopedProjectGroup !== null ? ( - ) : ( - listEmpty - ); + + ) + ) : v2SnoozedCount > 0 ? ( + + ) : v2ScopedProjectGroup !== null ? ( + + ) : ( + listEmpty + ); if (threadListV2Enabled) { return ( @@ -888,6 +902,8 @@ export function HomeScreen(props: HomeScreenProps) { keyExtractor={v2KeyExtractor} extraData={{ projectByKey, + projectCwdByKey, + projectTitleByProjectKey: v2ProjectTitleByProjectKey, serverConfigs, savedConnectionsById: props.savedConnectionsById, }} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index ea8d4f07955e..3d413f9c4879 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -26,7 +26,7 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; -import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; +import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; @@ -62,26 +62,21 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "./thread-list-items"; -import { ThreadListV2Row } from "./thread-list-v2-items"; +import { ThreadListV2PendingRow, ThreadListV2Row } from "./thread-list-v2-items"; import { buildThreadListV2Items, + buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2Item, + type ThreadListV2ListItem, } from "./threadListV2"; /** The sidebar list serves both lists: v1 grouped items or, when the Thread - List v2 beta is on, queued offline tasks, flat v2 rows, and a settled + List v2 beta is on, flat v2 rows with queued tasks spliced in, and a settled "Show more" pager. */ type SidebarListItem = | HomeListItem - | { - readonly type: "v2-pending-task"; - readonly key: string; - readonly pendingTask: PendingNewTask; - readonly isLast: boolean; - } - | { readonly type: "v2-thread"; readonly key: string; readonly item: ThreadListV2Item } + | ThreadListV2ListItem | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; /** @@ -473,11 +468,11 @@ function ThreadNavigationSidebarPane( }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems = useMemo(() => { if (!threadListV2Enabled) return listLayout.items; - // Queued offline tasks render above the thread rows (mirrors the - // compact Home v2 list): they are not thread shells, so the v2 item - // builder never sees them, but they must stay visible and deletable - // while their environment is offline. Same environment scope and - // search filter as the list. + // Queued offline tasks are not thread shells, so the v2 item builder + // never sees them; the shared splice puts them below the active block + // (mirrors the compact Home v2 list) where they stay visible and + // deletable while their environment is offline. Same environment scope + // and search filter as the list. const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); const v2PendingTasks = pendingTasks.filter( (pendingTask) => @@ -490,19 +485,10 @@ function ThreadNavigationSidebarPane( (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ); - const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({ - type: "v2-pending-task" as const, - key: `v2-pending:${pendingTask.message.messageId}`, - pendingTask, - isLast: index === v2PendingTasks.length - 1, - })); - for (const item of threadListV2Layout.items) { - items.push({ - type: "v2-thread" as const, - key: scopedThreadKey(item.thread.environmentId, item.thread.id), - item, - }); - } + const items: SidebarListItem[] = buildThreadListV2ListItems({ + items: threadListV2Layout.items, + pendingTasks: v2PendingTasks, + }); if (threadListV2Layout.hiddenSettledCount > 0) { items.push({ type: "v2-show-more", @@ -690,13 +676,26 @@ function ThreadNavigationSidebarPane( onScroll: handleScroll, onScrollBeginDrag: handleScrollBeginDrag, }); + // Project shells load after the first rows draw, so the maps they feed have + // to bust the recycler's memoization — otherwise a row keeps the blank + // favicon and fallback title it was first rendered with. const listExtraData = useMemo( () => ({ selectedThreadKey: props.selectedThreadKey ?? "", + projectByKey, + projectCwdByKey, + projectTitleByProjectKey, savedConnectionsById, serverConfigs, }), - [props.selectedThreadKey, savedConnectionsById, serverConfigs], + [ + props.selectedThreadKey, + projectByKey, + projectCwdByKey, + projectTitleByProjectKey, + savedConnectionsById, + serverConfigs, + ], ); const sidebarItemsAreEqual = useCallback( (previous: SidebarListItem, item: SidebarListItem): boolean => { @@ -711,16 +710,19 @@ function ThreadNavigationSidebarPane( if (previous.type === "v2-show-more" && item.type === "v2-show-more") { return previous.hiddenCount === item.hiddenCount; } - if (previous.type === "v2-pending-task" && item.type === "v2-pending-task") { - return previous.pendingTask === item.pendingTask && previous.isLast === item.isLast; + if (previous.type === "v2-pending" && item.type === "v2-pending") { + return ( + previous.pendingTask === item.pendingTask && + previous.showPendingDivider === item.showPendingDivider + ); } if ( previous.type === "v2-thread" || previous.type === "v2-show-more" || - previous.type === "v2-pending-task" || + previous.type === "v2-pending" || item.type === "v2-thread" || item.type === "v2-show-more" || - item.type === "v2-pending-task" + item.type === "v2-pending" ) { return false; } @@ -748,20 +750,29 @@ function ThreadNavigationSidebarPane( const renderListItem = useCallback( ({ item }: { readonly item: SidebarListItem }) => { switch (item.type) { - case "v2-pending-task": + case "v2-pending": { + const pendingScopeKey = scopedProjectKey( + item.pendingTask.message.environmentId, + item.pendingTask.creation.projectId, + ); return ( - 1 + ? (savedConnectionsById[item.pendingTask.message.environmentId] + ?.environmentLabel ?? null) + : null } - isLast={item.isLast} + pane="sidebar" + showPendingDivider={item.showPendingDivider} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} /> ); + } case "v2-thread": { const thread = item.item.thread; const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 69729cb64690..2ab7e6cf9f43 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -14,6 +14,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; +import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; @@ -68,7 +69,9 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [ /** Rounded-row radius shared with the v1 sidebar rows. */ const SIDEBAR_V2_ROW_RADIUS = 12; -export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider(props: { +/** Section label + rule: the only structure in an otherwise flat list. */ +export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivider(props: { + readonly label: string; readonly pane?: "screen" | "sidebar"; }) { const borderColor = useThemeColor("--color-border"); @@ -79,12 +82,127 @@ export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivid props.pane === "sidebar" ? "px-3" : "px-5", )} > - Settled + {props.label} ); }); +const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +/** + * A queued new task, in the same idiom as an active v2 row: it is work the + * user wrote, so it reads like the threads it will become. "Queued" takes + * the status slot — the state is the one thing that differs — and stays + * uncolored because nothing is asked of the user; the environment is simply + * not reachable yet. + */ +export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: { + readonly pendingTask: PendingNewTask; + readonly project: EnvironmentProject | null; + readonly projectTitle?: string; + readonly environmentLabel: string | null; + readonly pane?: "screen" | "sidebar"; + /** Draws the "Pending" divider above the first queued row. */ + readonly showPendingDivider: boolean; + readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; + readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; +}) { + const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; + const drawerColor = useThemeColor("--color-drawer"); + const pressedBackgroundColor = useThemeColor("--color-subtle"); + const sidebarPane = props.pane === "sidebar"; + const projectTitle = + props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? ""; + const branch = pendingTask.creation.branch; + + const handleMenuAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); + }, + [onDeletePendingTask, pendingTask], + ); + + const rowContent = ( + <> + + {props.project ? ( + + ) : null} + + {projectTitle} + + Queued + + {/* One line, unlike the two an active row allows: a queued title is + derived from the whole prompt rather than written as a title, so the + second line is usually a stray word or emoji rather than meaning. */} + + {pendingTask.title} + + {branch || props.environmentLabel ? ( + + {branch ? ( + + {branch} + + ) : null} + {branch && props.environmentLabel ? " · " : null} + {props.environmentLabel ? ( + {props.environmentLabel} + ) : null} + + ) : null} + + ); + + return ( + <> + {props.showPendingDivider ? ( + + ) : null} + + onSelectPendingTask(pendingTask)} + style={ + sidebarPane + ? ({ pressed }) => ({ + backgroundColor: pressed ? pressedBackgroundColor : drawerColor, + borderRadius: SIDEBAR_V2_ROW_RADIUS, + paddingHorizontal: 12, + paddingVertical: 10, + }) + : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + } + > + {sidebarPane ? ( + rowContent + ) : ( + + {rowContent} + + + )} + + + + ); +}); + export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; @@ -423,7 +541,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { return ( <> - {props.showSettledDivider ? : null} + {props.showSettledDivider ? ( + + ) : null} { - it.each(["development", "preview"])("defaults on for the %s variant", (appVariant) => { - expect( - resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }), - ).toBe(true); + it("defaults on when the device has never chosen", () => { + expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe( + true, + ); }); - it.each(["production", undefined])("defaults off for the %s variant", (appVariant) => { - expect( - resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }), - ).toBe(false); + it("honors an explicit device opt-out", () => { + expect(resolveThreadListV2Enabled({ preference: false, preferencesLoaded: true })).toBe(false); + expect(resolveThreadListV2Enabled({ preference: true, preferencesLoaded: true })).toBe(true); }); - it("prefers an explicit device choice over the variant default", () => { - expect( - resolveThreadListV2Enabled({ - preference: false, - preferencesLoaded: true, - appVariant: "preview", - }), - ).toBe(false); - expect( - resolveThreadListV2Enabled({ - preference: true, - preferencesLoaded: true, - appVariant: "production", - }), - ).toBe(true); - }); - - it("holds v1 while preferences are still loading so the list does not remount", () => { - expect( - resolveThreadListV2Enabled({ - preference: undefined, - preferencesLoaded: false, - appVariant: "development", - }), - ).toBe(false); + it("holds the default while preferences are still loading so the list does not remount", () => { + expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: false })).toBe( + true, + ); }); }); @@ -362,3 +350,88 @@ describe("buildThreadListV2Items settled paging", () => { ]); }); }); + +function makePendingTask(id: string): PendingNewTask { + return { + message: { + environmentId, + threadId: ThreadId.make(`thread-${id}`), + messageId: MessageId.make(id), + commandId: CommandId.make(`command-${id}`), + text: id, + attachments: [], + createdAt: NOW, + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "worktree", + branch: null, + worktreePath: null, + }, + }, + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "worktree", + branch: null, + worktreePath: null, + }, + title: id, + }; +} + +describe("buildThreadListV2ListItems", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "active" }), + makeThread({ + id: ThreadId.make("settled"), + title: "settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + it("splices queued tasks between the active block and the settled tail", () => { + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [makePendingTask("queued-1"), makePendingTask("queued-2")], + }); + + expect( + items.map((item) => + item.type === "v2-pending" ? item.pendingTask.title : item.item.thread.id, + ), + ).toEqual(["active", "queued-1", "queued-2", "settled"]); + // Only the leading queued row labels the section, exactly like Settled. + expect( + items.filter((item) => item.type === "v2-pending" && item.showPendingDivider), + ).toHaveLength(1); + }); + + it("ends the list with queued tasks when nothing has settled yet", () => { + const activeOnly = buildThreadListV2Items({ + threads: [makeThread({ id: ThreadId.make("active"), title: "active" })], + environmentId: null, + searchQuery: "", + now: NOW, + }); + const items = buildThreadListV2ListItems({ + items: activeOnly.items, + pendingTasks: [makePendingTask("queued-1")], + }); + + expect(items.map((item) => item.type)).toEqual(["v2-thread", "v2-pending"]); + }); + + it("leaves the thread order untouched when nothing is queued", () => { + const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [] }); + + expect(items.map((item) => item.key)).toEqual([ + `v2-thread:${environmentId}:active`, + `v2-thread:${environmentId}:settled`, + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 62c0b39aeb8d..ab955d16d4d1 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -2,6 +2,8 @@ import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/stat import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { PendingNewTask } from "../../state/use-pending-new-tasks"; + /** * Thread List v2 model, ported from the web sidebar v2 * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). @@ -19,34 +21,23 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** - * Whether Thread List v2 is on by default for an app variant. The `development` - * and `preview` variants are mobile's nightly equivalents and opt in; - * `production` stays on v1. Counterpart of web's `resolveSidebarV2Default`. - */ -export function resolveThreadListV2Default(appVariant: unknown): boolean { - return appVariant === "development" || appVariant === "preview"; -} - -/** - * Resolved Thread List v2 state: the device-local preference if the user has - * set one, otherwise the default for this app variant. Preferences persist as - * sparse patches, so `undefined` genuinely means "never chosen". + * Thread List v2 is on by default on every app variant; the Settings → Beta + * toggle is an opt-out. Preferences persist as sparse patches, so `undefined` + * genuinely means "never chosen". * * `preferencesLoaded` guards the startup window: preferences load - * asynchronously, and treating "still loading" as "never chosen" would mount - * v2 on a development build and then flip to v1 once a stored opt-out arrives, - * remounting the whole list. While loading, hold v1 — the state both variants - * already start from. + * asynchronously, and rendering one list before the stored choice arrives would + * remount the whole thing a tick later. While loading, hold the default — that + * is where every device without an explicit opt-out lands anyway. */ export function resolveThreadListV2Enabled(input: { readonly preference: boolean | undefined; readonly preferencesLoaded: boolean; - readonly appVariant: unknown; }): boolean { if (!input.preferencesLoaded) { - return false; + return true; } - return input.preference ?? resolveThreadListV2Default(input.appVariant); + return input.preference ?? true; } export function resolveThreadListV2Status( @@ -124,6 +115,60 @@ export interface ThreadListV2Layout { readonly nextSnoozeWakeAt: string | null; } +export interface ThreadListV2ThreadListItem { + readonly type: "v2-thread"; + readonly key: string; + readonly item: ThreadListV2Item; +} + +export interface ThreadListV2PendingListItem { + readonly type: "v2-pending"; + readonly key: string; + readonly pendingTask: PendingNewTask; + /** First queued row after the active block draws the PENDING divider. */ + readonly showPendingDivider: boolean; +} + +export type ThreadListV2ListItem = ThreadListV2ThreadListItem | ThreadListV2PendingListItem; + +/** + * Splices queued tasks between the active block and the settled tail, so the + * list reads active → pending → settled. Queued work sits below the live + * threads because nothing can happen to it until its environment returns: + * it is waiting, not asking. Shared by the compact Home list and the iPad + * sidebar so both order and label the sections identically. + */ +export function buildThreadListV2ListItems(input: { + readonly items: ReadonlyArray; + readonly pendingTasks: ReadonlyArray; +}): ThreadListV2ListItem[] { + const threadItems = input.items.map( + (item): ThreadListV2ListItem => ({ + type: "v2-thread", + key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, + item, + }), + ); + if (input.pendingTasks.length === 0) return threadItems; + + const pendingItems = input.pendingTasks.map( + (pendingTask, index): ThreadListV2ListItem => ({ + type: "v2-pending", + key: `v2-pending:${pendingTask.message.messageId}`, + pendingTask, + showPendingDivider: index === 0, + }), + ); + // The settled tail begins at the row that draws the SETTLED divider; with + // no settled rows the queued block simply ends the list. + const settledStart = threadItems.findIndex( + (entry) => entry.type === "v2-thread" && entry.item.showSettledDivider, + ); + return settledStart === -1 + ? [...threadItems, ...pendingItems] + : [...threadItems.slice(0, settledStart), ...pendingItems, ...threadItems.slice(settledStart)]; +} + /** * Partitions visible threads into the active card block (creation order) and * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts index bb03b5aa9ad0..266bda944ae8 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts @@ -1,18 +1,13 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import Constants from "expo-constants"; import { mobilePreferencesAtom } from "../../state/preferences"; import { resolveThreadListV2Enabled } from "./threadListV2"; /** * Resolved Thread List v2 state: the device-local preference if the user has - * set one, otherwise the default for this app variant (on for development and - * preview, off for production). Every consumer must read through this rather - * than the raw preference, which is undefined until explicitly chosen. - * - * Kept out of `state/preferences.ts` so that module stays importable from node - * test environments, which have no `__DEV__` for expo-constants. + * set one, otherwise the default (on). Every consumer must read through this + * rather than the raw preference, which is undefined until explicitly chosen. */ export function useThreadListV2Enabled(): boolean { const preferencesResult = useAtomValue(mobilePreferencesAtom); @@ -20,6 +15,5 @@ export function useThreadListV2Enabled(): boolean { return resolveThreadListV2Enabled({ preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined, preferencesLoaded: loaded, - appVariant: Constants.expoConfig?.extra?.appVariant, }); } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index bbcf4131f31d..6b1018e2a0ad 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -25,9 +25,9 @@ export interface Preferences { readonly projectGroupingEnabled?: boolean; /** * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no - * client-settings sync, so the flat v2 thread list is opted into per - * device. Undefined means the user has never chosen, in which case the app - * variant decides — see `resolveThreadListV2Enabled`. + * client-settings sync, so the flat v2 thread list is opted out of per + * device. Undefined means the user has never chosen, which resolves to on — + * see `resolveThreadListV2Enabled`. */ readonly threadListV2Enabled?: boolean; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a3b2e3715fd6..b255c9396178 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3081,6 +3081,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("negotiates permessage-deflate with clients that offer it", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { cookie, url } = parseSessionCookieFromWsUrl(yield* getWsServerUrl("/ws")); + const openSocket = (perMessageDeflate: boolean) => + Effect.acquireRelease( + Effect.callback((resume) => { + const socket = new NodeSocket.NodeWS.WebSocket(url, { + perMessageDeflate, + ...(cookie ? { headers: { cookie } } : {}), + }); + socket.on("open", () => resume(Effect.succeed(socket))); + socket.on("error", (error) => resume(Effect.fail(error))); + }), + (socket) => Effect.sync(() => socket.close()), + ); + + const compressed = yield* openSocket(true); + // The ws client records the negotiated extension only when the server's + // 101 response accepted the offer. + assert.include(compressed.extensions, "permessage-deflate"); + + const plain = yield* openSocket(false); + assert.notInclude(plain.extensions, "permessage-deflate"); + }).pipe(Effect.scoped, Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 90a45e8e25f0..5c6acd62aea8 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,7 +14,7 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useSidebarV2Enabled } from "../hooks/useSettings"; +import { useEnvironmentIdentificationMode, useSidebarV2Enabled } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; import ThreadSidebarV2 from "./SidebarV2"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; @@ -62,7 +62,10 @@ function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); const isSidebarVisible = useSidebarVisibility(); - const stageBackdropVariant = useSidebarStageBackdropVariant(); + const environmentIdentificationMode = useEnvironmentIdentificationMode(); + const stageBackdropVariant = useSidebarStageBackdropVariant( + environmentIdentificationMode === "artwork", + ); const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle"); useEffect(() => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index fac3bf7d2455..d86fe39a77f3 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -67,8 +67,10 @@ import { import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { normalizeMarkdownLinkDestination, + resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, + type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; @@ -162,7 +164,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), - code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"], + code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], }, protocols: { ...defaultSchema.protocols, @@ -174,6 +176,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, + remarkTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ @@ -181,6 +184,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, + remarkTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ @@ -253,6 +257,33 @@ function remarkPreserveCodeMeta() { }; } +/** + * Fenced code also lands on the `code` component, and inline vs block is no + * longer distinguishable there once both render `` — so inline spans are + * tagged on the mdast, where the distinction still exists. Code inside a link + * label stays untagged: linkifying it would nest an anchor inside the link's + * anchor and steal its clicks. + */ +function remarkTagInlineCode() { + return (tree: MarkdownAstNode) => { + const visit = (node: MarkdownAstNode, insideLink: boolean) => { + if (node.type === "inlineCode" && !insideLink) { + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + dataInlineCode: "", + }, + }; + } + const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference"; + node.children?.forEach((child) => visit(child, childInsideLink)); + }; + + visit(tree, false); + }; +} + function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); @@ -276,11 +307,17 @@ function extractCodeBlock( const onlyChild = childNodes[0]; if ( - !isValidElement<{ className?: string; children?: ReactNode }>(onlyChild) || - onlyChild.type !== "code" + !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( + onlyChild, + ) ) { return null; } + // With a custom `code` component the child's type is that component, not + // the "code" tag — the hast node react-markdown attaches still names it. + if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") { + return null; + } return { className: onlyChild.props.className, @@ -816,6 +853,21 @@ function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map< return suffixByPath; } +const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; +const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g; + +function extractInlineCodeSpans(text: string): string[] { + const spans: string[] = []; + const segments = text.split(FENCED_CODE_SEGMENT_PATTERN); + for (let index = 0; index < segments.length; index += 2) { + for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) { + const span = match[1]?.trim(); + if (span) spans.push(span); + } + } + return spans; +} + function extractMarkdownLinkHrefs(text: string): string[] { const hrefs: string[] = []; for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { @@ -1281,10 +1333,24 @@ function ChatMarkdown({ } return metaByHref; }, [cwd, text]); + const inlineCodeFileLinkMetaByText = useMemo(() => { + const metaByText = new Map(); + for (const span of extractInlineCodeSpans(text)) { + if (metaByText.has(span)) continue; + const meta = resolveInlineCodeFileLinkMeta(span, cwd); + if (meta) { + metaByText.set(span, meta); + } + } + return metaByText; + }, [cwd, text]); const fileLinkParentSuffixByPath = useMemo(() => { - const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); + const filePaths = [ + ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), + ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), + ]; return buildFileLinkParentSuffixByPath(filePaths); - }, [markdownFileLinkMetaByHref]); + }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); @@ -1339,8 +1405,49 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); - const markdownComponents = useMemo( - () => ({ + const markdownComponents = useMemo(() => { + const fileLinkChip = ( + fileLinkMeta: MarkdownFileLinkMeta, + copyMarkdown: string, + className?: string, + ) => { + const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); + const labelParts = [fileLinkMeta.basename]; + if (typeof parentSuffix === "string" && parentSuffix.length > 0) { + labelParts.push(parentSuffix); + } + if (fileLinkMeta.line) { + labelParts.push( + `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, + ); + } + + return ( + openMarkdownFileInPreview(fileLinkMeta.filePath) + : undefined + } + className={className} + /> + ); + }; + + return { p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, @@ -1455,39 +1562,26 @@ function ChatMarkdown({ ); } - const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); - const labelParts = [fileLinkMeta.basename]; - if (typeof parentSuffix === "string" && parentSuffix.length > 0) { - labelParts.push(parentSuffix); - } - if (fileLinkMeta.line) { - labelParts.push( - `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, - ); + return fileLinkChip( + fileLinkMeta, + `[${fileLinkMeta.basename}](${normalizedHref})`, + props.className, + ); + }, + code({ node, children, className, ...props }) { + if (node?.properties?.dataInlineCode != null) { + const codeText = nodeToPlainText(children); + const fileLinkMeta = + inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? + resolveInlineCodeFileLinkMeta(codeText, cwd); + if (fileLinkMeta) { + return fileLinkChip(fileLinkMeta, `\`${codeText}\``); + } } - return ( - openMarkdownFileInPreview(fileLinkMeta.filePath) - : undefined - } - className={props.className} - /> + + {children} + ); }, table({ node: _node, ...props }) { @@ -1524,22 +1618,23 @@ function ChatMarkdown({ ); }, - }), - [ - diffThemeName, - fileLinkParentSuffixByPath, - isStreaming, - markdownFileLinkMetaByHref, - onTaskListChange, - openInPreferredEditor, - openExternalLinkInPreview, - openMarkdownFileInPreview, - resolvedTheme, - skills, - text, - threadRef, - ], - ); + }; + }, [ + cwd, + diffThemeName, + fileLinkParentSuffixByPath, + inlineCodeFileLinkMetaByText, + isStreaming, + markdownFileLinkMetaByHref, + onTaskListChange, + openInPreferredEditor, + openExternalLinkInPreview, + openMarkdownFileInPreview, + resolvedTheme, + skills, + text, + threadRef, + ]); return (
diff --git a/apps/web/src/components/SidebarStageBackdrop.test.tsx b/apps/web/src/components/SidebarStageBackdrop.test.tsx index 41aee21e3271..114fd5f9241e 100644 --- a/apps/web/src/components/SidebarStageBackdrop.test.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.test.tsx @@ -1,9 +1,28 @@ import { describe, expect, it } from "vite-plus/test"; import { renderToStaticMarkup } from "react-dom/server"; -import { StageBackdropArt, StageBackdropButtonArt } from "./SidebarStageBackdrop"; +import { + resolveEnvironmentIdentificationPillLabel, + resolveSidebarStageBackdropVariant, + StageBackdropArt, + StageBackdropButtonArt, +} from "./SidebarStageBackdrop"; describe("SidebarStageBackdrop", () => { + it("resolves stage artwork only when enabled", () => { + expect(resolveSidebarStageBackdropVariant("Dev")).toBe("dev"); + expect(resolveSidebarStageBackdropVariant("Nightly")).toBe("nightly"); + expect(resolveSidebarStageBackdropVariant("Dev", false)).toBeNull(); + expect(resolveSidebarStageBackdropVariant("Alpha")).toBeNull(); + }); + + it("resolves supported environment pill labels", () => { + expect(resolveEnvironmentIdentificationPillLabel("Dev")).toBe("Dev"); + expect(resolveEnvironmentIdentificationPillLabel("nightly")).toBe("Nightly"); + expect(resolveEnvironmentIdentificationPillLabel("Latest")).toBeNull(); + expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull(); + }); + it.each(["nightly", "dev"] as const)( "uses unique SVG definition ids when %s artwork is rendered more than once", (variant) => { diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index ba3de64de151..9fb448e940de 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -6,6 +6,7 @@ import { resolveServerBackedAppStageLabel } from "../branding.logic"; import { primaryServerConfigAtom } from "../state/server"; export type SidebarStageBackdropVariant = "nightly" | "dev"; +export type EnvironmentIdentificationPillLabel = "Dev" | "Nightly"; // A wide viewBox keeps the 96-unit art height at a fixed scale while sidebar resizing reveals // more horizontal canvas instead of zooming the scene. @@ -13,23 +14,36 @@ const STAGE_BACKDROP_VIEW_BOX = "0 0 8192 96"; export function resolveSidebarStageBackdropVariant( stageLabel: string, + enabled = true, ): SidebarStageBackdropVariant | null { + if (!enabled) return null; const normalized = stageLabel.trim().toLowerCase(); if (normalized === "nightly") return "nightly"; if (normalized === "dev") return "dev"; return null; } -export function useSidebarStageBackdropVariant(): SidebarStageBackdropVariant | null { +export function resolveEnvironmentIdentificationPillLabel( + stageLabel: string, +): EnvironmentIdentificationPillLabel | null { + const normalized = stageLabel.trim().toLowerCase(); + if (normalized === "dev") return "Dev"; + if (normalized === "nightly") return "Nightly"; + return null; +} + +export function useEnvironmentStageLabel(): string { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - return resolveSidebarStageBackdropVariant( - resolveServerBackedAppStageLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }), - ); + return resolveServerBackedAppStageLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); +} + +export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBackdropVariant | null { + return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), enabled); } /** Stage-channel header art; palettes mirror the per-channel app icons in `assets/`. */ diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 3e664e226b75..19c20e2abf24 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,5 +1,6 @@ import { memo, type PointerEventHandler } from "react"; import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; +import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; import { Button } from "../ui/button"; @@ -72,7 +73,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } : undefined; - const stageBackdropVariant = useSidebarStageBackdropVariant(); + const environmentIdentificationMode = useEnvironmentIdentificationMode(); + const stageBackdropVariant = useSidebarStageBackdropVariant( + environmentIdentificationMode === "artwork", + ); if (pendingAction) { return ( diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 444f35b425c5..0acff1a8f6cd 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -52,10 +52,13 @@ export function renderSkillInlineMarkdownChildren( if (typeof child === "string") { return ; } - if (!isValidElement<{ children?: ReactNode }>(child)) { + if (!isValidElement<{ children?: ReactNode; node?: { tagName?: string } }>(child)) { return child; } - if (child.type === "code" || child.type === "a") { + // Custom react-markdown components replace the intrinsic type, so also + // check the hast node they carry. + const markdownTagName = typeof child.type === "string" ? child.type : child.props.node?.tagName; + if (markdownTagName === "code" || markdownTagName === "a") { return child; } if (!("children" in child.props)) { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 310cdd5164c4..611cafc14536 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -21,7 +21,9 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { + DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, DEFAULT_UNIFIED_SETTINGS, + type EnvironmentIdentificationMode, MAX_GLASS_OPACITY, MIN_GLASS_OPACITY, } from "@t3tools/contracts/settings"; @@ -40,6 +42,10 @@ import { } from "../../components/desktopUpdate.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; +import { + resolveEnvironmentIdentificationPillLabel, + useEnvironmentStageLabel, +} from "../SidebarStageBackdrop"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; @@ -114,6 +120,12 @@ const THEME_OPTIONS = [ }, ] as const; +const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { + artwork: "Artwork", + pill: "Version pill", + none: "None", +}; + const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", @@ -401,6 +413,10 @@ export function useSettingsRestore(onRestored?: () => void) { () => [ ...(theme !== "system" ? ["Theme"] : []), ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), + ...(settings.environmentIdentificationMode !== + DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode + ? ["Environment identification"] + : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -456,6 +472,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, + settings.environmentIdentificationMode, settings.glassOpacity, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, @@ -483,6 +500,7 @@ export function useSettingsRestore(onRestored?: () => void) { timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, + environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, @@ -506,55 +524,23 @@ export function useSettingsRestore(onRestored?: () => void) { }; } -export function GeneralSettingsPanel() { +export function AppearanceSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); - const lastEnabledProjectGroupingMode = useRef( - readLastEnabledProjectGroupingMode(), - ); - const observability = useAtomValue(primaryServerObservabilityAtom); - const serverProviders = useAtomValue(primaryServerProvidersAtom); + const environmentStageLabel = useEnvironmentStageLabel(); + const showEnvironmentIdentification = + resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; const glassOpacityRatio = (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); const glassOpacitySliderStyle = { "--glass-slider-progress": `${glassOpacityRatio * 100}%`, "--glass-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`, } as CSSProperties; - const diagnosticsDescription = formatDiagnosticsDescription({ - localTracingEnabled: observability?.localTracingEnabled ?? false, - otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, - otlpTracesUrl: observability?.otlpTracesUrl, - otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false, - otlpMetricsUrl: observability?.otlpMetricsUrl, - }); - - const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); - const textGenInstanceId = textGenerationModelSelection.instanceId; - const textGenModel = textGenerationModelSelection.model; - const textGenModelOptions = textGenerationModelSelection.options; - const textGenerationModelInstanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), - ); - const textGenInstanceEntry = textGenerationModelInstanceEntries.find( - (entry) => entry.instanceId === textGenInstanceId, - ); - const textGenProvider: ProviderDriverKind = - textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND; - const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance( - settings, - serverProviders, - textGenInstanceId, - textGenModel, - ); - const isTextGenerationModelDirty = !Equal.equals( - settings.textGenerationModelSelection ?? null, - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, - ); return ( - + + {showEnvironmentIdentification ? ( + + updateSettings({ + environmentIdentificationMode: DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, + }) + } + /> + ) : null + } + control={ + + } + /> + ) : null} + + + updateSettings({ + wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, + }) + } + /> + ) : null + } + control={ + updateSettings({ wordWrap: Boolean(checked) })} + aria-label="Wrap code, tables, diffs, and file previews by default" + /> + } + /> + + + ); +} + +export function GeneralSettingsPanel() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const lastEnabledProjectGroupingMode = useRef( + readLastEnabledProjectGroupingMode(), + ); + const observability = useAtomValue(primaryServerObservabilityAtom); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const diagnosticsDescription = formatDiagnosticsDescription({ + localTracingEnabled: observability?.localTracingEnabled ?? false, + otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, + otlpTracesUrl: observability?.otlpTracesUrl, + otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false, + otlpMetricsUrl: observability?.otlpMetricsUrl, + }); + + const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const textGenInstanceId = textGenerationModelSelection.instanceId; + const textGenModel = textGenerationModelSelection.model; + const textGenModelOptions = textGenerationModelSelection.options; + const textGenerationModelInstanceEntries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + ); + const textGenInstanceEntry = textGenerationModelInstanceEntries.find( + (entry) => entry.instanceId === textGenInstanceId, + ); + const textGenProvider: ProviderDriverKind = + textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND; + const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance( + settings, + serverProviders, + textGenInstanceId, + textGenModel, + ); + const isTextGenerationModelDirty = !Equal.equals( + settings.textGenerationModelSelection ?? null, + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, + ); + + return ( + + - - updateSettings({ - wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, - }) - } - /> - ) : null - } - control={ - updateSettings({ wordWrap: Boolean(checked) })} - aria-label="Wrap code, tables, diffs, and file previews by default" - /> - } - /> - ; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, + { label: "Appearance", to: "/settings/appearance", icon: PaletteIcon }, { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index b8d9512001ca..09fcd773128f 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,13 +1,16 @@ -import { useAtomValue } from "@effect/atom-react"; import { SettingsIcon } from "lucide-react"; import { memo, useCallback } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; -import { APP_STAGE_LABEL } from "../../branding"; +import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; -import { primaryServerConfigAtom } from "../../state/server"; -import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic"; -import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; +import { + resolveEnvironmentIdentificationPillLabel, + resolveSidebarStageBackdropVariant, + SidebarStageBackdrop, + useEnvironmentStageLabel, +} from "../SidebarStageBackdrop"; +import { Badge } from "../ui/badge"; import { SidebarFooter, SidebarHeader, @@ -25,8 +28,16 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { - const stageLabel = useSidebarStageLabel(); - const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel); + const stageLabel = useEnvironmentStageLabel(); + const environmentIdentificationMode = useEnvironmentIdentificationMode(); + const backdropVariant = resolveSidebarStageBackdropVariant( + stageLabel, + environmentIdentificationMode === "artwork", + ); + const pillLabel = + environmentIdentificationMode === "pill" + ? resolveEnvironmentIdentificationPillLabel(stageLabel) + : null; return ( + {pillLabel ? ( + + {pillLabel} + + ) : null} ); }); @@ -71,16 +92,6 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { ); } -function useSidebarStageLabel() { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - - return resolveSidebarStageBadgeLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }); -} - function T3Wordmark() { return ( { + it("keeps identification hidden until client settings hydrate", () => { + expect(resolveEnvironmentIdentificationMode({ mode: "artwork", settingsHydrated: false })).toBe( + "none", + ); + expect(resolveEnvironmentIdentificationMode({ mode: "pill", settingsHydrated: true })).toBe( + "pill", + ); + }); +}); describe("mergeEnvironmentSettings", () => { it("combines the selected environment's server settings with client preferences", () => { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 9f78e8a85270..739f6cfcea5c 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -21,6 +21,7 @@ import { type ClientSettingsPatch, type ClientSettings, DEFAULT_CLIENT_SETTINGS, + type EnvironmentIdentificationMode, type UnifiedSettings, } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; @@ -222,6 +223,20 @@ export function useClientSettings( return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]); } +export function resolveEnvironmentIdentificationMode(input: { + mode: EnvironmentIdentificationMode; + settingsHydrated: boolean; +}): EnvironmentIdentificationMode { + // Avoid briefly rendering the default artwork before a persisted pill/none choice loads. + return input.settingsHydrated ? input.mode : "none"; +} + +export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode { + const settingsHydrated = useClientSettingsHydrated(); + const mode = useClientSettingsValue().environmentIdentificationMode; + return resolveEnvironmentIdentificationMode({ mode, settingsHydrated }); +} + /** * Resolved sidebar v2 state: an explicit choice in Settings → Beta if the user * has made one, otherwise the default for this build stage (on for nightly and diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 5691ffa88957..9fc296138672 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref, @@ -127,3 +128,148 @@ describe("resolveMarkdownFileLinkTarget", () => { expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); }); }); + +describe("resolveInlineCodeFileLinkMeta", () => { + it("links relative paths with file extensions", () => { + expect( + resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md", "/Users/julius/project"), + ).toMatchObject({ + targetPath: "/Users/julius/project/.plans/worktree-management-v1.md", + basename: "worktree-management-v1.md", + }); + }); + + it("links absolute posix paths", () => { + expect(resolveInlineCodeFileLinkMeta("/Users/julius/project/AGENTS.md")).toMatchObject({ + targetPath: "/Users/julius/project/AGENTS.md", + }); + expect(resolveInlineCodeFileLinkMeta("/usr/local/bin/tool")).toMatchObject({ + targetPath: "/usr/local/bin/tool", + }); + expect(resolveInlineCodeFileLinkMeta("/workspace/Makefile")).toMatchObject({ + basename: "Makefile", + }); + expect(resolveInlineCodeFileLinkMeta("/chat/settings")).toBeNull(); + }); + + it("links windows drive paths", () => { + expect(resolveInlineCodeFileLinkMeta("C:\\Users\\mike\\project\\src\\main.ts")).toMatchObject({ + basename: "main.ts", + }); + }); + + it("links relative paths with line positions", () => { + expect( + resolveInlineCodeFileLinkMeta("src/processRunner.ts:71", "/Users/julius/project"), + ).toMatchObject({ + targetPath: "/Users/julius/project/src/processRunner.ts:71", + line: 71, + }); + }); + + it("links bare filenames only when a line suffix marks them as file references", () => { + expect(resolveInlineCodeFileLinkMeta("script.ts:10", "/Users/julius/project")).toMatchObject({ + targetPath: "/Users/julius/project/script.ts:10", + line: 10, + }); + expect(resolveInlineCodeFileLinkMeta("AGENTS.md", "/Users/julius/project")).toBeNull(); + }); + + it("links extensionless bare filenames with a line suffix", () => { + expect(resolveInlineCodeFileLinkMeta("Makefile:12", "/Users/julius/project")).toMatchObject({ + targetPath: "/Users/julius/project/Makefile:12", + basename: "Makefile", + line: 12, + }); + expect(resolveInlineCodeFileLinkMeta("Dockerfile:8:2", "/Users/julius/project")).toMatchObject({ + line: 8, + column: 2, + }); + expect(resolveInlineCodeFileLinkMeta("Makefile:12")).toBeNull(); + }); + + it("does not treat arbitrary name:digits shapes as files", () => { + expect(resolveInlineCodeFileLinkMeta("error:1", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("TODO:12", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("exit:0", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("port:3000", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("http:80", "/Users/julius/project")).toBeNull(); + }); + + it("links dot-prefixed relative paths without extensions", () => { + expect( + resolveInlineCodeFileLinkMeta("./scripts/deploy", "/Users/julius/project"), + ).toMatchObject({ + basename: "deploy", + }); + }); + + it("links relative windows-style paths by normalizing backslashes", () => { + expect(resolveInlineCodeFileLinkMeta("src\\main.ts", "/Users/julius/project")).toMatchObject({ + targetPath: "/Users/julius/project/src/main.ts", + basename: "main.ts", + }); + expect( + resolveInlineCodeFileLinkMeta(".\\scripts\\deploy", "/Users/julius/project"), + ).toMatchObject({ + basename: "deploy", + }); + }); + + it("ignores hosts, ports, and versions", () => { + expect(resolveInlineCodeFileLinkMeta("127.0.0.1:3000", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("localhost:3000", "/Users/julius/project")).toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("example.com/index.html", "/Users/julius/project"), + ).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("example.com:8080", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("10.0.0.1:80:1", "/Users/julius/project")).toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("localhost/index.html", "/Users/julius/project"), + ).toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("example.uk/index.html", "/Users/julius/project"), + ).toBeNull(); + }); + + it("still links files whose extension merely resembles a tld", () => { + expect(resolveInlineCodeFileLinkMeta("script.ts:10", "/Users/julius/project")).not.toBeNull(); + expect(resolveInlineCodeFileLinkMeta("src/setup.sh:3", "/Users/julius/project")).not.toBeNull(); + expect(resolveInlineCodeFileLinkMeta("Makefile.in:12", "/Users/julius/project")).not.toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("conf.d/nginx.conf", "/Users/julius/project"), + ).not.toBeNull(); + }); + + it("prefers file over country-code host when a line suffix is present", () => { + expect(resolveInlineCodeFileLinkMeta("script.pl:10", "/Users/julius/project")).toMatchObject({ + targetPath: "/Users/julius/project/script.pl:10", + line: 10, + }); + expect(resolveInlineCodeFileLinkMeta("model.pt:3", "/Users/julius/project")).not.toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("example.pl/index.html", "/Users/julius/project"), + ).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("example.com:8080", "/Users/julius/project")).toBeNull(); + }); + + it("ignores commands, flags, and expressions", () => { + expect(resolveInlineCodeFileLinkMeta("git worktree list --porcelain")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("node.meta", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("pnpm install", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("src/**/*.ts", "/Users/julius/project")).toBeNull(); + }); + + it("ignores extension-less relative segments like git refs and directories", () => { + expect(resolveInlineCodeFileLinkMeta("origin/main", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("apps/web", "/Users/julius/project")).toBeNull(); + }); + + it("ignores external urls", () => { + expect(resolveInlineCodeFileLinkMeta("https://example.com/docs.html")).toBeNull(); + }); + + it("ignores relative paths without a cwd to resolve against", () => { + expect(resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md")).toBeNull(); + }); +}); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 1e24de8bb1de..a6dba941b8ac 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -9,6 +9,8 @@ const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; +// Standard OS and dev-container roots; deliberately excludes app-route-ish +// prefixes like /app/ or /chat/ so SPA routes never read as files. const POSIX_FILE_ROOT_PREFIXES = [ "/Users/", "/home/", @@ -20,6 +22,20 @@ const POSIX_FILE_ROOT_PREFIXES = [ "/Volumes/", "/private/", "/root/", + "/usr/", + "/bin/", + "/sbin/", + "/lib/", + "/lib64/", + "/srv/", + "/dev/", + "/proc/", + "/sys/", + "/run/", + "/boot/", + "/media/", + "/workspace/", + "/workspaces/", ] as const; export interface MarkdownFileLinkMeta { @@ -170,6 +186,178 @@ export function resolveMarkdownFileLinkTarget( return resolvePathLinkTarget(pathWithPosition, cwd); } +const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; +const PATH_SEPARATOR_PATTERN = /[\\/]/; +const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; +const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; +const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; +// Any `Name:digits` shape also matches `error:1`, `port:3000`, `TODO:12`, so +// extensionless linking is limited to conventional filenames. +const EXTENSIONLESS_FILE_NAMES = new Set([ + "Makefile", + "makefile", + "GNUmakefile", + "Dockerfile", + "Containerfile", + "Justfile", + "justfile", + "Rakefile", + "Gemfile", + "Procfile", + "Brewfile", + "Caddyfile", + "Vagrantfile", + "Jenkinsfile", + "Podfile", + "Fastfile", + "BUILD", + "WORKSPACE", + "LICENSE", + "LICENCE", + "COPYING", + "NOTICE", + "AUTHORS", + "CONTRIBUTORS", + "CHANGELOG", + "README", + "CODEOWNERS", +]); +const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]); +// Allowlists, not full public-suffix detection: treating every dotted first +// segment as a host would swallow real paths like `conf.d/x.conf` or +// `Makefile.in:12`. Extensions that double as filename suffixes (`sh`, `md`, +// `ts`, `rs`, `in`, ...) are deliberately absent from both sets. +const GENERIC_HOSTNAME_TLDS = new Set([ + "com", + "net", + "org", + "io", + "dev", + "app", + "ai", + "co", + "edu", + "gov", + "mil", + "info", + "biz", + "xyz", + "me", + "tv", + "cc", + "gg", + "chat", + "cloud", + "site", + "online", + "tech", + "store", + "link", +]); +// Country codes collide with file extensions (`.pl` Perl, `.pt` PyTorch, +// `.es` ES modules), so they only count as host evidence when the candidate +// lacks a :line suffix — an explicit line reference marks a file and wins. +const COUNTRY_HOSTNAME_TLDS = new Set([ + "uk", + "de", + "fr", + "nl", + "se", + "no", + "fi", + "dk", + "pl", + "ch", + "at", + "be", + "es", + "it", + "pt", + "eu", + "us", + "ca", + "au", + "nz", + "jp", + "kr", + "cn", + "br", + "ru", + "mx", + "ie", + "cz", + "tr", + "sg", + "hk", +]); + +/** `127.0.0.1`, `localhost`, `example.com`, `1.2.3` — hosts and versions, not files. */ +function looksLikeHostname(segment: string, hasPosition: boolean): boolean { + if (segment.startsWith(".")) return false; + const lowered = segment.toLowerCase(); + if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true; + if (NUMERIC_DOTTED_PATTERN.test(segment)) return true; + const labels = lowered.split("."); + const lastLabel = labels[labels.length - 1]; + if (labels.length < 2 || lastLabel === undefined) return false; + if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true; + return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel); +} + +/** + * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`, + * `origin/main`) rather than deliberate link destinations, so auto-linking + * them demands stronger path evidence than an explicit markdown link does: + * an unambiguous path prefix, a file extension, or a :line suffix. + */ +export function resolveInlineCodeFileLinkMeta( + codeText: string, + cwd?: string, +): MarkdownFileLinkMeta | null { + const trimmed = codeText.trim(); + if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; + + // Windows drive/UNC paths keep their backslashes; any other backslashes are + // relative Windows-style paths, which neither the shape checks nor the + // downstream resolver understand — normalize them to forward slashes. + const candidate = + WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed) + ? trimmed + : trimmed.replaceAll("\\", "/"); + + const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); + if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; + + const hasExplicitPathShape = + RELATIVE_PATH_PREFIX_PATTERN.test(candidate) || + candidate.startsWith("/") || + WINDOWS_DRIVE_PATH_PATTERN.test(candidate) || + WINDOWS_UNC_PATH_PATTERN.test(candidate); + if (!hasExplicitPathShape) { + const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); + const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; + if (looksLikeHostname(firstSegment, hasPosition)) return null; + if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basenameOfPath(withoutPosition))) { + return null; + } + } + + const resolved = resolveMarkdownFileLinkMeta(candidate, cwd); + if (resolved) return resolved; + + // `Makefile:12` — conventional extensionless names fail the generic + // markdown-link candidate patterns, but here the :line suffix already + // marked the span as a file reference. + if ( + cwd && + BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate) && + EXTENSIONLESS_FILE_NAMES.has(candidate.replace(POSITION_SUFFIX_PATTERN, "")) + ) { + return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd); + } + return null; +} + function basenameOfPath(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; @@ -194,7 +382,10 @@ export function resolveMarkdownFileLinkMeta( ): MarkdownFileLinkMeta | null { const targetPath = resolveMarkdownFileLinkTarget(href, cwd); if (!targetPath) return null; + return buildFileLinkMetaFromTarget(targetPath, cwd); +} +function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { const { path, line, column } = splitPathAndPosition(targetPath); const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 563d1b43755e..58ab4c3a714e 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagn import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -90,6 +91,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({ + id: '/appearance', + path: '/appearance', + getParentRoute: () => SettingsRoute, +} as any) const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ id: '/connect_/callback', path: '/connect/callback', @@ -113,6 +119,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute + '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute @@ -129,6 +136,7 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute + '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute @@ -148,6 +156,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/connect_/callback': typeof ConnectCallbackRoute + '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute @@ -168,6 +177,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/connect/callback' + | '/settings/appearance' | '/settings/archived' | '/settings/beta' | '/settings/connections' @@ -184,6 +194,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/connect/callback' + | '/settings/appearance' | '/settings/archived' | '/settings/beta' | '/settings/connections' @@ -202,6 +213,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/connect_/callback' + | '/settings/appearance' | '/settings/archived' | '/settings/beta' | '/settings/connections' @@ -316,6 +328,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/settings/appearance': { + id: '/settings/appearance' + path: '/appearance' + fullPath: '/settings/appearance' + preLoaderRoute: typeof SettingsAppearanceRouteImport + parentRoute: typeof SettingsRoute + } '/connect_/callback': { id: '/connect_/callback' path: '/connect/callback' @@ -355,6 +374,7 @@ const ChatRouteChildren: ChatRouteChildren = { const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { + SettingsAppearanceRoute: typeof SettingsAppearanceRoute SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsBetaRoute: typeof SettingsBetaRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute @@ -366,6 +386,7 @@ interface SettingsRouteChildren { } const SettingsRouteChildren: SettingsRouteChildren = { + SettingsAppearanceRoute: SettingsAppearanceRoute, SettingsArchivedRoute: SettingsArchivedRoute, SettingsBetaRoute: SettingsBetaRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, diff --git a/apps/web/src/routes/settings.appearance.tsx b/apps/web/src/routes/settings.appearance.tsx new file mode 100644 index 000000000000..60c33fff307c --- /dev/null +++ b/apps/web/src/routes/settings.appearance.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { AppearanceSettingsPanel } from "../components/settings/SettingsPanels"; + +function SettingsAppearanceRoute() { + return ; +} + +export const Route = createFileRoute("/settings/appearance")({ + component: SettingsAppearanceRoute, +}); diff --git a/infra/relay/src/db.ts b/infra/relay/src/db.ts index 194ffb6ea476..efb0df567f8e 100644 --- a/infra/relay/src/db.ts +++ b/infra/relay/src/db.ts @@ -7,6 +7,7 @@ import * as RemovalPolicy from "alchemy/RemovalPolicy"; import type { EffectPgDatabase } from "drizzle-orm/effect-postgres"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { relayDatabaseMode } from "./dbConfig.ts"; @@ -17,6 +18,23 @@ export class RelayDb extends Context.Service< } >()("t3code-relay/db/RelayDb") {} +export class RelayTransactions extends Context.Service< + RelayTransactions, + { + readonly withTransaction: RelayDb["Service"]["$client"]["withTransaction"]; + } +>()("t3code-relay/db/RelayTransactions") { + static readonly layer = Layer.effect( + RelayTransactions, + Effect.gen(function* () { + const db = yield* RelayDb; + return RelayTransactions.of({ + withTransaction: db.$client.withTransaction, + }); + }), + ); +} + export const PlanetscaleDatabase = Effect.gen(function* () { const { stage } = yield* Alchemy.Stack; const schema = yield* Drizzle.Schema("RelaySchema", { diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 6e3388e3977b..7f536bafb375 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -198,7 +198,9 @@ function makeAllocations( recordDns: () => Effect.die("unused"), markReady: () => Effect.die("unused"), claimRelease: () => Effect.die("unused"), + claimDeprovision: () => Effect.die("unused"), remove: () => Effect.die("unused"), + removeClaimed: () => Effect.die("unused"), }; } diff --git a/infra/relay/src/environments/EnvironmentCredentials.test.ts b/infra/relay/src/environments/EnvironmentCredentials.test.ts index 4e12dabe831f..4a2e07c6b6dc 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.test.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.test.ts @@ -1,6 +1,6 @@ import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; -import { PgDialect, QueryBuilder } from "drizzle-orm/pg-core"; +import { PgDialect } from "drizzle-orm/pg-core"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -58,13 +58,17 @@ describe("EnvironmentCredentials", () => { it.effect("does not retain credential tokens when lookup persistence fails", () => { const cause = new Error("database unavailable"); const token = "t3env_sensitive-credential-token"; + const whereConditions: Array = []; const fakeDb = { select: () => ({ from: (table: unknown) => { expect(table).toBe(relayEnvironmentCredentials); return { - where: () => ({ - limit: () => Effect.fail(cause), + where: (condition: unknown) => ({ + limit: () => { + whereConditions.push(condition); + return Effect.fail(cause); + }, }), }; }, @@ -81,6 +85,13 @@ describe("EnvironmentCredentials", () => { }); expect(error.cause).toBe(cause); expect(error).not.toHaveProperty("token"); + expect(whereConditions).toHaveLength(1); + + const query = new PgDialect().sqlToQuery(whereConditions[0] as never); + expect(query.sql).toContain("exists"); + expect(query.sql).toContain('"relay_environment_links"."environment_id"'); + expect(query.sql).toContain('"relay_environment_links"."environment_public_key"'); + expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); }).pipe( Effect.provide( EnvironmentCredentials.layer.pipe( @@ -180,7 +191,6 @@ describe("EnvironmentCredentials", () => { const updateValues: Array> = []; const whereConditions: Array = []; const fakeDb = { - select: (fields: Parameters[0]) => new QueryBuilder().select(fields), update: (table: unknown) => { expect(table).toBe(relayEnvironmentCredentials); return { diff --git a/infra/relay/src/environments/EnvironmentCredentials.ts b/infra/relay/src/environments/EnvironmentCredentials.ts index 39f40d941b8d..373f894ee16f 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.ts @@ -6,7 +6,8 @@ import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { and, eq, isNull, ne, notExists } from "drizzle-orm"; +import { and, eq, exists, isNull, ne, notExists } from "drizzle-orm"; +import { QueryBuilder } from "drizzle-orm/pg-core"; import * as RelayDb from "../db.ts"; import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts"; @@ -196,6 +197,24 @@ const make = Effect.gen(function* () { and( eq(relayEnvironmentCredentials.credentialHash, credentialHash), isNull(relayEnvironmentCredentials.revokedAt), + exists( + new QueryBuilder() + .select({ userId: relayEnvironmentLinks.userId }) + .from(relayEnvironmentLinks) + .where( + and( + eq( + relayEnvironmentLinks.environmentId, + relayEnvironmentCredentials.environmentId, + ), + eq( + relayEnvironmentLinks.environmentPublicKey, + relayEnvironmentCredentials.environmentPublicKey, + ), + isNull(relayEnvironmentLinks.revokedAt), + ), + ), + ), ), ) .limit(1) @@ -238,7 +257,7 @@ const make = Effect.gen(function* () { eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), isNull(relayEnvironmentCredentials.revokedAt), notExists( - db + new QueryBuilder() .select({ userId: relayEnvironmentLinks.userId }) .from(relayEnvironmentLinks) .where( diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index 7488939f0f90..c0811e82d923 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -136,6 +136,7 @@ function testLayer(input?: { revokeForEnvironmentPublicKey: () => Effect.succeed(false), }), Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { + prepareDeprovision: () => Effect.succeed(null), deprovision: input?.deprovision ?? (() => Effect.void), release: () => Effect.succeed(true), provision: () => diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts index 1a3c01d1e13e..ebf51de100c1 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts @@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) => ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db))); describe("ManagedEndpointAllocations", () => { + it.effect("returns a claim generation only when deprovision wins the allocation CAS", () => { + let claimedAt: string | undefined; + const fakeDb = { + update: (table: unknown) => { + expect(table).toBe(relayManagedEndpointAllocations); + return { + set: (values: { readonly updatedAt: string }) => { + claimedAt = values.updatedAt; + return { + where: () => ({ + returning: () => Effect.succeed([{ userId: "user-1" }]), + }), + }; + }, + }; + }, + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; + const generation = yield* allocations.claimDeprovision({ + userId: "user-1", + environmentId: "environment-1", + updatedAt: "captured-generation", + }); + + expect(generation).toBe(claimedAt); + expect(generation).not.toBeNull(); + }).pipe(Effect.provide(layerWithDb(fakeDb))); + }); + + it.effect("does not remove an allocation superseded after a deprovision claim", () => { + const fakeDb = { + delete: (table: unknown) => { + expect(table).toBe(relayManagedEndpointAllocations); + return { + where: () => ({ + returning: () => Effect.succeed([]), + }), + }; + }, + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; + expect( + yield* allocations.removeClaimed({ + userId: "user-1", + environmentId: "environment-1", + updatedAt: "outdated-claim-generation", + }), + ).toBe(false); + }).pipe(Effect.provide(layerWithDb(fakeDb))); + }); + it.effect("retains database failures with allocation operation and identity", () => { const cause = new Error("database unavailable"); const fakeDb = { diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index f0349de99e10..4320eeea3b72 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro "record-dns", "mark-ready", "claim-release", + "claim-deprovision", "remove", + "remove-claimed", ]), stage: Schema.Literals(["database-request", "resolve-reservation"]), userId: Schema.String, @@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey readonly updatedAt: string; } +interface ClaimManagedEndpointDeprovisionInput extends ManagedEndpointAllocationKey { + readonly updatedAt: string; +} + +interface RemoveClaimedManagedEndpointAllocationInput extends ManagedEndpointAllocationKey { + readonly updatedAt: string; +} + export class ManagedEndpointAllocations extends Context.Service< ManagedEndpointAllocations, { @@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service< readonly claimRelease: ( input: ClaimManagedEndpointReleaseInput, ) => Effect.Effect; + /** + * Claims the complete allocation for teardown only if its generation still + * matches the snapshot captured by the unlink operation. + * + * Returns the claim generation used by `removeClaimed`, or null when a + * concurrent provision has already superseded the snapshot. + */ + readonly claimDeprovision: ( + input: ClaimManagedEndpointDeprovisionInput, + ) => Effect.Effect; readonly remove: ( input: ManagedEndpointAllocationKey, ) => Effect.Effect; + readonly removeClaimed: ( + input: RemoveClaimedManagedEndpointAllocationInput, + ) => Effect.Effect; } >()("t3code-relay/environments/ManagedEndpointAllocations") {} @@ -321,6 +344,35 @@ export const make = Effect.gen(function* () { ); return claimed; }), + claimDeprovision: Effect.fn("relay.managed_endpoint_allocations.claim_deprovision")(function* ( + input: ClaimManagedEndpointDeprovisionInput, + ) { + const claimedAt = DateTime.formatIso(yield* DateTime.now); + const claimed = yield* db + .update(relayManagedEndpointAllocations) + .set({ updatedAt: claimedAt }) + .where( + and( + whereAllocation(input), + eq(relayManagedEndpointAllocations.updatedAt, input.updatedAt), + ), + ) + .returning({ userId: relayManagedEndpointAllocations.userId }) + .pipe( + Effect.map((rows) => rows.length > 0), + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "claim-deprovision", + stage: "database-request", + userId: input.userId, + environmentId: input.environmentId, + cause, + }), + ), + ); + return claimed ? claimedAt : null; + }), remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* ( input: ManagedEndpointAllocationKey, ) { @@ -339,6 +391,32 @@ export const make = Effect.gen(function* () { ), ); }), + removeClaimed: Effect.fn("relay.managed_endpoint_allocations.remove_claimed")(function* ( + input: RemoveClaimedManagedEndpointAllocationInput, + ) { + return yield* db + .delete(relayManagedEndpointAllocations) + .where( + and( + whereAllocation(input), + eq(relayManagedEndpointAllocations.updatedAt, input.updatedAt), + ), + ) + .returning({ userId: relayManagedEndpointAllocations.userId }) + .pipe( + Effect.map((rows) => rows.length > 0), + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "remove-claimed", + stage: "database-request", + userId: input.userId, + environmentId: input.environmentId, + cause, + }), + ), + ); + }), }); }); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index a26cd3042feb..4d136658c8fd 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -50,7 +50,9 @@ interface AllocationCall { | "recordDns" | "markReady" | "claimRelease" - | "remove"; + | "claimDeprovision" + | "remove" + | "removeClaimed"; readonly input: unknown; } @@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) { mutate(allocationKey(input), (current) => current); return true; }), + claimDeprovision: (input) => + Effect.sync(() => { + calls.push({ operation: "claimDeprovision", input }); + const allocation = allocations.get(allocationKey(input)); + if (allocation === undefined || allocation.updatedAt !== input.updatedAt) { + return null; + } + mutate(allocationKey(input), (current) => current); + return allocations.get(allocationKey(input))?.updatedAt ?? null; + }), remove: (input) => Effect.sync(() => { calls.push({ operation: "remove", input }); allocations.delete(allocationKey(input)); }), + removeClaimed: (input) => + Effect.sync(() => { + calls.push({ operation: "removeClaimed", input }); + const allocation = allocations.get(allocationKey(input)); + if (allocation === undefined || allocation.updatedAt !== input.updatedAt) { + return false; + } + allocations.delete(allocationKey(input)); + return true; + }), }); } @@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => { "recordDns", "markReady", "get", - "remove", + "claimDeprovision", + "removeClaimed", ]); }).pipe(Effect.provide(layer)); }, ); + it.effect("does not deprovision an allocation superseded by a concurrent relink", () => { + const tunnelCalls: TunnelCall[] = []; + const dnsCalls: DnsCall[] = []; + const allocationCalls: AllocationCall[] = []; + const layer = providerLayer( + makePersistentTunnelClient(tunnelCalls), + makeDnsClient(dnsCalls), + makeAllocations(allocationCalls), + ); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + const request = { + ...key, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + } as const; + yield* provider.provision(request); + const unlinkTarget = yield* provider.prepareDeprovision(key); + expect(unlinkTarget).not.toBeNull(); + if (unlinkTarget === null) { + return; + } + + // A relink refreshes the allocation generation after unlink captured its + // target but before unlink begins external teardown. + yield* provider.provision(request); + const tunnelCallCount = tunnelCalls.length; + const dnsCallCount = dnsCalls.length; + const allocationCallCount = allocationCalls.length; + + yield* provider.deprovision({ ...key, target: unlinkTarget }); + + expect(tunnelCalls).toHaveLength(tunnelCallCount); + expect(dnsCalls).toHaveLength(dnsCallCount); + expect(allocationCalls.slice(allocationCallCount).map((call) => call.operation)).toEqual([ + "claimDeprovision", + ]); + }).pipe(Effect.provide(layer)); + }); + it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname", () => { const tunnelCalls: TunnelCall[] = []; const dnsCalls: DnsCall[] = []; @@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => { "recordDns", "markReady", "get", + "claimDeprovision", "get", - "remove", + "claimDeprovision", + "removeClaimed", ]); }).pipe(Effect.provide(layer)); }); @@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => { }); yield* provider.deprovision(key); - expect(allocationCalls.map((call) => call.operation)).toContain("remove"); + expect(allocationCalls.map((call) => call.operation)).toContain("removeClaimed"); }).pipe(Effect.provide(layer)); }); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 46875b12193a..9a578874844e 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -77,6 +77,7 @@ export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass Effect.Effect; + /** + * Captures the allocation generation owned by an unlink before its link + * revocation commits. Passing this target to `deprovision` prevents a + * concurrent relink from having its newer allocation torn down. + */ + readonly prepareDeprovision: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect< + ManagedEndpointDeprovisionTarget | null, + ManagedEndpointDeprovisioningFailed + >; readonly deprovision: (input: { readonly userId: string; readonly environmentId: string; + readonly target?: ManagedEndpointDeprovisionTarget | null; }) => Effect.Effect; /** * Deletes the provisioned Cloudflare tunnel while keeping the allocation @@ -424,13 +440,9 @@ export const make = Effect.gen(function* () { ); }); - return ManagedEndpointProvider.of({ - deprovision: Effect.fn("relay.managed_endpoint_provider.deprovision")(function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.user_id": input.userId, - "relay.environment_id": input.environmentId, - }); - const allocation = yield* allocations.get(input).pipe( + const prepareDeprovision = Effect.fn("relay.managed_endpoint_provider.prepare_deprovision")( + function* (input: { readonly userId: string; readonly environmentId: string }) { + return yield* allocations.get(input).pipe( Effect.mapError( (cause) => new ManagedEndpointDeprovisioningFailed({ @@ -440,9 +452,42 @@ export const make = Effect.gen(function* () { }), ), ); + }, + ); + + return ManagedEndpointProvider.of({ + prepareDeprovision, + deprovision: Effect.fn("relay.managed_endpoint_provider.deprovision")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.user_id": input.userId, + "relay.environment_id": input.environmentId, + }); + const allocation = + input.target === undefined ? yield* prepareDeprovision(input) : input.target; if (allocation === null) { return; } + const claimedAt = yield* allocations + .claimDeprovision({ + userId: input.userId, + environmentId: input.environmentId, + updatedAt: allocation.updatedAt, + }) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "claim-deprovision", + ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }), + ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }), + cause, + }), + ), + ); + if (claimedAt === null) { + return; + } const dnsRecordId = allocation.dnsRecordId; if (dnsRecordId !== null) { yield* ignoreNotFound(dns.deleteRecord(dnsRecordId)).pipe( @@ -471,18 +516,24 @@ export const make = Effect.gen(function* () { ), ); } - yield* allocations.remove(input).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointDeprovisioningFailed({ - ...input, - stage: "remove-allocation", - ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }), - ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }), - cause, - }), - ), - ); + yield* allocations + .removeClaimed({ + userId: input.userId, + environmentId: input.environmentId, + updatedAt: claimedAt, + }) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "remove-allocation", + ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }), + ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }), + cause, + }), + ), + ); }), release: Effect.fn("relay.managed_endpoint_provider.release")(function* (input) { yield* Effect.annotateCurrentSpan({ diff --git a/infra/relay/src/environments/ManagedTunnelLimits.test.ts b/infra/relay/src/environments/ManagedTunnelLimits.test.ts index 45d4db8fefe0..b53a487ee75a 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.test.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.test.ts @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; import * as RelayDb from "../db.ts"; import { + relayEnvironmentLinks, relayManagedEndpointAllocations, relayManagedTunnelLimits, } from "../persistence/schema.ts"; @@ -28,7 +29,16 @@ function makeFakeDb(input: { } expect(table).toBe(relayManagedEndpointAllocations); return { - where: () => input.countRows ?? Effect.succeed([{ activeTunnels: 0 }]), + innerJoin: (table: unknown, condition: unknown) => { + expect(table).toBe(relayEnvironmentLinks); + expect(condition).toBeDefined(); + return { + where: (where: unknown) => { + expect(where).toBeDefined(); + return input.countRows ?? Effect.succeed([{ activeTunnels: 0 }]); + }, + }; + }, }; }, }), diff --git a/infra/relay/src/environments/ManagedTunnelLimits.ts b/infra/relay/src/environments/ManagedTunnelLimits.ts index bab4d882e5d5..9d8b9c796ed6 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.ts @@ -1,4 +1,4 @@ -import { and, count, eq, ne } from "drizzle-orm"; +import { and, count, eq, isNull, ne } from "drizzle-orm"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,6 +6,7 @@ import * as Schema from "effect/Schema"; import * as RelayDb from "../db.ts"; import { + relayEnvironmentLinks, relayManagedEndpointAllocations, relayManagedTunnelLimits, } from "../persistence/schema.ts"; @@ -81,10 +82,19 @@ export const make = Effect.gen(function* () { const counted = yield* db .select({ activeTunnels: count() }) .from(relayManagedEndpointAllocations) + .innerJoin( + relayEnvironmentLinks, + and( + eq(relayEnvironmentLinks.userId, relayManagedEndpointAllocations.userId), + eq(relayEnvironmentLinks.environmentId, relayManagedEndpointAllocations.environmentId), + ), + ) .where( and( eq(relayManagedEndpointAllocations.userId, input.userId), ne(relayManagedEndpointAllocations.environmentId, input.environmentId), + isNull(relayEnvironmentLinks.revokedAt), + eq(relayEnvironmentLinks.managedTunnelsEnabled, true), ), ) .pipe( diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index b43a28375900..daf756a2b7cc 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -14,6 +14,7 @@ import * as Tracer from "effect/Tracer"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { EnvironmentId } from "@t3tools/contracts"; import { RelayEnvironmentAuth } from "@t3tools/contracts/relay"; import { @@ -22,12 +23,17 @@ import { relayDocsRedirectRoute, relayEnvironmentAuthLayer, relayNotFoundRoute, + revokeEnvironmentLinkRecord, traceRelayHttpRequestWith, + unlinkEnvironmentRecord, verifyRelayClientBearerToken, withoutCapturedParentSpan, } from "./Api.ts"; import * as RelayConfiguration from "../Config.ts"; +import * as RelayDb from "../db.ts"; import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts"; +import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; +import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; vi.mock("@clerk/backend", () => ({ createClerkClient: vi.fn(), @@ -155,6 +161,240 @@ describe("relay environment authentication", () => { }); }); +function relayUnlinkTestLayer(input?: { + readonly withTransaction?: RelayDb.RelayTransactions["Service"]["withTransaction"]; + readonly getForUser?: EnvironmentLinks.EnvironmentLinks["Service"]["getForUser"]; + readonly revokeForUser?: EnvironmentLinks.EnvironmentLinks["Service"]["revokeForUser"]; + readonly revokeCredential?: EnvironmentCredentials.EnvironmentCredentials["Service"]["revokeForEnvironmentPublicKey"]; + readonly prepareDeprovision?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["prepareDeprovision"]; + readonly deprovision?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["deprovision"]; +}) { + return Layer.mergeAll( + Layer.succeed( + RelayDb.RelayTransactions, + RelayDb.RelayTransactions.of({ + withTransaction: input?.withTransaction ?? ((effect) => effect), + }), + ), + Layer.succeed( + EnvironmentLinks.EnvironmentLinks, + EnvironmentLinks.EnvironmentLinks.of({ + upsert: () => Effect.die("unused upsert"), + listUsersForEnvironment: () => Effect.die("unused listUsersForEnvironment"), + listDeliveryUsersForEnvironment: () => Effect.die("unused listDeliveryUsersForEnvironment"), + listPublicKeysForEnvironment: () => Effect.die("unused listPublicKeysForEnvironment"), + listForUser: () => Effect.die("unused listForUser"), + getForUser: input?.getForUser ?? (() => Effect.succeed(null)), + revokeForUser: input?.revokeForUser ?? (() => Effect.succeed(false)), + }), + ), + Layer.succeed( + EnvironmentCredentials.EnvironmentCredentials, + EnvironmentCredentials.EnvironmentCredentials.of({ + create: () => Effect.die("unused create"), + authenticate: () => Effect.die("unused authenticate"), + revokeForEnvironmentPublicKey: input?.revokeCredential ?? (() => Effect.succeed(false)), + }), + ), + Layer.succeed( + ManagedEndpointProvider.ManagedEndpointProvider, + ManagedEndpointProvider.ManagedEndpointProvider.of({ + provision: () => Effect.die("unused provision"), + prepareDeprovision: input?.prepareDeprovision ?? (() => Effect.succeed(null)), + deprovision: input?.deprovision ?? (() => Effect.void), + release: () => Effect.die("unused release"), + }), + ), + ); +} + +const linkedEnvironmentRecord = { + environmentId: EnvironmentId.make("environment-1"), + label: "Environment 1", + endpoint: { + httpBaseUrl: "https://environment-1.example.test/", + wsBaseUrl: "wss://environment-1.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + environmentPublicKey: "public-key", + linkedAt: "2026-07-28T00:00:00.000Z", +} as const; + +describe("relay environment unlink", () => { + it.effect("revokes the link and its credentials in one database transaction", () => { + const calls: Array = []; + return Effect.gen(function* () { + expect( + yield* revokeEnvironmentLinkRecord({ + userId: "user-1", + environmentId: "environment-1", + environmentPublicKey: "public-key", + }), + ).toBe(true); + expect(calls).toEqual(["transaction", "link", "credential"]); + }).pipe( + Effect.provide( + relayUnlinkTestLayer({ + withTransaction: (effect) => { + calls.push("transaction"); + return effect; + }, + revokeForUser: () => + Effect.sync(() => { + calls.push("link"); + return true; + }), + revokeCredential: () => + Effect.sync(() => { + calls.push("credential"); + return true; + }), + }), + ), + ); + }); + + it.effect("commits database revocation before deprovisioning the managed endpoint", () => { + const calls: Array = []; + const deprovisionTarget = { + userId: "user-1", + environmentId: "environment-1", + hostname: "environment-1.example.test", + tunnelId: "tunnel-1", + tunnelName: "environment-1-tunnel", + dnsRecordId: "dns-1", + readyAt: "2026-07-28T00:00:00.000Z", + updatedAt: "generation-before-unlink", + } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget; + + return Effect.gen(function* () { + expect( + yield* unlinkEnvironmentRecord({ + userId: "user-1", + environmentId: "environment-1", + }), + ).toBe(true); + expect(calls).toEqual([ + "prepare", + "lookup", + "transaction", + "link", + "credential", + "deprovision", + ]); + }).pipe( + Effect.provide( + relayUnlinkTestLayer({ + withTransaction: (effect) => { + calls.push("transaction"); + return effect; + }, + getForUser: () => + Effect.sync(() => { + calls.push("lookup"); + return linkedEnvironmentRecord; + }), + revokeForUser: () => + Effect.sync(() => { + calls.push("link"); + return true; + }), + revokeCredential: () => + Effect.sync(() => { + calls.push("credential"); + return true; + }), + prepareDeprovision: () => + Effect.sync(() => { + calls.push("prepare"); + return deprovisionTarget; + }), + deprovision: (request) => + Effect.sync(() => { + expect(request.target).toBe(deprovisionTarget); + calls.push("deprovision"); + }), + }), + ), + ); + }); + + it.effect("does not deprovision when database revocation fails", () => { + const calls: Array = []; + const failure = new EnvironmentCredentials.EnvironmentCredentialRevokePersistenceError({ + environmentId: "environment-1", + cause: "database unavailable", + }); + + return Effect.gen(function* () { + expect( + yield* Effect.flip( + unlinkEnvironmentRecord({ + userId: "user-1", + environmentId: "environment-1", + }), + ), + ).toBe(failure); + expect(calls).toEqual(["prepare", "transaction", "link", "credential"]); + }).pipe( + Effect.provide( + relayUnlinkTestLayer({ + withTransaction: (effect) => { + calls.push("transaction"); + return effect; + }, + getForUser: () => Effect.succeed(linkedEnvironmentRecord), + revokeForUser: () => + Effect.sync(() => { + calls.push("link"); + return true; + }), + revokeCredential: () => + Effect.sync(() => { + calls.push("credential"); + }).pipe(Effect.andThen(Effect.fail(failure))), + prepareDeprovision: () => + Effect.sync(() => { + calls.push("prepare"); + return null; + }), + deprovision: () => + Effect.sync(() => { + calls.push("deprovision"); + }), + }), + ), + ); + }); + + it.effect("retries deprovisioning after the link is already revoked", () => { + const calls: Array = []; + return Effect.gen(function* () { + expect( + yield* unlinkEnvironmentRecord({ + userId: "user-1", + environmentId: "environment-1", + }), + ).toBe(false); + expect(calls).toEqual(["prepare", "deprovision"]); + }).pipe( + Effect.provide( + relayUnlinkTestLayer({ + prepareDeprovision: () => + Effect.sync(() => { + calls.push("prepare"); + return null; + }), + deprovision: () => + Effect.sync(() => { + calls.push("deprovision"); + }), + }), + ), + ); + }); +}); + describe("relay request tracing", () => { it.effect( "does not parent endpoint spans to an ambient parent captured while building handlers", diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 0d5813ab71ca..7510b4a8d418 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -403,6 +403,67 @@ export const healthApi = HttpApiBuilder.group( }), ); +export const revokeEnvironmentLinkRecord = Effect.fn( + "relay.api.client.revokeEnvironmentLinkRecord", +)(function* (input: { + readonly userId: string; + readonly environmentId: string; + readonly environmentPublicKey: string; +}) { + const transactions = yield* RelayDb.RelayTransactions; + const links = yield* EnvironmentLinks.EnvironmentLinks; + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + return yield* transactions.withTransaction( + Effect.gen(function* () { + const revoked = yield* links.revokeForUser({ + userId: input.userId, + environmentId: input.environmentId, + }); + if (revoked) { + yield* credentials.revokeForEnvironmentPublicKey({ + environmentId: input.environmentId, + environmentPublicKey: input.environmentPublicKey, + }); + } + return revoked; + }), + ); +}); + +export const unlinkEnvironmentRecord = Effect.fn("relay.api.client.unlinkEnvironmentRecord")( + function* (input: { readonly userId: string; readonly environmentId: string }) { + const links = yield* EnvironmentLinks.EnvironmentLinks; + const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const deprovisionTarget = yield* managedEndpointProvider.prepareDeprovision({ + userId: input.userId, + environmentId: input.environmentId, + }); + const link = yield* links.getForUser({ + userId: input.userId, + environmentId: input.environmentId, + }); + const unlinked = + link === null + ? false + : yield* revokeEnvironmentLinkRecord({ + userId: input.userId, + environmentId: link.environmentId, + environmentPublicKey: link.environmentPublicKey, + }); + + // External teardown cannot share the SQL transaction. Run it only after + // revocation commits so a database failure leaves a fully usable active + // link. Still run teardown when the link is already revoked, allowing a + // retry to finish cleanup after an earlier Cloudflare failure. + yield* managedEndpointProvider.deprovision({ + userId: input.userId, + environmentId: input.environmentId, + target: deprovisionTarget, + }); + return unlinked; + }, +); + export const mobileApi = HttpApiBuilder.group( RelayApi, "mobile", @@ -470,7 +531,6 @@ export const clientApi = HttpApiBuilder.group( const linker = yield* EnvironmentLinker.EnvironmentLinker; const links = yield* EnvironmentLinks.EnvironmentLinks; const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider; - const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; const devices = yield* Devices.Devices; return handlers .handle( @@ -592,29 +652,16 @@ export const clientApi = HttpApiBuilder.group( Effect.fn("relay.api.client.unlinkEnvironment")(function* (args) { const { params } = args; const { userId } = yield* RelayClientPrincipal; - yield* managedEndpointProvider - .deprovision({ - userId, - environmentId: params.environmentId, - }) - .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))); - const link = yield* links.getForUser({ - userId, - environmentId: params.environmentId, - }); - if (link === null) { - return { ok: false }; - } - const unlinked = yield* links.revokeForUser({ + const unlinked = yield* unlinkEnvironmentRecord({ userId, environmentId: params.environmentId, - }); - if (unlinked) { - yield* credentials.revokeForEnvironmentPublicKey({ - environmentId: link.environmentId, - environmentPublicKey: link.environmentPublicKey, - }); - } + }).pipe( + Effect.catchTags({ + SqlError: () => relayInternalErrorResponse("internal_error"), + ManagedEndpointDeprovisioningFailed: () => + relayInternalErrorResponse("upstream_unavailable"), + }), + ); return { ok: unlinked }; }, mapRelayCommonApiErrors("not_authorized")), ) diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index d0fabe8dee77..bcc53f3ff05c 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -217,7 +217,11 @@ export const ApiLive = Api.make( Layer.provideMerge(LiveActivities.layer), Layer.provideMerge(DeliveryAttempts.layer), Layer.provideMerge(RelayTokens.layer), - Layer.provideMerge(Layer.succeed(RelayDb.RelayDb, db)), + Layer.provideMerge( + RelayDb.RelayTransactions.layer.pipe( + Layer.provideMerge(Layer.succeed(RelayDb.RelayDb, db)), + ), + ), Layer.provideMerge(Layer.effect(RelayConfiguration.RelayConfiguration, loadSettings)), Layer.provideMerge(webcryptoLayer), ); diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index 421d95c6b135..99d906c00fa3 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -163,32 +163,11 @@ describe("effectiveSettled", () => { ).toBe(true); }); - it("does not re-settle a warm thread on the merge signal: a message sent in a settled thread keeps it active until idle", () => { - // The merge signal never clears, so without the idle guard a follow-up - // message would un-settle the row only until its turn completed, then - // snap straight back into the settled tail. - const justActive = makeShell({ activityAt: "2026-04-09T23:30:00.000Z" }); - // The idle gate is strict: activity exactly one hour old is still warm. - const boundary = makeShell({ activityAt: "2026-04-09T23:00:00.000Z" }); - const idle = makeShell({ activityAt: "2026-04-09T22:59:59.999Z" }); - + it("settles immediately when a change request merges or closes", () => { + const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); for (const changeRequestState of ["merged", "closed"] as const) { expect( - effectiveSettled(justActive, { - now: NOW, - autoSettleAfterDays: null, - changeRequestState, - }), - ).toBe(false); - expect( - effectiveSettled(boundary, { - now: NOW, - autoSettleAfterDays: null, - changeRequestState, - }), - ).toBe(false); - expect( - effectiveSettled(idle, { + effectiveSettled(recentlyActive, { now: NOW, autoSettleAfterDays: null, changeRequestState, @@ -197,14 +176,18 @@ describe("effectiveSettled", () => { } }); - it("re-settles a merged-PR thread once the follow-up burst goes idle", () => { - // Same shell, advancing clock: active while warm, settled again after - // the idle window passes — the burst cools and the merge signal wins. - const shell = makeShell({ activityAt: "2026-04-09T23:30:00.000Z" }); - const options = { autoSettleAfterDays: null, changeRequestState: "merged" as const }; - - expect(effectiveSettled(shell, { ...options, now: NOW })).toBe(false); - expect(effectiveSettled(shell, { ...options, now: "2026-04-10T00:30:00.001Z" })).toBe(true); + it("keeps an explicitly un-settled merged-PR thread active", () => { + const shell = makeShell({ + settledOverride: "active", + activityAt: "2026-04-09T23:59:59.999Z", + }); + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState: "merged", + }), + ).toBe(false); }); it("never settles a starting session, even with a settled override", () => { diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 0d077c892bb7..3a98b9d2f270 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -214,20 +214,6 @@ export function threadWokeAt( return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null; } -/** - * A merged/closed change request settles its thread only once the thread has - * been idle this long. Without the idle guard the merge signal is permanent: - * sending a message to a merged-PR thread would un-settle the row only until - * its turn completed, then the still-merged PR would snap it straight back - * into the settled tail. An hour keeps the follow-up conversation visible - * while it is warm; once the burst goes stale the merge signal settles it - * again. Activity timestamps can originate on another device while `now` is - * this caller's clock: skew shortens or stretches the window by its size, - * the same exposure the inactivity auto-settle already accepts — worst case - * is a row changing lists early or late, never lost work. - */ -export const CHANGE_REQUEST_SETTLE_IDLE_MS = 60 * 60 * 1_000; - /** * Settled resolution over the server-backed settled lifecycle. Activity * blockers (pending approval/user-input, a live session, an unadjudicated @@ -235,7 +221,7 @@ export const CHANGE_REQUEST_SETTLE_IDLE_MS = 60 * 60 * 1_000; * override. Past the blockers, the explicit user override (thread.settle / * thread.unsettle commands, projected into settledOverride + settledAt) * wins in both directions; without one, a thread auto-settles on a - * merged/closed PR (once idle) or inactivity past the window. The server + * merged/closed PR immediately or on inactivity past the window. The server * un-settles on real activity (user message, session start, approval/ * user-input request), so an override never goes stale silently. */ @@ -271,16 +257,7 @@ export function effectiveSettled( // until real activity clears it server-side. if (shell.settledOverride === "active") return false; if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { - // Only an idle thread settles on the merge signal: the signal itself - // never clears, so without this guard fresh activity (a message sent in - // a settled thread) would re-settle the moment its turn completed. - const lastActivityAt = threadLastActivityAt(shell); - if ( - lastActivityAt === null || - Date.parse(lastActivityAt) < Date.parse(options.now) - CHANGE_REQUEST_SETTLE_IDLE_MS - ) { - return true; - } + return true; } if (options.autoSettleAfterDays === null) return false; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ed864344e35e..2bc61d72f219 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -49,6 +49,24 @@ describe("ClientSettings glass opacity", () => { }); }); +describe("ClientSettings environment identification", () => { + it("defaults to artwork and accepts each presentation mode", () => { + expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork"); + + for (const mode of ["artwork", "pill", "none"] as const) { + expect( + decodeClientSettingsPatch({ environmentIdentificationMode: mode }) + .environmentIdentificationMode, + ).toBe(mode); + } + }); + + it("rejects unsupported presentation modes", () => { + expect(() => decodeClientSettings({ environmentIdentificationMode: "badge" })).toThrow(); + expect(() => decodeClientSettingsPatch({ environmentIdentificationMode: "badge" })).toThrow(); + }); +}); + describe("ClientSettings sidebar v2", () => { it("defaults the beta off with a three-day auto-settle threshold", () => { const settings = decodeClientSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 8266ad3337f8..b97d46812702 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -58,6 +58,9 @@ export const GlassOpacity = Schema.Int.check( ); export type GlassOpacity = typeof GlassOpacity.Type; export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; +export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]); +export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; +export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -67,6 +70,9 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + environmentIdentificationMode: EnvironmentIdentificationMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)), + ), glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), @@ -611,6 +617,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), + environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), favorites: Schema.optionalKey( Schema.Array( diff --git a/patches/@effect__platform-bun@4.0.0-beta.102.patch b/patches/@effect__platform-bun@4.0.0-beta.102.patch new file mode 100644 index 000000000000..8b8687295df7 --- /dev/null +++ b/patches/@effect__platform-bun@4.0.0-beta.102.patch @@ -0,0 +1,50 @@ +diff --git a/dist/BunHttpServer.js b/dist/BunHttpServer.js +index 5fb0cbacc183b58e31a442badf4aef06bebcd592..e31fb00c430821fc06247079091a93fb15a1ab92 100644 +--- a/dist/BunHttpServer.js ++++ b/dist/BunHttpServer.js +@@ -45,6 +45,20 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* (options) { + ...options, + fetch: handlerStack[0], + websocket: { ++ // Patch: upstreamed as https://github.com/Effect-TS/effect/pull/6691 (ships ++ // in >= 4.0.0-beta.103 as the layer option `websocket: { ... }`). On ++ // upgrade: drop this patch and pass the option in apps/server/src/server.ts. ++ // Negotiate permessage-deflate with clients that offer it; clients that ++ // don't still get uncompressed frames on their connection. A dedicated ++ // compressor keeps a per-connection sliding window (context takeover) so ++ // the compression dictionary is shared across server-to-client frames. ++ // Decompression uses the shared decompressor: uWebSockets' dedicated ++ // decompressor path can abort connections (close 1006) on valid DEFLATE ++ // input — see https://github.com/uNetworking/uWebSockets.js/issues/633. ++ perMessageDeflate: { ++ compress: "dedicated", ++ decompress: "shared" ++ }, + open(ws) { + Deferred.doneUnsafe(ws.data.deferred, Exit.succeed(ws)); + }, +diff --git a/src/BunHttpServer.ts b/src/BunHttpServer.ts +index 9f2ac3dfc018e2d35c6f44785f1ce05e2e06fc7c..f2bd3def712677ae1e7d475f5eaf27ac2e42ae1a 100644 +--- a/src/BunHttpServer.ts ++++ b/src/BunHttpServer.ts +@@ -90,6 +90,20 @@ export const make = Effect.fnUntraced( + ...options as ServeOptions, + fetch: handlerStack[0], + websocket: { ++ // Patch: upstreamed as https://github.com/Effect-TS/effect/pull/6691 (ships ++ // in >= 4.0.0-beta.103 as the layer option `websocket: { ... }`). On ++ // upgrade: drop this patch and pass the option in apps/server/src/server.ts. ++ // Negotiate permessage-deflate with clients that offer it; clients that ++ // don't still get uncompressed frames on their connection. A dedicated ++ // compressor keeps a per-connection sliding window (context takeover) so ++ // the compression dictionary is shared across server-to-client frames. ++ // Decompression uses the shared decompressor: uWebSockets' dedicated ++ // decompressor path can abort connections (close 1006) on valid DEFLATE ++ // input — see https://github.com/uNetworking/uWebSockets.js/issues/633. ++ perMessageDeflate: { ++ compress: "dedicated", ++ decompress: "shared" ++ }, + open(ws) { + Deferred.doneUnsafe(ws.data.deferred, Exit.succeed(ws)) + }, diff --git a/patches/@effect__platform-node@4.0.0-beta.102.patch b/patches/@effect__platform-node@4.0.0-beta.102.patch new file mode 100644 index 000000000000..4a11632273bf --- /dev/null +++ b/patches/@effect__platform-node@4.0.0-beta.102.patch @@ -0,0 +1,50 @@ +diff --git a/dist/NodeHttpServer.js b/dist/NodeHttpServer.js +index ab7c9354853032fefa192ebfa8ab2026bac6ddb7..319815f01e78f5d4e6f7e62a00046ec092eaf797 100644 +--- a/dist/NodeHttpServer.js ++++ b/dist/NodeHttpServer.js +@@ -83,7 +83,17 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* (evaluate, options) + }); + const address = server.address(); + const wss = yield* Effect.acquireRelease(Effect.sync(() => new NodeWS.WebSocketServer({ +- noServer: true ++ noServer: true, ++ // Patch: upstreamed as https://github.com/Effect-TS/effect/pull/6691 (ships ++ // in >= 4.0.0-beta.103 as the layer option `websocket: { perMessageDeflate }`). ++ // On upgrade: drop this patch and pass the option in apps/server/src/server.ts. ++ // Negotiate permessage-deflate with clients that offer it; clients that ++ // don't still get uncompressed frames on their connection. Context ++ // takeover stays enabled (ws default) so the compression window is shared ++ // across frames — that also makes small frames cheap to compress, so no ++ // size threshold is set (ws only honors `threshold` when context takeover ++ // is disabled). ++ perMessageDeflate: true + })), wss => Effect.callback(resume => { + wss.close(() => resume(Effect.void)); + })).pipe(Scope.provide(scope), Effect.cached); +diff --git a/src/NodeHttpServer.ts b/src/NodeHttpServer.ts +index e18cd931f17c26d6ca985a2d1ece9e9d760851d3..9c888a581738aea12dbdb2b213cce1c480aa2ecc 100644 +--- a/src/NodeHttpServer.ts ++++ b/src/NodeHttpServer.ts +@@ -116,7 +116,21 @@ export const make = Effect.fnUntraced(function*( + const address = server.address()! + + const wss = yield* Effect.acquireRelease( +- Effect.sync(() => new NodeWS.WebSocketServer({ noServer: true })), ++ Effect.sync(() => ++ new NodeWS.WebSocketServer({ ++ noServer: true, ++ // Patch: upstreamed as https://github.com/Effect-TS/effect/pull/6691 (ships ++ // in >= 4.0.0-beta.103 as the layer option `websocket: { perMessageDeflate }`). ++ // On upgrade: drop this patch and pass the option in apps/server/src/server.ts. ++ // Negotiate permessage-deflate with clients that offer it; clients that ++ // don't still get uncompressed frames on their connection. Context ++ // takeover stays enabled (ws default) so the compression window is ++ // shared across frames — that also makes small frames cheap to ++ // compress, so no size threshold is set (ws only honors `threshold` ++ // when context takeover is disabled). ++ perMessageDeflate: true ++ }) ++ ), + (wss) => + Effect.callback((resume) => { + wss.close(() => resume(Effect.void)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37596864bdfd..4981a25d3d00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,8 @@ overrides: packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= patchedDependencies: + '@effect/platform-bun@4.0.0-beta.102': 9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23 + '@effect/platform-node@4.0.0-beta.102': cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0 '@effect/vitest@4.0.0-beta.102': a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425 '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 @@ -116,7 +118,7 @@ importers: version: 0.0.3 '@effect/platform-node': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -446,10 +448,10 @@ importers: version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/platform-node-shared': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) @@ -619,7 +621,7 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(patch_hash=a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) @@ -688,7 +690,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.65 - version: 2.0.0-beta.65(72bf61d418b0d4ce14e14ce6833d7182) + version: 2.0.0-beta.65(249551d75ad3792c0b22cd2754b25266) drizzle-orm: specifier: 1.0.0-rc.4 version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-pg@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-sqlite-bun@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) @@ -701,7 +703,7 @@ importers: version: 4.20260604.1 '@effect/platform-node': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(patch_hash=a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) @@ -722,7 +724,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@oxlint/plugins': specifier: ^1.63.0 version: 1.68.0 @@ -777,10 +779,10 @@ importers: devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.102(@effect/platform-node@4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) + version: 4.0.0-beta.102(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@effect/platform-node': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(patch_hash=a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) @@ -799,10 +801,10 @@ importers: devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.102(@effect/platform-node@4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) + version: 4.0.0-beta.102(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@effect/platform-node': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(patch_hash=a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) @@ -836,7 +838,7 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(patch_hash=a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) @@ -861,7 +863,7 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(patch_hash=a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) @@ -876,7 +878,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/shared': specifier: workspace:* version: link:../shared @@ -898,7 +900,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -11681,26 +11683,26 @@ snapshots: transitivePeerDependencies: - workerd - '@distilled.cloud/cloudflare-runtime@0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))': + '@distilled.cloud/cloudflare-runtime@0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))': dependencies: '@alchemy.run/node-utils': 0.0.5 '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) workerd: 1.20260704.1 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@distilled.cloud/cloudflare-vite-plugin@0.13.10(762dc011829f23250083219601626dca)': + '@distilled.cloud/cloudflare-vite-plugin@0.13.10(554b80dcfb9268a9eeb72fedc0ae62a5)': dependencies: '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) + '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - rolldown - workerd @@ -11770,15 +11772,15 @@ snapshots: react: 19.2.6 scheduler: 0.27.0 - '@effect/openapi-generator@4.0.0-beta.102(@effect/platform-node@4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))': + '@effect/openapi-generator@4.0.0-beta.102(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))': dependencies: - '@effect/platform-node': 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) swagger2openapi: 7.0.8 transitivePeerDependencies: - encoding - '@effect/platform-bun@4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6)': + '@effect/platform-bun@4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6)': dependencies: '@effect/platform-node-shared': 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) @@ -11795,7 +11797,7 @@ snapshots: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6)': + '@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6)': dependencies: '@effect/platform-node-shared': 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) @@ -15369,7 +15371,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.65(72bf61d418b0d4ce14e14ce6833d7182): + alchemy@2.0.0-beta.65(249551d75ad3792c0b22cd2754b25266): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1062.0 @@ -15378,8 +15380,8 @@ snapshots: '@distilled.cloud/axiom': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) - '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(762dc011829f23250083219601626dca) + '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) + '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(554b80dcfb9268a9eeb72fedc0ae62a5) '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/neon': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/planetscale': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) @@ -15410,8 +15412,8 @@ snapshots: undici: 7.27.1 yaml: 2.9.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/sql-pg': 4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) drizzle-kit: 1.0.0-rc.4 drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-pg@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-sqlite-bun@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 48e088963563..42529f56ee3c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -122,6 +122,8 @@ packageExtensions: vite: "catalog:" patchedDependencies: + "@effect/platform-bun@4.0.0-beta.102": patches/@effect__platform-bun@4.0.0-beta.102.patch + "@effect/platform-node@4.0.0-beta.102": patches/@effect__platform-node@4.0.0-beta.102.patch "@effect/vitest@4.0.0-beta.102": patches/@effect__vitest@4.0.0-beta.102.patch "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index c42239e427f4..9c04c7e9dd19 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -201,16 +201,42 @@ export const SHOWCASE_THREADS = [ response: "The plan groups milestones without changing the underlying log stream, preserves plain-text output, and adds zero work to the hot path.", }, + // Finished work, settled by hand: the list keeps it as a receded tail so + // the active block above reads as everything still in flight. The active + // block stays small enough that the settled tail begins above the fold — + // a store screenshot has to show that history exists, not just imply it. { - id: "scheduler-breathe", + id: "handoff-haptics", + projectId: "t3code", + title: "Tune the handoff haptics", + branch: "feat/handoff-haptics", + minutesAgo: 5 * 60, + settled: true, + request: "Give the desktop-to-phone handoff a haptic that lands with the animation.", + response: + "The handoff now taps once as the thread lands and stays silent on failure, so the phone never celebrates a handoff that did not happen.", + }, + { + id: "streaming-shell", + projectId: "react", + title: "Stream the shell before the data", + branch: "feat/streaming-shell", + minutesAgo: 28 * 60, + settled: true, + request: "Get the app shell painted before any data request resolves.", + response: + "The shell now flushes on first byte and the data boundaries hydrate underneath it, so the first paint no longer waits on the slowest query.", + }, + { + id: "quieter-oom", projectId: "linux", - title: "Let the scheduler breathe", - branch: "perf/scheduler-breathe", - minutesAgo: 76, - request: - "Find a calmer balancing strategy for bursty mixed workloads without hurting tail latency.", + title: "Make the OOM killer explain itself", + branch: "feat/quieter-oom", + minutesAgo: 2 * 24 * 60, + settled: true, + request: "Make out-of-memory kills legible without adding a single allocation to the hot path.", response: - "The new heuristic reduces needless migrations during short bursts while preserving the existing latency guardrails.", + "Kills now report the winning heuristic and the runner-up alongside the usual dump, assembled entirely from data the path already had.", }, ] as const; @@ -300,6 +326,7 @@ function insertThread( readonly branch: string; readonly minutesAgo: number; readonly state?: "working" | "approval" | "plan"; + readonly settled?: boolean; readonly workspaceRoot: string; }, ): void { @@ -312,8 +339,8 @@ function insertThread( thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id, latest_user_message_at, pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, created_at, updated_at, - archived_at, deleted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL)`, + archived_at, deleted_at, settled_override, settled_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL, ?, ?)`, ) .run( input.id, @@ -330,6 +357,8 @@ function insertThread( input.state === "plan" ? 1 : 0, minutesBefore(now, input.minutesAgo + 120), updatedAt, + input.settled ? "settled" : null, + input.settled ? updatedAt : null, ); database .prepare( @@ -365,7 +394,11 @@ function seedDatabase( threads: ReadonlyArray<(typeof SHOWCASE_THREADS)[number]>, now: number, ): void { - const database = new NodeSqlite.DatabaseSync(dbPath); + // The environment server is already running against this file and keeps + // writing (migrations, projections) while we seed, so the write lock is + // genuinely contended — without a busy timeout `BEGIN IMMEDIATE` fails + // instantly with SQLITE_BUSY on a loaded machine. + const database = new NodeSqlite.DatabaseSync(dbPath, { timeout: 30_000 }); try { database.exec("BEGIN IMMEDIATE"); for (const table of [ @@ -506,7 +539,14 @@ function seedDatabase( } database.exec("COMMIT"); } catch (error) { - database.exec("ROLLBACK"); + // A failed BEGIN (or an error SQLite already auto-rolled back) leaves no + // transaction, and the rollback's own "cannot rollback" error would then + // replace the one that actually explains the failure. + try { + database.exec("ROLLBACK"); + } catch { + // Nothing to roll back. + } throw error; } finally { database.close(); diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index 1eaefe5ad863..242c9ebdb679 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -272,8 +272,23 @@ it("seeds a playful multi-environment project spectrum", () => { SHOWCASE_ENVIRONMENTS.map((environment) => environment.label), ["Moonbase Terminal", "Suspense Station", "Kernel Cabin"], ); - assert.equal(SHOWCASE_THREADS.length, 6); + assert.equal(SHOWCASE_THREADS.length, 8); assert.equal(new Set(SHOWCASE_THREADS.map((thread) => thread.projectId)).size, 3); + // Every project contributes to both the active block and the settled tail, + // so each list scope screenshots with the same two-part structure. + for (const project of SHOWCASE_PROJECTS) { + const projectThreads = SHOWCASE_THREADS.filter((thread) => thread.projectId === project.id); + assert.equal( + projectThreads.some((thread) => "settled" in thread && thread.settled), + true, + `${project.title} has no settled thread`, + ); + assert.equal( + projectThreads.some((thread) => !("settled" in thread && thread.settled)), + true, + `${project.title} has no active thread`, + ); + } assert.equal( SHOWCASE_PROJECTS.every((project) => project.favicon.includes(" { if (import.meta.main) { void main().catch((error: unknown) => { - NodeProcess.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + // Stack over message: the harness only fails in CI, where the line that + // threw is the whole diagnosis and there is nobody at a terminal to + // re-run it with more output. + NodeProcess.stderr.write( + `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); NodeProcess.exit(1); }); }