diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index fd86c5f05d2..6b5c8321085 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -175,12 +175,14 @@ const bootstrap = Effect.gen(function* () { const rendererTarget = environment.isDevelopment ? Option.getOrThrow(environment.devServerUrl) : backendConfig.httpBaseUrl; - yield* electronProtocol.registerDesktopProtocol({ - scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), - targetOrigin: rendererTarget, - backendOrigin: backendConfig.httpBaseUrl, - clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, - }); + if (!environment.isDevelopment || DesktopClerk.isDesktopClerkBridgeEnabled()) { + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), + targetOrigin: rendererTarget, + backendOrigin: backendConfig.httpBaseUrl, + clerkFrontendApiHostname: DesktopClerk.getDesktopClerkFrontendApiHostname(), + }); + } yield* logBootstrapInfo("bootstrap resolved backend endpoint", { baseUrl: backendConfig.httpBaseUrl.href, }); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 9b5ed56d1f3..6e8b7355d64 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -31,7 +31,7 @@ const makeDesktopClerkLayer = (isDevelopment = true) => { isDevelopment, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); - return DesktopClerk.layer.pipe( + return DesktopClerk.makeDesktopClerkLayer(true).pipe( Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), ); }; @@ -76,6 +76,27 @@ describe("DesktopClerk", () => { }); }); + it.effect("skips the SDK bridge when the build has no Clerk publishable key", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() }); + + return Effect.gen(function* () { + yield* Effect.scoped( + Layer.build(DesktopClerk.makeDesktopClerkLayer(false)).pipe( + Effect.provide( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + stateDir: "/tmp/t3-state", + isDevelopment: true, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]), + ), + ), + ); + + assert.deepEqual(storageMock.mock.calls, []); + assert.deepEqual(createClerkBridgeMock.mock.calls, []); + }); + }); + it.effect("preserves bridge initialization failures", () => { const cause = new Error("bridge initialization failed"); storageMock.mockReturnValue(storageAdapter); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 0e283f8dd0c..8974fa9619b 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -7,13 +7,21 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { + getDesktopClerkFrontendApiHostname, + isDesktopClerkBridgeEnabled, + resolveDesktopClerkFrontendApiHostname, +} from "./DesktopClerkConfig.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; +export { + getDesktopClerkFrontendApiHostname, + isDesktopClerkBridgeEnabled, + resolveDesktopClerkFrontendApiHostname, +} from "./DesktopClerkConfig.ts"; export class DesktopClerkBridgeInitializationError extends Schema.TaggedErrorClass()( "DesktopClerkBridgeInitializationError", @@ -52,25 +60,6 @@ export class DesktopClerk extends Context.Service< } >()("@t3tools/desktop/app/DesktopClerk") {} -export function resolveDesktopClerkFrontendApiHostname( - publishableKey: string | undefined, -): string | undefined { - const normalizedKey = publishableKey?.trim(); - if (!normalizedKey) return undefined; - - try { - return clerkFrontendApiHostnameFromPublishableKey(normalizedKey); - } catch { - return undefined; - } -} - -export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHostname( - typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" - ? undefined - : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, -); - export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { return createClerkBridge({ storage: storage({ path: stateDir }), @@ -82,54 +71,60 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea }); } -export const make = Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* Effect.acquireRelease( - Effect.try({ - try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), - catch: (cause) => - new DesktopClerkBridgeInitializationError({ - stateDir: environment.stateDir, - isDevelopment: environment.isDevelopment, - cause, +export function makeDesktopClerkLayer(enabled = isDesktopClerkBridgeEnabled()) { + const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (enabled) { + yield* Effect.acquireRelease( + Effect.try({ + try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), + catch: (cause) => + new DesktopClerkBridgeInitializationError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), }), - }), - (bridge) => - Effect.try({ - try: () => bridge.cleanup(), - catch: (cause) => - new DesktopClerkBridgeCleanupError({ - stateDir: environment.stateDir, - isDevelopment: environment.isDevelopment, - cause, - }), - }).pipe(Effect.orDie), - ); + (bridge) => + Effect.try({ + try: () => bridge.cleanup(), + catch: (cause) => + new DesktopClerkBridgeCleanupError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), + }).pipe(Effect.orDie), + ); + } - return DesktopClerk.of({ - configure: Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const context = yield* Effect.context(); - const runPromise = Effect.runPromiseWith(context); + return DesktopClerk.of({ + configure: Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); - if (!(yield* electronApp.requestSingleInstanceLock)) { - yield* electronApp.quit; - return yield* Effect.interrupt; - } + if (!(yield* electronApp.requestSingleInstanceLock)) { + yield* electronApp.quit; + return yield* Effect.interrupt; + } - yield* electronApp.on("second-instance", () => { - void runPromise( - Effect.gen(function* () { - const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(mainWindow)) { - yield* electronWindow.reveal(mainWindow.value); - } - }), - ); - }); - }).pipe(Effect.withSpan("desktop.clerk.configure")), + yield* electronApp.on("second-instance", () => { + void runPromise( + Effect.gen(function* () { + const mainWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(mainWindow)) { + yield* electronWindow.reveal(mainWindow.value); + } + }), + ); + }); + }).pipe(Effect.withSpan("desktop.clerk.configure")), + }); }); -}); -export const layer = Layer.effect(DesktopClerk, make); + return Layer.effect(DesktopClerk, make); +} + +export const layer = makeDesktopClerkLayer(); diff --git a/apps/desktop/src/app/DesktopClerkConfig.ts b/apps/desktop/src/app/DesktopClerkConfig.ts new file mode 100644 index 00000000000..548bd5ea3ac --- /dev/null +++ b/apps/desktop/src/app/DesktopClerkConfig.ts @@ -0,0 +1,32 @@ +declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; + +export function resolveDesktopClerkFrontendApiHostname( + publishableKey: string | undefined, +): string | undefined { + const normalizedKey = publishableKey?.trim(); + if (!normalizedKey) return undefined; + + try { + const encodedFrontendApi = normalizedKey.split("_").slice(2).join("_"); + const frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); + if (frontendApi.length === 0 || frontendApi.includes("/")) return undefined; + + return new URL(`https://${frontendApi}`).hostname; + } catch { + return undefined; + } +} + +const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHostname( + typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" + ? undefined + : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, +); + +export function getDesktopClerkFrontendApiHostname(): string | undefined { + return desktopClerkFrontendApiHostname; +} + +export function isDesktopClerkBridgeEnabled(): boolean { + return Boolean(desktopClerkFrontendApiHostname); +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4b5c0d2f656..ccad31d214f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,12 +4,16 @@ import type { DesktopPreviewRecordingFrame, DesktopPreviewTabState, } from "@t3tools/contracts"; -import { exposeClerkBridge } from "@clerk/electron/preload"; import { contextBridge, ipcRenderer } from "electron"; +import { isDesktopClerkBridgeEnabled } from "./app/DesktopClerkConfig.ts"; import * as IpcChannels from "./ipc/channels.ts"; -exposeClerkBridge({ passkeys: true }); +if (isDesktopClerkBridgeEnabled()) { + const { exposeClerkBridge } = + require("@clerk/electron/preload") as typeof import("@clerk/electron/preload"); + exposeClerkBridge({ passkeys: true }); +} function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 280f2109fec..cf809db3512 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -82,6 +82,7 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, + getURL: webContents.getURL, loadURL: window.loadURL, openDevTools: webContents.openDevTools, reload: webContents.reload, @@ -332,7 +333,7 @@ describe("DesktopWindow", () => { assert.equal(yield* Ref.get(createCount), 1); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); - assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); }).pipe(Effect.provide(layer)); }), @@ -359,17 +360,18 @@ describe("DesktopWindow", () => { return yield* Effect.die("renderer load listeners were not registered"); } - didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); + didFailLoad({}, -9, "ERR_UNEXPECTED", "http://127.0.0.1:5733/", true); assert.equal(fakeWindow.loadURL.mock.calls.length, 1); yield* TestClock.adjust(100); assert.deepEqual(fakeWindow.loadURL.mock.calls, [ - ["t3code-dev://app/"], - ["t3code-dev://app/"], + ["http://127.0.0.1:5733/"], + ["http://127.0.0.1:5733/"], ]); assert.equal(fakeWindow.reload.mock.calls.length, 0); - didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); + didFailLoad({}, -9, "ERR_UNEXPECTED", "http://127.0.0.1:5733/", true); + fakeWindow.getURL.mockReturnValue("http://127.0.0.1:5733/"); didFinishLoad(); yield* TestClock.adjust(250); assert.equal(fakeWindow.loadURL.mock.calls.length, 2); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b1dfabe7ab4..50242dc87d5 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -8,6 +8,7 @@ import * as Ref from "effect/Ref"; import type * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; +import * as DesktopClerk from "../app/DesktopClerk.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; @@ -246,7 +247,10 @@ export const make = Effect.gen(function* () { DesktopWindowError > { yield* previewManager.getBrowserSession(); - const applicationUrl = getDesktopUrl(environment.isDevelopment); + const applicationUrl = + environment.isDevelopment && !DesktopClerk.isDesktopClerkBridgeEnabled() + ? Option.getOrThrow(environment.devServerUrl).href + : getDesktopUrl(environment.isDevelopment); const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 62d1bce1157..27a6be78d26 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,5 +1,4 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import type { LegendListRef } from "@legendapp/list/react-native"; import type { ApprovalRequestId, @@ -17,7 +16,7 @@ import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { View, type GestureResponderEvent } from "react-native"; +import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -225,12 +224,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const composerOverlapHeight = composerChrome + composerBottomInset; const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8; - const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( - listRef, - composerOverlayRef, - estimatedOverlayHeight, - ); - const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); + const [composerOverlayHeight, setComposerOverlayHeight] = useState(estimatedOverlayHeight); + const contentBottomInset = Math.max(estimatedOverlayHeight, composerOverlayHeight); + const onComposerLayout = useCallback((event: LayoutChangeEvent) => { + const nextHeight = event.nativeEvent.layout.height; + setComposerOverlayHeight((current) => + Math.abs(current - nextHeight) < 0.5 ? current : nextHeight, + ); + }, []); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; @@ -272,8 +273,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useEffect(() => { setAnchorMessageId(null); lastScrolledAnchorMessageIdRef.current = null; - freeze.set(false); - }, [freeze, selectedThreadKey]); + }, [selectedThreadKey]); useEffect(() => { if ( @@ -291,7 +291,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return; } lastScrolledAnchorMessageIdRef.current = anchorMessageId; - void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { + const scrollPromise = listRef.current?.scrollToEnd({ animated: true }); + if (!scrollPromise) { + lastScrolledAnchorMessageIdRef.current = null; + return; + } + void scrollPromise.catch(() => { if ( selectedThreadKeyRef.current !== targetThreadKey || lastScrolledAnchorMessageIdRef.current !== anchorMessageId @@ -299,18 +304,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return; } lastScrolledAnchorMessageIdRef.current = null; - freeze.set(false); }); }); return () => cancelAnimationFrame(frame); - }, [ - anchorMessageId, - freeze, - contentPresentationKind, - selectedThreadFeed, - scrollMessageToEnd, - selectedThreadKey, - ]); + }, [anchorMessageId, contentPresentationKind, selectedThreadFeed, selectedThreadKey]); const handleSendMessage = useCallback(async () => { const targetThreadKey = selectedThreadKey; @@ -379,11 +376,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread agentLabel={agentLabel} latestTurn={props.selectedThread.latestTurn} listRef={listRef} - freeze={freeze} anchorMessageId={anchorMessageId} - contentInsetEndAdjustment={contentInsetEndAdjustment} contentTopInset={headerHeight} - contentBottomInset={estimatedOverlayHeight} + contentBottomInset={contentBottomInset} layoutVariant={layoutVariant} skills={selectedProviderSkills} /> diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a82e7c49ccd..7a4ee304091 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,5 +1,5 @@ import * as Haptics from "expo-haptics"; -import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; +import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; @@ -37,7 +37,6 @@ import { import { TouchableOpacity } from "react-native-gesture-handler"; import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import type { SharedValue } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { @@ -100,9 +99,7 @@ export interface ThreadFeedProps { readonly agentLabel: string; readonly latestTurn: ThreadFeedLatestTurn | null; readonly listRef: RefObject; - readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; - readonly contentInsetEndAdjustment: SharedValue; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly layoutVariant?: LayoutVariant; @@ -1452,15 +1449,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return ( <> - ref={props.listRef} key={props.threadId} style={{ flex: 1 }} automaticallyAdjustsScrollIndicatorInsets={false} scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }} + safeAreaInsetBottom={insets.bottom} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - contentInsetEndAdjustment={props.contentInsetEndAdjustment} - freeze={props.freeze} maintainScrollAtEnd={ disclosureToggleSettling ? false @@ -1483,12 +1479,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } keyboardShouldPersistTaps="always" keyboardDismissMode="none" - keyboardLiftBehavior="whenAtEnd" estimatedItemSize={180} initialScrollAtEnd ListHeaderComponent={} contentContainerStyle={{ paddingTop: 12, + paddingBottom: bottomContentInset, paddingHorizontal: horizontalPadding, }} /> diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1a4dc6b6895..ef09c1d8b1f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -19,10 +19,11 @@ import { useState, type KeyboardEvent, type MouseEvent, + type RefAttributes, type ReactNode, } from "react"; import { flushSync } from "react-dom"; -import { LegendList, type LegendListRef } from "@legendapp/list/react"; +import { LegendList, type LegendListProps, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; import { deriveTimelineEntries, @@ -113,6 +114,15 @@ import { type ReviewCommentContext, } from "../../reviewCommentContext"; +type TimelineLegendListProps = LegendListProps & + RefAttributes & { + readonly contentInsetEndAdjustment?: number; + }; + +const TimelineLegendList = LegendList as ( + props: TimelineLegendListProps, +) => ReactNode; + // --------------------------------------------------------------------------- // Context — shared state consumed by every row component via Context. // Propagates through LegendList's memo boundaries for shared callbacks and @@ -471,7 +481,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
- + ref={listRef} data={rows} keyExtractor={keyExtractor}