diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 3c95b266bc1..50f82ac3026 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 00000000000..107ee21d232 --- /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 f8e05915718..ab03d18f38d 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 f3ce3b4b5f4..077b343959c 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 0af8691f6c4..933f40e1705 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 8f50aa8f882..8d76ea83a33 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: {}, @@ -30,6 +31,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, sidebarV2Enabled: false, + sidebarV2ConfiguredByUser: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 168846466ed..0d48ab04ceb 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/Stack.tsx b/apps/mobile/src/Stack.tsx index 4dee5681a9d..2dcfe3505f5 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -47,7 +47,6 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; -import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, @@ -192,10 +191,11 @@ const SettingsSheetStack = createNativeStackNavigator({ }, }), SettingsWaitlist: createNativeStackScreen({ - screen: SettingsWaitlistRouteScreen, + // Keep the old deep link working after the Connect GA launch. + screen: SettingsAuthRouteScreen, linking: "waitlist", options: { - title: "Join the waitlist", + title: "Sign in", }, }), }, diff --git a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx deleted file mode 100644 index 1a7020fd0ed..00000000000 --- a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useWaitlist } from "@clerk/expo"; -import { ActivityIndicator, Pressable, Text, TextInput, View } from "react-native"; -import { useState } from "react"; - -import { cn } from "../../lib/cn"; -import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin"; - -export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) { - const { errors, fetchStatus, waitlist } = useWaitlist(); - const [emailAddress, setEmailAddress] = useState(""); - const [requestError, setRequestError] = useState(null); - const isSubmitting = fetchStatus === "fetching"; - const fieldError = errors.fields.emailAddress?.longMessage; - - const joinWaitlist = async () => { - const normalizedEmailAddress = emailAddress.trim(); - if (!normalizedEmailAddress || isSubmitting) { - return; - } - - setRequestError(null); - try { - await joinCloudWaitlist(waitlist, normalizedEmailAddress); - } catch (error) { - console.error(error); - setRequestError( - error instanceof CloudWaitlistJoinRejectedError - ? "Could not join the waitlist. Check your email address and try again." - : "Could not join the waitlist. Check your connection and try again.", - ); - } - }; - - if (waitlist.id) { - return ( - - - You are on the waitlist - - - We will email you when your T3 Connect access is ready. - - - - ); - } - - return ( - - - Enter your email and we will let you know when access is ready. - - - - Email address - { - setEmailAddress(value); - setRequestError(null); - }} - onSubmitEditing={() => void joinWaitlist()} - placeholder="Enter your email address" - placeholderTextColorClassName="accent-placeholder" - returnKeyType="join" - textContentType="emailAddress" - value={emailAddress} - /> - {fieldError || requestError ? ( - - {fieldError ?? requestError} - - ) : null} - - - void joinWaitlist()} - className="min-h-[54px] flex-row items-center justify-center gap-2 rounded-full bg-primary px-5 py-3 disabled:opacity-[0.45]" - > - {isSubmitting ? ( - - ) : null} - - {isSubmitting ? "Joining" : "Join the waitlist"} - - - - - - ); -} - -function SignInAction(props: { readonly onPress: () => void }) { - return ( - - Already have access? - - Sign in - - - ); -} diff --git a/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts b/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts deleted file mode 100644 index 582cb40ffbf..00000000000 --- a/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { - CloudWaitlistJoinRejectedError, - CloudWaitlistJoinRequestError, - joinCloudWaitlist, -} from "./cloudWaitlistJoin"; - -describe("joinCloudWaitlist", () => { - it("submits the provided email address", async () => { - const join = vi.fn().mockResolvedValue({ error: null }); - - await joinCloudWaitlist({ join }, "person@example.com"); - - expect(join).toHaveBeenCalledExactlyOnceWith({ emailAddress: "person@example.com" }); - }); - - it("preserves Clerk rejection details without exposing the email address", async () => { - const cause = Object.assign(new Error("The enrollment was rejected."), { - code: "form_identifier_invalid", - }); - const join = vi.fn().mockResolvedValue({ error: cause }); - - const failure = await joinCloudWaitlist({ join }, "secret@example.com").catch( - (error: unknown) => error, - ); - - expect(failure).toBeInstanceOf(CloudWaitlistJoinRejectedError); - expect(failure).toMatchObject({ - code: "form_identifier_invalid", - cause, - }); - expect(String(failure)).not.toContain("secret@example.com"); - }); - - it("distinguishes request failures from rejected enrollments", async () => { - const cause = new Error("network unavailable"); - const join = vi.fn().mockRejectedValue(cause); - - const failure = await joinCloudWaitlist({ join }, "person@example.com").catch( - (error: unknown) => error, - ); - - expect(failure).toBeInstanceOf(CloudWaitlistJoinRequestError); - expect(failure).toMatchObject({ cause }); - expect(failure).not.toBeInstanceOf(CloudWaitlistJoinRejectedError); - }); -}); diff --git a/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts b/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts deleted file mode 100644 index 4a467a19e4b..00000000000 --- a/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as Schema from "effect/Schema"; - -interface CloudWaitlistJoiner { - readonly join: (input: { emailAddress: string }) => Promise<{ - readonly error: { readonly code: string } | null; - }>; -} - -export class CloudWaitlistJoinRejectedError extends Schema.TaggedErrorClass()( - "CloudWaitlistJoinRejectedError", - { - code: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Cloud waitlist enrollment was rejected with code "${this.code}".`; - } -} - -export class CloudWaitlistJoinRequestError extends Schema.TaggedErrorClass()( - "CloudWaitlistJoinRequestError", - { - cause: Schema.Defect(), - }, -) { - override get message(): string { - return "Cloud waitlist enrollment request failed."; - } -} - -export async function joinCloudWaitlist( - waitlist: CloudWaitlistJoiner, - emailAddress: string, -): Promise { - const result = await waitlist.join({ emailAddress }).catch((cause) => { - throw new CloudWaitlistJoinRequestError({ cause }); - }); - - if (result.error) { - throw new CloudWaitlistJoinRejectedError({ - code: result.error.code, - cause: result.error, - }); - } -} diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 36cead9f8cc..4265107912b 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,7 +1,5 @@ import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; -import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; @@ -12,7 +10,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; import { useThemeColor } from "../../lib/useThemeColor"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; @@ -59,21 +57,14 @@ function checkedMenuState(checked: boolean) { return checked ? ("on" as const) : undefined; } -/** Thread List v2 lays the list out in fixed creation order, so the - sort/group filter controls would be silently ignored — hide them and - key the "customized" icon state off the environment filter alone. */ -function useThreadListV2FilterGate() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - return ( - AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true - ); -} - function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); - const threadListV2Enabled = useThreadListV2FilterGate(); + // Thread List v2 lays the list out in fixed creation order, so the + // sort/group filter controls would be silently ignored — hide them and + // key the "customized" icon state off the environment filter alone. + const threadListV2Enabled = useThreadListV2Enabled(); const hasCustomListOptions = threadListV2Enabled ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null : hasCustomHomeListOptions(props); @@ -291,7 +282,10 @@ function AndroidHomeHeader(props: HomeHeaderProps) { function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useThemeColor("--color-icon"); - const threadListV2Enabled = useThreadListV2FilterGate(); + // Thread List v2 lays the list out in fixed creation order, so the + // sort/group filter controls would be silently ignored — hide them and + // key the "customized" icon state off the environment filter alone. + const threadListV2Enabled = useThreadListV2Enabled(); const hasCustomListOptions = threadListV2Enabled ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null : hasCustomHomeListOptions(props); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 82e563a0e7d..10158c17504 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -27,6 +27,7 @@ import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -181,9 +182,7 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; + const threadListV2Enabled = useThreadListV2Enabled(); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 4c33675f57f..0d23b890ad0 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -37,6 +37,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -263,13 +264,13 @@ function ConfiguredSettingsRouteScreen() { const promptSignIn = useCallback(() => { Alert.alert( - "Request T3 Connect access", - "Live Activity updates require approved T3 Connect access so relay can deliver updates to this device.", + "Sign in to T3 Connect", + "Live Activity updates require T3 Connect so relay can deliver updates to this device.", [ { text: "Cancel", style: "cancel" }, { text: "Continue", - onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }), + onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), }, ], ); @@ -431,7 +432,8 @@ function ConfiguredSettingsRouteScreen() { const openAccount = useCallback(() => { if (!isLoaded) return; if (!isSignedIn) { - navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }); + expandClerkSheet(); + navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); return; } expandClerkSheet(); @@ -546,11 +548,8 @@ function GeneralSettingsSection() { * the counterpart of web's Settings → Beta backed by mobile preferences. */ function BetaSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.threadListV2Enabled === true - : false; + const threadListV2Enabled = useThreadListV2Enabled(); return ( diff --git a/apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx deleted file mode 100644 index f5182307ebb..00000000000 --- a/apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useAuth } from "@clerk/expo"; -import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; -import { useCallback } from "react"; -import { ScrollView } from "react-native"; - -import { CloudWaitlistEnrollment } from "../cloud/CloudWaitlistEnrollment"; -import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; -import { hasCloudPublicConfig } from "../cloud/publicConfig"; - -export function SettingsWaitlistRouteScreen() { - const navigation = useNavigation(); - - useFocusEffect( - useCallback(() => { - if (!hasCloudPublicConfig()) { - navigation.dispatch(StackActions.replace("Settings")); - } - }, [navigation]), - ); - - return hasCloudPublicConfig() ? : null; -} - -function ConfiguredSettingsWaitlistRouteScreen() { - const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const { expand } = useClerkSettingsSheetDetent(); - const navigation = useNavigation(); - - useFocusEffect( - useCallback(() => { - if (isLoaded && isSignedIn) { - navigation.dispatch(StackActions.replace("Settings")); - } - }, [isLoaded, isSignedIn, navigation]), - ); - - return ( - <> - - { - expand(); - navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); - }} - /> - - - ); -} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index e8b2c9b1903..a898ccb9af9 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -6,7 +6,6 @@ import type { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; @@ -25,7 +24,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -196,10 +195,7 @@ function ThreadNavigationSidebarPane( const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = useThreadListActions(); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; + const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index e62b9ceda34..b3e9f73bfe1 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildThreadListV2Items, + resolveThreadListV2Enabled, resolveThreadListV2Status, sortThreadsForListV2, } from "./threadListV2"; @@ -38,6 +39,47 @@ function makeThread( const NOW = "2026-06-02T00:00:00.000Z"; +describe("resolveThreadListV2Enabled", () => { + it.each(["development", "preview"])("defaults on for the %s variant", (appVariant) => { + expect( + resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }), + ).toBe(true); + }); + + it.each(["production", undefined])("defaults off for the %s variant", (appVariant) => { + expect( + resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }), + ).toBe(false); + }); + + 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); + }); +}); + describe("resolveThreadListV2Status", () => { it("prioritizes approval over a running session", () => { const thread = makeThread({ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index efa68153a3f..62c0b39aeb8 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -18,6 +18,37 @@ export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | " 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". + * + * `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. + */ +export function resolveThreadListV2Enabled(input: { + readonly preference: boolean | undefined; + readonly preferencesLoaded: boolean; + readonly appVariant: unknown; +}): boolean { + if (!input.preferencesLoaded) { + return false; + } + return input.preference ?? resolveThreadListV2Default(input.appVariant); +} + export function resolveThreadListV2Status( thread: Pick, ): ThreadListV2Status { 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 new file mode 100644 index 00000000000..bb03b5aa9ad --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts @@ -0,0 +1,25 @@ +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. + */ +export function useThreadListV2Enabled(): boolean { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferencesResult); + 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 4e576bb2fe1..bbcf4131f31 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -26,7 +26,8 @@ export interface Preferences { /** * 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. + * device. Undefined means the user has never chosen, in which case the app + * variant decides — see `resolveThreadListV2Enabled`. */ readonly threadListV2Enabled?: boolean; } diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 056fbb76e6a..06d663ca0b4 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -11,6 +11,51 @@ export function formatAppDisplayName(input: { return `${input.baseName} (${input.stageLabel})`; } +/** + * Whether the sidebar v2 beta is on by default for a build stage. + * + * Nightly and local dev opt in; Alpha and Latest stay on v1. This is resolved + * from the client's own stage label rather than the connected server's version: + * v2 only exists in the client, so a stable client on a nightly server has + * nothing to turn on. + */ +export function resolveSidebarV2Default(stageLabel: string): boolean { + const stage = stageLabel.trim().toLowerCase(); + return stage === "nightly" || stage === "dev"; +} + +/** + * Resolved sidebar v2 state: an explicit choice if the user has made one, + * otherwise the default for this build stage. + * + * A stored `enabled: true` counts as an explicit choice even without the + * companion flag. `true` was never the schema default, so it can only have come + * from the Settings → Beta toggle — settings written before that flag existed + * would otherwise lose the opt-in and drop such users back to v1 on production. + * Mirrors how `normalizeDesktopSettingsDocument` treats a legacy stored + * `updateChannel: "nightly"` as user-configured. + * + * `settingsHydrated` guards the startup window: client settings load + * asynchronously and the pre-hydration snapshot is just the schema defaults, so + * resolving against it would mount one sidebar and swap it out a tick later, + * remounting the tree. While hydrating, hold v1 — where both paths already + * start. + */ +export function resolveSidebarV2Enabled(input: { + readonly enabled: boolean; + readonly configuredByUser: boolean; + readonly settingsHydrated: boolean; + readonly stageLabel: string; +}): boolean { + if (!input.settingsHydrated) { + return false; + } + + return input.configuredByUser || input.enabled + ? input.enabled + : resolveSidebarV2Default(input.stageLabel); +} + export function resolveServerBackedAppStageLabel(input: { readonly primaryServerVersion: string | null | undefined; readonly fallbackStageLabel: string; diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index e1c87bcf059..e517d40b04f 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { resolveServerBackedAppDisplayName, resolveServerBackedAppStageLabel, + resolveSidebarV2Default, + resolveSidebarV2Enabled, } from "./branding.logic"; const originalWindow = globalThis.window; @@ -114,3 +116,74 @@ describe("branding logic", () => { ).toBe("T3 Code (Alpha)"); }); }); + +describe("resolveSidebarV2Default", () => { + it.each(["Nightly", "Dev", "nightly", " dev "])("enables the beta for %s builds", (stage) => { + expect(resolveSidebarV2Default(stage)).toBe(true); + }); + + it.each(["Alpha", "Latest", ""])("leaves the beta off for %s builds", (stage) => { + expect(resolveSidebarV2Default(stage)).toBe(false); + }); +}); + +describe("resolveSidebarV2Enabled", () => { + const hydrated = { settingsHydrated: true } as const; + + it.each(["Alpha", "Latest"])( + "keeps a legacy opt-in on %s builds even without the companion flag", + (stageLabel) => { + // `true` was never the schema default, so it can only be an explicit + // opt-in from settings written before `sidebarV2ConfiguredByUser` existed. + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: true, + configuredByUser: false, + stageLabel, + }), + ).toBe(true); + }, + ); + + it("applies the stage default when the beta was never enabled or configured", () => { + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: false, + configuredByUser: false, + stageLabel: "Nightly", + }), + ).toBe(true); + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: false, + configuredByUser: false, + stageLabel: "Latest", + }), + ).toBe(false); + }); + + it("honors an explicit opt-out over the stage default", () => { + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: false, + configuredByUser: true, + stageLabel: "Nightly", + }), + ).toBe(false); + }); + + it("holds v1 until settings hydrate so the sidebar does not remount", () => { + expect( + resolveSidebarV2Enabled({ + enabled: true, + configuredByUser: true, + settingsHydrated: false, + stageLabel: "Nightly", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 3a70390d0c4..5c6acd62aea 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,12 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; -import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; +import { + useEffect, + useState, + useSyncExternalStore, + type CSSProperties, + type ReactNode, +} from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; @@ -8,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 { useClientSettings } from "../hooks/useSettings"; +import { useEnvironmentIdentificationMode, useSidebarV2Enabled } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; import ThreadSidebarV2 from "./SidebarV2"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; @@ -31,6 +37,15 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; +function subscribeToViewportWidth(onChange: () => void): () => void { + window.addEventListener("resize", onChange); + return () => window.removeEventListener("resize", onChange); +} + +function readViewportWidth(): number { + return window.innerWidth; +} + function readInitialThreadSidebarWidth(): number { try { return resolveInitialThreadSidebarWidth( @@ -47,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(() => { @@ -100,7 +118,7 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); - const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarV2Enabled = useSidebarV2Enabled(); // Settings routes render the settings nav, which lives in the v1 component // and is identical for both sidebars — so v1 stays mounted there. const pathname = useLocation({ select: (location) => location.pathname }); @@ -109,7 +127,11 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const useSidebarV2Theme = useSidebarV2 || isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); - const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); + // Subscribed rather than read once: the clamp must track live window size, + // and a clamped drag ends with an unchanged width, which skips the re-render + // that would otherwise refresh a render-time snapshot. + const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth); + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; return isMacosDesktop && typeof getWindowFullscreenState === "function" diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index c4ae7b96694..76336f1ef1f 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -9,6 +9,8 @@ import { resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, resolveEnvModeLabel, + resolveBranchTriggerLabel, + resolveBranchToolbarPrBranch, resolveBranchToolbarValue, resolveLockedWorkspaceLabel, resolveLocalCheckoutBranchMismatch, @@ -173,6 +175,130 @@ describe("resolveBranchToolbarValue", () => { }); }); +describe("resolveBranchTriggerLabel", () => { + it("shows the origin ref when a new worktree will start from origin", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "main", + resolvedActiveBranchIsRemote: false, + startFromOrigin: true, + }), + ).toBe("From origin/main"); + }); + + it("shows the origin ref for local branch names that contain slashes", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "feature/demo", + resolvedActiveBranchIsRemote: false, + startFromOrigin: true, + }), + ).toBe("From origin/feature/demo"); + }); + + it("shows the local ref when start from origin is disabled", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "main", + resolvedActiveBranchIsRemote: false, + startFromOrigin: false, + }), + ).toBe("From main"); + }); + + it("does not duplicate the origin prefix for an explicit remote ref", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "origin/feature/demo", + resolvedActiveBranchIsRemote: true, + startFromOrigin: true, + }), + ).toBe("From origin/feature/demo"); + }); + + it("preserves an explicit ref from a non-origin remote", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "upstream/feature/demo", + resolvedActiveBranchIsRemote: true, + startFromOrigin: true, + }), + ).toBe("From upstream/feature/demo"); + }); + + it("keeps current-checkout labels and empty state unchanged", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "local", + resolvedActiveBranch: "main", + resolvedActiveBranchIsRemote: false, + startFromOrigin: true, + }), + ).toBe("main"); + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: null, + resolvedActiveBranchIsRemote: null, + startFromOrigin: true, + }), + ).toBe("Select ref"); + }); + + it("does not fabricate an origin ref while branch metadata is loading", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "upstream/feature/demo", + resolvedActiveBranchIsRemote: null, + startFromOrigin: true, + }), + ).toBe("From upstream/feature/demo"); + }); +}); + +describe("resolveBranchToolbarPrBranch", () => { + it("uses the explicit thread branch when it matches the displayed branch", () => { + expect( + resolveBranchToolbarPrBranch({ + activeThreadBranch: "feature/current", + resolvedActiveBranch: "feature/current", + }), + ).toBe("feature/current"); + }); + + it("hides PR state while an optimistic branch switch is in flight", () => { + expect( + resolveBranchToolbarPrBranch({ + activeThreadBranch: "feature/current", + resolvedActiveBranch: "feature/next", + }), + ).toBeNull(); + }); + + it("does not infer PR state without an explicit thread branch", () => { + expect( + resolveBranchToolbarPrBranch({ + activeThreadBranch: null, + resolvedActiveBranch: "feature/current", + }), + ).toBeNull(); + }); +}); + describe("resolveLocalCheckoutBranchMismatch", () => { it("detects when a local thread is associated with a different branch than the checkout", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 8fe35fa464a..d9737f17a32 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -156,6 +156,40 @@ export function resolveBranchToolbarValue(input: { return currentGitBranch ?? activeThreadBranch; } +export function resolveBranchTriggerLabel(input: { + activeWorktreePath: string | null; + effectiveEnvMode: EnvMode; + resolvedActiveBranch: string | null; + resolvedActiveBranchIsRemote: boolean | null; + startFromOrigin: boolean; +}): string { + const { + activeWorktreePath, + effectiveEnvMode, + resolvedActiveBranch, + resolvedActiveBranchIsRemote, + startFromOrigin, + } = input; + if (!resolvedActiveBranch) { + return "Select ref"; + } + if (effectiveEnvMode === "worktree" && !activeWorktreePath) { + const baseRef = + startFromOrigin && resolvedActiveBranchIsRemote === false + ? `origin/${resolvedActiveBranch}` + : resolvedActiveBranch; + return `From ${baseRef}`; + } + return resolvedActiveBranch; +} + +export function resolveBranchToolbarPrBranch(input: { + activeThreadBranch: string | null; + resolvedActiveBranch: string | null; +}): string | null { + return input.activeThreadBranch === input.resolvedActiveBranch ? input.activeThreadBranch : null; +} + export function resolveLocalCheckoutBranchMismatch(input: { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index e703427d4b6..3a83f5c9a0f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -234,10 +234,10 @@ export const BranchToolbar = memo(function BranchToolbar({ () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); - const serverThread = useThread(threadRef); const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); + const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) @@ -321,7 +321,7 @@ export const BranchToolbar = memo(function BranchToolbar({ onUsePreviousWorktree={onUsePreviousWorktree} /> ) : ( -
+
{showEnvironmentIndicator && availableEnvironments && ( <> scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); - const serverThread = useThread(threadRef); - const serverSession = serverThread?.session ?? null; const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); + const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + const serverSession = serverThread?.session ?? null; const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread @@ -308,6 +295,29 @@ export function BranchToolbarBranchSelector({ canonicalActiveBranch, (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, ); + const listedActiveBranch = + resolvedActiveBranch === null ? null : (branchByName.get(resolvedActiveBranch) ?? null); + const activeBranchRefQuery = useEnvironmentQuery( + branchCwd !== null && resolvedActiveBranch !== null + ? vcsEnvironment.listRefs({ + environmentId, + input: { + cwd: branchCwd, + query: resolvedActiveBranch, + limit: 10, + }, + }) + : null, + ); + const queriedActiveBranch = activeBranchRefQuery.data?.refs.find( + (refName) => refName.name === resolvedActiveBranch, + ); + const resolvedActiveBranchIsRemote = + listedActiveBranch !== null + ? listedActiveBranch.isRemote === true + : queriedActiveBranch + ? queriedActiveBranch.isRemote === true + : null; const [isBranchActionPending, startBranchActionTransition] = useTransition(); const totalBranchCount = branchRefState.data?.totalCount ?? 0; const branchStatusText = isInitialBranchesLoadPending @@ -587,17 +597,21 @@ export function BranchToolbarBranchSelector({ maybeFetchNextBranchPage(); }, [refs.length, maybeFetchNextBranchPage]); - const triggerLabel = getBranchTriggerLabel({ + const triggerLabel = resolveBranchTriggerLabel({ activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, + resolvedActiveBranchIsRemote, + startFromOrigin, }); // PR pill shown next to the branch selector when the active branch has one. const branchPr = resolveThreadPr({ - threadBranch: resolvedActiveBranch, + threadBranch: resolveBranchToolbarPrBranch({ + activeThreadBranch, + resolvedActiveBranch, + }), gitStatus: branchStatusQuery.data ?? null, - hasDedicatedWorktree: activeWorktreePath !== null, }); const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index e915c27312c..d300139d3cf 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -50,7 +50,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( - + {activeWorktreePath ? ( <> @@ -79,7 +79,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe }} items={envModeItems} > - + {effectiveEnvMode === "worktree" ? ( ) : activeWorktreePath ? ( diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index dbc742bea5a..e4ed54758ff 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -43,13 +43,13 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir if (envLocked || onEnvironmentChange === undefined) { return ( - + {activeEnvironment?.isPrimary ? ( - + ) : ( - + )} - {activeEnvironment?.label ?? "Run on"} + {activeEnvironment?.label ?? "Run on"} ); } @@ -61,11 +61,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} items={environmentItems} > - + {activeEnvironment?.isPrimary ? ( - + ) : ( - + )} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 285b0ca5cce..d86fe39a77f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -67,9 +67,10 @@ import { import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { normalizeMarkdownLinkDestination, + resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, - resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref, + type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; @@ -163,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, @@ -175,6 +176,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, + remarkTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ @@ -182,6 +184,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, + remarkTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ @@ -254,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); @@ -277,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, @@ -817,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)) { @@ -1282,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); }, []); @@ -1340,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)}

; }, @@ -1456,91 +1562,24 @@ 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 ( - openMarkdownFileInPreview(fileLinkMeta.filePath) - : undefined - } - className={props.className} - /> + return fileLinkChip( + fileLinkMeta, + `[${fileLinkMeta.basename}](${normalizedHref})`, + props.className, ); }, - code({ node: _node, className, children, ...props }) { - // Only transform inline code (fenced code blocks have language classes - // and are handled by the `pre` override). - if (className) { - return ( - - {children} - - ); - } - - const text = typeof children === "string" ? children : nodeToPlainText(children); - const targetPath = resolveMarkdownFileLinkTarget(text.trim(), cwd); - - if (!targetPath) { - return {children}; + 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}\``); + } } - - // Strip :line:col suffix — OS default apps don't understand them. - const pathForOpen = targetPath.replace(/:\d+(?::\d+)?$/, ""); - return ( - { - event.preventDefault(); - event.stopPropagation(); - const api = readLocalApi(); - if (api) { - api.shell.openInEditor(pathForOpen, "file-manager").catch((error: unknown) => { - console.warn("Unable to open in file manager.", error); - }); - } - }} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - const api = readLocalApi(); - if (api) { - api.shell.openInEditor(pathForOpen, "file-manager").catch((error: unknown) => { - console.warn("Unable to open in file manager.", error); - }); - } - } - }} - > + {children} ); @@ -1579,23 +1618,23 @@ function ChatMarkdown({ ); }, - }), - [ - cwd, - 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 (
{ + it("preserves shell metadata and supplies empty detail collections", () => { + const shell = { + environmentId, + id: threadId, + projectId, + title: "Loading thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + session: null, + latestUserMessageAt: now, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } satisfies ThreadShell; + + expect(buildLoadingThreadFromShell(shell)).toMatchObject({ + environmentId, + id: threadId, + projectId, + title: "Loading thread", + branch: "main", + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + }); + }); +}); + describe("resolveThreadMetadataUpdateForNextTurn", () => { const modelSelection = { instanceId: ProviderInstanceId.make("codex"), @@ -425,19 +472,24 @@ describe("reconcileRetainedMountedThreadIds", () => { }); describe("shouldWriteThreadErrorToCurrentServerThread", () => { - it("requires the environment, route thread, and target thread to match", () => { + it("writes errors for a shell-derived active server thread", () => { const routeThreadRef = { environmentId, threadId }; expect( shouldWriteThreadErrorToCurrentServerThread({ - serverThread: { environmentId, id: threadId }, + activeServerThread: { environmentId, id: threadId }, routeThreadRef, targetThreadId: threadId, }), ).toBe(true); + }); + + it("requires an active server thread matching the environment, route, and target", () => { + const routeThreadRef = { environmentId, threadId }; + expect( shouldWriteThreadErrorToCurrentServerThread({ - serverThread: null, + activeServerThread: null, routeThreadRef, targetThreadId: threadId, }), @@ -445,6 +497,33 @@ describe("shouldWriteThreadErrorToCurrentServerThread", () => { }); }); +describe("startNewThreadForProject", () => { + it("starts a thread through the supplied shared handler for the active project", () => { + const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = []; + const projectRef = { environmentId, projectId }; + + expect( + startNewThreadForProject(projectRef, (nextProjectRef) => { + calls.push(nextProjectRef); + return Promise.resolve(); + }), + ).toBe(true); + expect(calls).toEqual([projectRef]); + }); + + it("does nothing when the active project is unavailable", () => { + let called = false; + + expect( + startNewThreadForProject(null, () => { + called = true; + return Promise.resolve(); + }), + ).toBe(false); + expect(called).toBe(false); + }); +}); + describe("hasServerAcknowledgedLocalDispatch", () => { it("does not acknowledge unchanged server state", () => { const localDispatch = createLocalDispatchSnapshot( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 466c9b24c87..04b35fd4551 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -5,11 +5,12 @@ import { type ModelSelection, type ProviderDriverKind, type ServerProvider, + type ScopedProjectRef, type ScopedThreadRef, type ThreadId, type TurnId, } from "@t3tools/contracts"; -import { type ChatMessage, type SessionPhase, type Thread } from "../types"; +import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -27,6 +28,16 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function startNewThreadForProject( + projectRef: ScopedProjectRef | null, + handleNewThread: (projectRef: ScopedProjectRef) => Promise, +): boolean { + if (projectRef === null) return false; + void handleNewThread(projectRef); + + return true; +} + export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; nextModelSelection?: ModelSelection; @@ -84,8 +95,19 @@ export function buildLocalDraftThread( }; } +export function buildLoadingThreadFromShell(shell: ThreadShell): Thread { + return { + ...shell, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + deletedAt: null, + }; +} + export function shouldWriteThreadErrorToCurrentServerThread(input: { - serverThread: + activeServerThread: | { environmentId: EnvironmentId; id: ThreadId; @@ -96,10 +118,10 @@ export function shouldWriteThreadErrorToCurrentServerThread(input: { targetThreadId: ThreadId; }): boolean { return Boolean( - input.serverThread && + input.activeServerThread && input.targetThreadId === input.routeThreadRef.threadId && - input.serverThread.environmentId === input.routeThreadRef.environmentId && - input.serverThread.id === input.targetThreadId, + input.activeServerThread.environmentId === input.routeThreadRef.environmentId && + input.activeServerThread.id === input.targetThreadId, ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 328a3fa9423..9742925973c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -168,6 +168,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; @@ -237,6 +238,7 @@ import { import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { resolveThreadPr } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, DRAFT_HERO_TRANSITION_DURATION_MS, @@ -250,6 +252,7 @@ import { branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, + buildLoadingThreadFromShell, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, @@ -271,8 +274,11 @@ import { resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + shouldWriteThreadErrorToCurrentServerThread, + startNewThreadForProject, waitForStartedServerThread, } from "./ChatView.logic"; +import type { ThreadSyncPhase } from "../threadSync"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; @@ -463,6 +469,7 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + threadSyncPhase?: ThreadSyncPhase | null; routeKind: "server"; draftId?: never; } @@ -472,6 +479,7 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + threadSyncPhase?: never; routeKind: "draft"; draftId: DraftId; }; @@ -618,8 +626,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); - const serverThread = useThread(threadRef); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread @@ -974,8 +982,8 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane newShortcutLabel, closeShortcutLabel, }: PersistentThreadTerminalPanelProps) { - const serverThread = useThread(threadRef); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread @@ -1130,6 +1138,9 @@ function ChatViewContent(props: ChatViewProps) { forceExpandedMobileComposer = false, } = props; const draftId = routeKind === "draft" ? props.draftId : null; + const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; + const threadDetailLoading = threadSyncPhase === "loading"; + const handleNewThread = useNewThreadHandler(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -1178,7 +1189,23 @@ function ChatViewContent(props: ChatViewProps) { ); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; - const serverThread = useThread(routeThreadRef); + const draftThread = useComposerDraftStore((store) => + routeKind === "server" + ? store.getDraftSessionByRef(routeThreadRef) + : draftId + ? store.getDraftSession(draftId) + : null, + ); + const routeServerThreadShell = useThreadShell(routeKind === "server" ? routeThreadRef : null); + const serverThread = useThread(routeThreadRef, { waitForShell: draftThread !== null }); + const loadingServerThread = useMemo( + () => + threadDetailLoading && routeServerThreadShell + ? buildLoadingThreadFromShell(routeServerThreadShell) + : null, + [routeServerThreadShell, threadDetailLoading], + ); + const activeServerThread = serverThread ?? loadingServerThread; const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( (store) => store.threadLastVisitedAtById[routeThreadKey], @@ -1231,13 +1258,6 @@ function ChatViewContent(props: ChatViewProps) { const setLogicalProjectDraftThreadId = useComposerDraftStore( (store) => store.setLogicalProjectDraftThreadId, ); - const draftThread = useComposerDraftStore((store) => - routeKind === "server" - ? store.getDraftSessionByRef(routeThreadRef) - : draftId - ? store.getDraftSession(draftId) - : null, - ); const promptRef = useRef(""); const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); @@ -1365,7 +1385,7 @@ function ChatViewContent(props: ChatViewProps) { ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const fallbackDraftProject = useProject(fallbackDraftProjectRef); - const localDraftError = serverThread + const localDraftError = activeServerThread ? null : ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null); const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null; @@ -1374,7 +1394,7 @@ function ChatViewContent(props: ChatViewProps) { // a failed send would silently disappear on promotion. When both keys hold // an entry, the most recent write wins. useEffect(() => { - if (!serverThread || !draftId) { + if (!activeServerThread || !draftId) { return; } const pendingDraftEntry = localDraftErrorsByDraftId[draftId]; @@ -1403,7 +1423,7 @@ function ChatViewContent(props: ChatViewProps) { [routeThreadKey]: pendingDraftEntry, }; }); - }, [draftId, localDraftErrorsByDraftId, routeThreadKey, serverThread]); + }, [activeServerThread, draftId, localDraftErrorsByDraftId, routeThreadKey]); const localDraftThread = useMemo( () => draftThread @@ -1418,10 +1438,10 @@ function ChatViewContent(props: ChatViewProps) { // Promotion is data-driven: the draft route keeps rendering while the // server thread (same pre-allocated ref) starts, so live state must not // depend on which route is mounted. - const isServerThread = serverThread !== null; - const activeThread = isServerThread ? serverThread : localDraftThread; + const isServerThread = activeServerThread !== null; + const activeThread = activeServerThread ?? localDraftThread; const threadError = isServerThread - ? (localServerError ?? serverThread?.session?.lastError ?? null) + ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = @@ -1583,6 +1603,9 @@ function ChatViewContent(props: ChatViewProps) { ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; const activeProject = useProject(activeProjectRef); + const handleNewThreadInActiveProject = useCallback(() => { + startNewThreadForProject(activeProjectRef, handleNewThread); + }, [activeProjectRef, handleNewThread]); const activeEnvironmentShell = useEnvironmentQuery( activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, ); @@ -2484,10 +2507,11 @@ function ChatViewContent(props: ChatViewProps) { const nextError = sanitizeThreadErrorMessage(error); const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; if ( - serverThread && - targetThreadId === routeThreadRef.threadId && - serverThread.environmentId === routeThreadRef.environmentId && - serverThread.id === targetThreadId + shouldWriteThreadErrorToCurrentServerThread({ + activeServerThread, + routeThreadRef, + targetThreadId, + }) ) { setLocalServerErrorsByThreadKey((existing) => { if ((existing[routeThreadKey]?.message ?? null) === nextError) { @@ -2511,7 +2535,7 @@ function ChatViewContent(props: ChatViewProps) { }; }); }, - [draftId, routeThreadKey, routeThreadRef, serverThread], + [activeServerThread, draftId, routeThreadKey, routeThreadRef], ); const focusComposer = useCallback(() => { @@ -3973,7 +3997,6 @@ function ChatViewContent(props: ChatViewProps) { const activeThreadPr = resolveThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, - hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, }); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; @@ -4571,6 +4594,7 @@ function ChatViewContent(props: ChatViewProps) { !activeThread || isSendBusy || isConnecting || + threadDetailLoading || activeEnvironmentUnavailable || sendInFlightRef.current ) @@ -5783,6 +5807,7 @@ function ChatViewContent(props: ChatViewProps) { availableEditors={availableEditors} rightPanelOpen={rightPanelOpen} gitCwd={gitCwd} + onNewThreadInProject={handleNewThreadInActiveProject} onRunProjectScript={runProjectScript} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} @@ -5840,7 +5865,7 @@ function ChatViewContent(props: ChatViewProps) { contentInsetEndAdjustment={composerOverlayHeight} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} - hideEmptyPlaceholder={isDraftHeroState} + hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} /> @@ -5901,6 +5926,9 @@ function ChatViewContent(props: ChatViewProps) { ) : ( )} + {threadSyncPhase && !activeEnvironmentUnavailable ? ( + + ) : null}
{ expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); }); }); + +describe("buildBrowseGroups", () => { + it("waits for asynchronous browse navigation actions", async () => { + let finishNavigation: (() => void) | undefined; + const browseTo = vi.fn( + () => + new Promise((resolve) => { + finishNavigation = resolve; + }), + ); + const groups = buildBrowseGroups({ + browseEntries: [{ name: "Downloads", fullPath: "/Users/test/Downloads" }], + browseQuery: "~/", + canBrowseUp: false, + upIcon: null, + directoryIcon: null, + browseUp: vi.fn(), + browseTo, + }); + const item = groups[0]?.items[0]; + if (!item || item.kind !== "action") { + throw new Error("Expected a browse action"); + } + + let actionSettled = false; + const action = item.run().then(() => { + actionSettled = true; + }); + await Promise.resolve(); + + expect(browseTo).toHaveBeenCalledWith("Downloads"); + expect(actionSettled).toBe(false); + + finishNavigation?.(); + await action; + expect(actionSettled).toBe(true); + }); +}); + +describe("createBrowseNavigationCoordinator", () => { + it("only commits the latest overlapping navigation", async () => { + const coordinator = createBrowseNavigationCoordinator(); + let finishFirst: (() => void) | undefined; + let finishSecond: (() => void) | undefined; + const commits: string[] = []; + + const first = coordinator.run({ + load: () => + new Promise((resolve) => { + finishFirst = resolve; + }), + commit: () => { + commits.push("first"); + }, + }); + const second = coordinator.run({ + load: () => + new Promise((resolve) => { + finishSecond = resolve; + }), + commit: () => { + commits.push("second"); + }, + }); + + finishSecond?.(); + await expect(second).resolves.toBe(true); + finishFirst?.(); + await expect(first).resolves.toBe(false); + expect(commits).toEqual(["second"]); + }); + + it("does not commit after newer user input invalidates the navigation", async () => { + const coordinator = createBrowseNavigationCoordinator(); + let finishNavigation: (() => void) | undefined; + const commit = vi.fn(); + const navigation = coordinator.run({ + load: () => + new Promise((resolve) => { + finishNavigation = resolve; + }), + commit, + }); + + coordinator.invalidate(); + finishNavigation?.(); + + await expect(navigation).resolves.toBe(false); + expect(commit).not.toHaveBeenCalled(); + }); +}); + +describe("canPreloadBrowsePath", () => { + it("only preloads paths for connected environments", () => { + expect(canPreloadBrowsePath("connected")).toBe(true); + expect(canPreloadBrowsePath("offline")).toBe(false); + expect(canPreloadBrowsePath("reconnecting")).toBe(false); + expect(canPreloadBrowsePath(null)).toBe(false); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f69c38e1a0f..f6f5a08352c 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -3,6 +3,7 @@ import { type FilesystemBrowseEntry, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; @@ -15,6 +16,39 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; export const ADDON_ICON_CLASS = "size-4"; +export interface BrowseNavigationCoordinator { + readonly invalidate: () => void; + readonly run: (input: { + readonly load: () => Promise; + readonly commit: () => void; + }) => Promise; +} + +export function createBrowseNavigationCoordinator(): BrowseNavigationCoordinator { + let generation = 0; + + return { + invalidate: () => { + generation += 1; + }, + run: async (input) => { + const navigationGeneration = ++generation; + await input.load(); + if (navigationGeneration !== generation) { + return false; + } + input.commit(); + return true; + }, + }; +} + +export function canPreloadBrowsePath( + connectionPhase: EnvironmentConnectionPhase | null | undefined, +): boolean { + return connectionPhase === "connected"; +} + export interface CommandPaletteItem { readonly kind: "action" | "submenu"; readonly value: string; @@ -73,10 +107,8 @@ export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-b export function filterBrowseEntries(input: { browseEntries: ReadonlyArray; browseFilterQuery: string; - highlightedItemValue: string | null; }): { filteredEntries: FilesystemBrowseEntry[]; - highlightedEntry: FilesystemBrowseEntry | null; exactEntry: FilesystemBrowseEntry | null; } { const lowerFilter = input.browseFilterQuery.toLowerCase(); @@ -88,18 +120,12 @@ export function filterBrowseEntries(input: { (showHidden || !entry.name.startsWith(".")), ); - let highlightedEntry: FilesystemBrowseEntry | null = null; - if (input.highlightedItemValue?.startsWith("browse:")) { - const highlightedPath = input.highlightedItemValue.slice("browse:".length); - highlightedEntry = filteredEntries.find((entry) => entry.fullPath === highlightedPath) ?? null; - } - const exactEntry = input.browseFilterQuery.length > 0 ? (filteredEntries.find((entry) => entry.name === input.browseFilterQuery) ?? null) : null; - return { filteredEntries, highlightedEntry, exactEntry }; + return { filteredEntries, exactEntry }; } export function normalizeSearchText(value: string): string { @@ -302,8 +328,8 @@ export function buildBrowseGroups(input: { canBrowseUp: boolean; upIcon: ReactNode; directoryIcon: ReactNode; - browseUp: () => void; - browseTo: (name: string) => void; + browseUp: () => void | Promise; + browseTo: (name: string) => void | Promise; }): CommandPaletteGroup[] { const items: CommandPaletteActionItem[] = []; @@ -316,7 +342,7 @@ export function buildBrowseGroups(input: { icon: input.upIcon, keepOpen: true, run: async () => { - input.browseUp(); + await input.browseUp(); }, }); } @@ -330,7 +356,7 @@ export function buildBrowseGroups(input: { icon: input.directoryIcon, keepOpen: true, run: async () => { - input.browseTo(entry.name); + await input.browseTo(entry.name); }, }); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index e55817700e5..9b6bfc0545b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -92,6 +92,8 @@ import { buildProjectActionItems, buildRootGroups, buildThreadActionItems, + canPreloadBrowsePath, + createBrowseNavigationCoordinator, enumerateCommandPaletteItems, type CommandPaletteActionItem, type CommandPaletteSubmenuItem, @@ -488,6 +490,10 @@ function OpenCommandPaletteDialog(props: { const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); + const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { + reportFailure: false, + reportDefect: false, + }); const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); @@ -504,6 +510,13 @@ function OpenCommandPaletteDialog(props: { const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); + const browseNavigationRef = useRef | null>( + null, + ); + if (browseNavigationRef.current === null) { + browseNavigationRef.current = createBrowseNavigationCoordinator(); + } + const browseNavigation = browseNavigationRef.current; const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( null, ); @@ -712,6 +725,11 @@ function OpenCommandPaletteDialog(props: { browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId ? currentProjectCwd : null; + const getBrowseCwdForEnvironment = useCallback( + (environmentId: EnvironmentId | null): string | null => + environmentId && currentProjectEnvironmentId === environmentId ? currentProjectCwd : null, + [currentProjectCwd, currentProjectEnvironmentId], + ); const relativePathNeedsActiveProject = isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; @@ -735,8 +753,42 @@ function OpenCommandPaletteDialog(props: { const isBrowsePending = browseQuery.isPending; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( - () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), - [browseEntries, browseFilterQuery, highlightedItemValue], + () => filterBrowseEntries({ browseEntries, browseFilterQuery }), + [browseEntries, browseFilterQuery], + ); + + const prefetchBrowsePath = useCallback( + async ( + partialPath: string, + environmentId: EnvironmentId | null = browseEnvironmentId, + cwd: string | null = currentProjectCwdForBrowse, + ): Promise => { + if (!environmentId) { + return; + } + const environment = environments.find( + (candidate) => candidate.environmentId === environmentId, + ); + if (!canPreloadBrowsePath(environment?.connection.phase)) { + return; + } + + await loadBrowsePath({ + environmentId, + input: { + partialPath, + ...(cwd ? { cwd } : {}), + }, + }); + }, + [browseEnvironmentId, currentProjectCwdForBrowse, environments, loadBrowsePath], + ); + + useEffect( + () => () => { + browseNavigation.invalidate(); + }, + [browseNavigation], ); const openProjectFromSearch = useMemo( @@ -867,18 +919,22 @@ function OpenCommandPaletteDialog(props: { ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); - function pushPaletteView(view: CommandPaletteView): void { - setViewStack((previousViews) => [ - ...previousViews, - { - addonIcon: view.addonIcon, - groups: view.groups, - ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), - }, - ]); - setHighlightedItemValue(null); - setQuery(view.initialQuery ?? ""); - } + const pushPaletteView = useCallback( + (view: CommandPaletteView): void => { + browseNavigation.invalidate(); + setViewStack((previousViews) => [ + ...previousViews, + { + addonIcon: view.addonIcon, + groups: view.groups, + ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), + }, + ]); + setHighlightedItemValue(null); + setQuery(view.initialQuery ?? ""); + }, + [browseNavigation], + ); function pushView(item: CommandPaletteSubmenuItem): void { pushPaletteView({ @@ -889,6 +945,7 @@ function OpenCommandPaletteDialog(props: { } function popView(): void { + browseNavigation.invalidate(); setAddProjectCloneFlow(null); if (viewStack.length <= 1) { setAddProjectEnvironmentId(null); @@ -899,6 +956,7 @@ function OpenCommandPaletteDialog(props: { } function handleQueryChange(nextQuery: string): void { + browseNavigation.invalidate(); setHighlightedItemValue(null); setQuery(nextQuery); if (nextQuery === "" && currentView?.initialQuery) { @@ -907,16 +965,35 @@ function OpenCommandPaletteDialog(props: { } const startAddProjectBrowse = useCallback( - (environmentId: EnvironmentId): void => { - setAddProjectEnvironmentId(environmentId); - setAddProjectCloneFlow(null); - pushPaletteView({ + async (environmentId: EnvironmentId): Promise => { + const initialQuery = getAddProjectInitialQueryForEnvironment(environmentId); + const initialBrowsePath = getBrowseDirectoryPath(initialQuery); + const browseCwd = getBrowseCwdForEnvironment(environmentId); + const view: CommandPaletteView = { addonIcon: , groups: [], - initialQuery: getAddProjectInitialQueryForEnvironment(environmentId), + initialQuery, + }; + + await browseNavigation.run({ + load: () => + initialBrowsePath.length > 0 + ? prefetchBrowsePath(initialBrowsePath, environmentId, browseCwd) + : Promise.resolve(), + commit: () => { + setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow(null); + pushPaletteView(view); + }, }); }, - [getAddProjectInitialQueryForEnvironment], + [ + browseNavigation, + getAddProjectInitialQueryForEnvironment, + getBrowseCwdForEnvironment, + prefetchBrowsePath, + pushPaletteView, + ], ); const startAddProjectClone = useCallback( @@ -929,7 +1006,7 @@ function OpenCommandPaletteDialog(props: { initialQuery: "", }); }, - [], + [pushPaletteView], ); const openSourceControlSettings = useCallback(() => { @@ -952,7 +1029,7 @@ function OpenCommandPaletteDialog(props: { icon: , keepOpen: true, run: async () => { - startAddProjectBrowse(environmentId); + await startAddProjectBrowse(environmentId); }, }, ]; @@ -1045,7 +1122,12 @@ function OpenCommandPaletteDialog(props: { ), }); }, - [browseEnvironmentId, buildAddProjectSourceGroups, sourceControlDiscovery.data], + [ + browseEnvironmentId, + buildAddProjectSourceGroups, + pushPaletteView, + sourceControlDiscovery.data, + ], ); const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( @@ -1100,6 +1182,7 @@ function OpenCommandPaletteDialog(props: { addProjectEnvironmentGroups, addProjectEnvironmentOptions.length, defaultAddProjectEnvironmentId, + pushPaletteView, startAddProjectSourceSelection, ]); @@ -1116,6 +1199,7 @@ function OpenCommandPaletteDialog(props: { return; } clearOpenIntent(); + browseNavigation.invalidate(); setAddProjectCloneFlow(null); setViewStack([]); setQuery(""); @@ -1141,10 +1225,12 @@ function OpenCommandPaletteDialog(props: { }); }, [ clearOpenIntent, + browseNavigation, currentProjectEnvironmentId, currentProjectId, openIntent, projectThreadItems, + pushPaletteView, ]); const actionItems: Array = []; @@ -1227,7 +1313,7 @@ function OpenCommandPaletteDialog(props: { icon: , keepOpen: true, run: async () => { - startAddProjectBrowse(wslAddProjectEnvironmentOption.environmentId); + await startAddProjectBrowse(wslAddProjectEnvironmentOption.environmentId); }, }); } @@ -1543,23 +1629,36 @@ function OpenCommandPaletteDialog(props: { await handleAddProject(cloneResult.value.cwd); } - function browseTo(name: string): void { - const nextQuery = appendBrowsePathSegment(query, name); - setHighlightedItemValue(null); - setQuery(nextQuery); - setBrowseGeneration((generation) => generation + 1); - } + const browseTo = useCallback( + async (name: string): Promise => { + const nextQuery = appendBrowsePathSegment(query, name); + await browseNavigation.run({ + load: () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)), + commit: () => { + setHighlightedItemValue(null); + setQuery(nextQuery); + setBrowseGeneration((generation) => generation + 1); + }, + }); + }, + [browseNavigation, prefetchBrowsePath, query], + ); - function browseUp(): void { + const browseUp = useCallback(async (): Promise => { const parentPath = getBrowseParentPath(query); if (parentPath === null) { return; } - setHighlightedItemValue(null); - setQuery(parentPath); - setBrowseGeneration((generation) => generation + 1); - } + await browseNavigation.run({ + load: () => prefetchBrowsePath(parentPath), + commit: () => { + setHighlightedItemValue(null); + setQuery(parentPath); + setBrowseGeneration((generation) => generation + 1); + }, + }); + }, [browseNavigation, prefetchBrowsePath, query]); // Resolve the add-project path from browse data when available. When the // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index d4d31af3682..532d546df9a 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -41,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {props.groups.map((group) => ( - {group.label} + {group.label} {(item) => item.disabled ? ( @@ -133,13 +133,13 @@ function CommandPaletteResultRow(props: { )} {props.item.titleTrailingContent} {props.item.timestamp ? ( - + {props.item.timestamp} ) : null} {shortcutLabel ? {shortcutLabel} : null} {props.item.kind === "submenu" ? ( - + ) : null} ); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 3a30fbb2c7e..d10cb39f0e3 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -30,6 +30,7 @@ import { useTheme } from "../hooks/useTheme"; import { buildFileDiffRenderKey, getDiffCollapseIconClassName, + getDiffLineStat, getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, @@ -41,6 +42,7 @@ import { resolveThreadRouteRef } from "../threadRoutes"; import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; +import { DiffStatLabel } from "./chat/DiffStatLabel"; import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; import { Button } from "./ui/button"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; @@ -452,6 +454,7 @@ export default function DiffPanel({ ); const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]); const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); + const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]); useEffect(() => { if (!selectedFilePath) return; @@ -713,6 +716,14 @@ export default function DiffPanel({ )}
+ {codeViewFiles.length > 0 && ( + + )} {codeViewFiles.length > 0 && (
diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 11716e935ae..4f2bd19952d 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -986,7 +986,6 @@ export default function GitActionsControl({ () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), [activeThreadRef], ); - const activeServerThread = useThread(activeThreadRef); const activeDraftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) @@ -994,6 +993,9 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); + const activeServerThread = useThread(activeThreadRef, { + waitForShell: activeDraftThread !== null, + }); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index bc3e8ee832f..201241731fa 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -4,6 +4,7 @@ import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; +import { cn } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Set(); @@ -40,7 +41,7 @@ function ProjectFaviconFallback({ readonly className?: string | undefined; readonly icon: ComponentType<{ className?: string }>; }) { - return ; + return ; } function ProjectFaviconImage({ @@ -64,7 +65,11 @@ function ProjectFaviconImage({ { loadedProjectFaviconSrcs.add(src); setStatus("loaded"); diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 364c60e051d..e80a7381cea 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -138,6 +138,10 @@ export default function ProjectScriptsControl({ }: ProjectScriptsControlProps) { const addScriptFormId = React.useId(); const [editingScriptId, setEditingScriptId] = useState(null); + const [actionsMenuOpen, setActionsMenuOpen] = useState({ + scripts: false, + imports: false, + }); const [dialogOpen, setDialogOpen] = useState(false); const [name, setName] = useState(""); const [command, setCommand] = useState(""); @@ -255,6 +259,7 @@ export default function ProjectScriptsControl({ }; const openEditDialog = (script: ProjectScript) => { + setActionsMenuOpen({ scripts: false, imports: false }); setEditingScriptId(script.id); setName(script.name); setCommand(script.command); @@ -353,7 +358,11 @@ export default function ProjectScriptsControl({ Run {primaryScript.name} - + setActionsMenuOpen({ scripts: open, imports: false })} + > } > @@ -412,7 +421,11 @@ export default function ProjectScriptsControl({ ) : importableScripts.length > 0 ? ( - + setActionsMenuOpen({ scripts: false, imports: open })} + > }> diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index a7f7a2c1e69..64399679acf 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -68,6 +68,10 @@ function updateProviderUpdateToast(input: { title: input.view.title, description: input.view.description, timeout: 0, + // Base UI merges toast updates with the existing toast. Explicitly clear + // the prompt action so its guarded Update handler cannot linger as a + // visible no-op while the update is running (or after it succeeds). + actionProps: undefined, data: { hideCopyButton: true, ...(input.view.dismissAfterVisibleMs !== undefined diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 83524009c19..fe652b6fde7 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -358,6 +358,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
- - - - - } - > - - Search - {commandPaletteShortcutLabel ? ( - - {commandPaletteShortcutLabel} - - ) : null} - - - - + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + } + > {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( diff --git a/apps/web/src/components/SidebarStageBackdrop.test.tsx b/apps/web/src/components/SidebarStageBackdrop.test.tsx index 41aee21e327..114fd5f9241 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 ba3de64de15..9fb448e940d 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/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f87e7fbe539..33c2512abf7 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -32,6 +32,7 @@ import { SearchIcon, ServerIcon, SquarePenIcon, + TerminalIcon, Trash2Icon, Undo2Icon, } from "lucide-react"; @@ -96,7 +97,11 @@ import { threadEnvironment } from "../state/threads"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; -import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { + buildThreadRouteParams, + resolveActiveThreadRouteRef, + resolveThreadRouteTarget, +} from "../threadRoutes"; import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; @@ -120,6 +125,8 @@ import { prStatusIndicator, resolveThreadPr, settledPrHoverColorClass, + terminalStatusFromRunningIds, + type TerminalStatusIndicator, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, @@ -132,6 +139,7 @@ import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; import { primaryServerProvidersAtom } from "../state/server"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { CommandDialogTrigger } from "./ui/command"; import { Button } from "./ui/button"; @@ -209,7 +217,15 @@ function WorkingDuration(props: { startedAt: string | null }) { return () => window.clearInterval(id); }, [startedMs]); if (Number.isNaN(startedMs)) return null; - return {formatWorkingDurationLabel(Date.now() - startedMs)}; + return ( + + {formatWorkingDurationLabel(Date.now() - startedMs)} + + ); +} + +function terminalProcessLabel(count: number): string { + return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } function SidebarV2ThreadTooltip({ @@ -221,6 +237,8 @@ function SidebarV2ThreadTooltip({ modelInstanceId, modelLabel, branchMismatch, + terminalStatus, + terminalProcessCount, }: { thread: SidebarThreadSummary; projectTitle: string | null; @@ -233,43 +251,47 @@ function SidebarV2ThreadTooltip({ threadBranch: string; currentBranch: string; } | null; + terminalStatus: TerminalStatusIndicator | null; + terminalProcessCount: number; }) { return ( -
-
{thread.title}
-
+
+
+ {thread.title} +
+
{projectTitle ? (
-
{projectTitle}
+
{projectTitle}
) : null} {environmentLabel ? (
- -
{environmentLabel}
+ +
{environmentLabel}
) : null} {thread.branch ? (
- -
{thread.branch}
+ +
{thread.branch}
) : null} {branchMismatch ? (
- +
You're currently checked out on another branch.
@@ -280,15 +302,26 @@ function SidebarV2ThreadTooltip({ +
{modelLabel}
+
+ ) : null} + {terminalStatus ? ( +
+ -
{modelLabel}
+
+ {terminalProcessLabel(terminalProcessCount)} +
) : null} {thread.session?.lastError ? (
- -
{thread.session.lastError}
+ +
Error occurred
) : null}
@@ -414,6 +447,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const openPrLink = useOpenPrLink(); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const terminalProcessCount = runningTerminalIds.length; // Same semantics as v1 (never-visited counts as read): flipping the beta // flag must not light up every historical thread as unread. @@ -499,7 +538,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const pr = resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, - hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; @@ -533,6 +571,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} + terminalStatus={terminalStatus} + terminalProcessCount={terminalProcessCount} /> ); @@ -726,6 +766,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { #{pr.number} ) : null; + const terminalStatusIcon = terminalStatus ? ( + + + + ) : null; if (variant === "slim") { return ( @@ -765,6 +815,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { /> {title} + {terminalStatusIcon} {/* The PR badge stays outside the hover-fading slot: it must remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} @@ -812,9 +863,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Un-settle thread" onClick={handleUnsettleClick} - className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" + className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > - + ) : ( ) : null} @@ -947,6 +1005,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : ( )} + {terminalStatusIcon} {prBadge} {diff ? ( @@ -1015,7 +1074,7 @@ export default function SidebarV2() { reportFailure: false, }); const updateSettings = useUpdateClientSettings(); - const { copyToClipboard: copyProjectPath } = useCopyToClipboard<{ path: string }>({ + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ type: "success", @@ -1033,6 +1092,25 @@ export default function SidebarV2() { ); }, }); + const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({ + target: "branch name", + onCopy: ({ branch }) => { + toastManager.add({ + type: "success", + title: "Branch copied", + description: branch, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy branch", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + }); const [projectActionsTarget, setProjectActionsTarget] = useState( null, ); @@ -1053,7 +1131,13 @@ export default function SidebarV2() { strict: false, select: (params) => resolveThreadRouteTarget(params), }); - const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), + [routeDraftThread, routeTarget], + ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const routeTargetRef = useRef(routeTarget); routeTargetRef.current = routeTarget; @@ -1966,6 +2050,10 @@ export default function SidebarV2() { } const thread = threadByKeyRef.current.get(threadKey); if (!thread) return; + const threadWorkspacePath = + thread.worktreePath ?? + projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? + null; // Un-settle works on every settled row: for explicit settles it // clears the override, for auto-settled rows it pins the thread // active until real activity clears the pin. Environments without @@ -2014,6 +2102,8 @@ export default function SidebarV2() { : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy path", icon: "copy" }, + ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []), { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], position, @@ -2066,6 +2156,24 @@ export default function SidebarV2() { case "mark-unread": markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; + case "copy-path": + if (!threadWorkspacePath) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }), + ); + return; + } + copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); + return; + case "copy-branch": + if (thread.branch) { + copyBranchToClipboard(thread.branch, { branch: thread.branch }); + } + return; case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -2103,9 +2211,12 @@ export default function SidebarV2() { attemptUnsettle, attemptUnsnooze, confirmThreadDelete, + copyBranchToClipboard, + copyPathToClipboard, deleteThread, handleMultiSelectContextMenu, markThreadUnread, + projectCwdByKey, serverConfigs, startThreadRename, ], @@ -2211,151 +2322,158 @@ export default function SidebarV2() { return ( <> - - -
-
- - } - > - -
Search
- {commandPaletteShortcutLabel ? ( - - {commandPaletteShortcutLabel} - - ) : null} -
-
-
- - +
+
+ } > - -
-
- - {projectGroups.length > 0 ? ( - -
- - - {scopedProjectGroup ? ( - +
Search
+ {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + +
+
+ + + } + > + + + + {newThreadShortcutLabel + ? `New thread (${newThreadShortcutLabel})` + : "New thread"} + + +
+
+ {projectGroups.length > 0 ? ( +
+ + } > - + {scopedProjectGroup ? ( + + ) : ( - All projects - - {projectGroups.map((project) => { - const scopeKey = project.projectKey; - return ( - - - {project.displayName} - - - ); - })} - - - - - + {project.displayName} + + + ); + })} + + +
+ + + } + > + + - New project - -
+ + New project + +
+ ) : null} - ) : null} - + } + > + + Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more - - ({hiddenSettledCount} settled hidden) - ) : null} @@ -2538,7 +2654,7 @@ export default function SidebarV2() { onClick={openAddProjectCommandPalette} className="inline-flex items-center gap-1.5 rounded-md border border-sidebar-border px-2.5 py-1 text-[11px] font-medium text-sidebar-muted-foreground transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground" > - + Add project @@ -2558,48 +2674,52 @@ export default function SidebarV2() { }} > - + Project settings - - {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 - ? `${projectActionsTarget.displayName} has an entry in each environment. Changes apply only to the entry you choose.` - : `Manage ${projectActionsTarget?.displayName ?? "this project"} in this environment.`} + + Manage project names, grouping rules, and environments. +
+ {projectActionsTarget?.memberProjects.map((member) => ( +
+ + + {member.workspaceRoot} + + + + + + {member.environmentLabel ?? "Current environment"} + + +
+ ))} +
{projectActionsTarget?.memberProjects.map((member) => (
-
- -
-
- -

- {member.environmentLabel ?? "Current environment"} -

-
-

- {member.workspaceRoot} -

-
-
-
+
-
- - -
+ {projectActionsTarget.memberProjects.length > 1 ? ( +
+ +
+ ) : null}
))}
@@ -2718,8 +2828,26 @@ export default function SidebarV2() {
) : null} - - + + {projectActionsTarget?.memberProjects.length === 1 ? ( + + ) : null} + diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 9fb4535f266..3eb8e4f710f 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -36,31 +36,35 @@ describe("resolveThreadPr", () => { resolveThreadPr({ threadBranch: "feature/other", gitStatus: status(), - hasDedicatedWorktree: false, }), ).toBeNull(); }); - it("shows PR indicators for dedicated worktree threads even when branch metadata is stale", () => { - const gitStatus = status(); + it("hides PR indicators when a dedicated worktree has switched away from the thread branch", () => { + expect( + resolveThreadPr({ + threadBranch: "stack/base", + gitStatus: status(), + }), + ).toBeNull(); + }); + it("hides PR indicators when thread branch metadata is missing", () => { expect( resolveThreadPr({ - threadBranch: "feature/old-name", - gitStatus, - hasDedicatedWorktree: true, + threadBranch: null, + gitStatus: status(), }), - ).toBe(gitStatus.pr); + ).toBeNull(); }); - it("shows PR indicators for dedicated worktree threads even when branch metadata is missing", () => { + it("shows the PR when the live checkout matches the stored thread branch", () => { const gitStatus = status(); expect( resolveThreadPr({ - threadBranch: null, + threadBranch: "feature/current", gitStatus, - hasDedicatedWorktree: true, }), ).toBe(gitStatus.pr); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 0d3291a9e28..af53d1a78b2 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -113,17 +113,12 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator } export function resolveThreadPr(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; - hasDedicatedWorktree: boolean; }): ThreadPr | null { - const { threadBranch, gitStatus, hasDedicatedWorktree } = input; + const { threadBranch, gitStatus } = input; if (gitStatus === null) { return null; } - if (hasDedicatedWorktree) { - return gitStatus.pr ?? null; - } - if (threadBranch === null || gitStatus.refName !== threadBranch) { return null; } @@ -258,7 +253,6 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const pr = resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, - hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const threadStatus = resolveThreadStatusPill({ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6eba26ae70b..929d9e9387b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -51,13 +51,27 @@ import { type ComposerImageAttachment, type DraftId, type PersistedComposerImageAttachment, + hydrateImagesFromPersisted, useComposerDraftStore, useComposerThreadDraft, useEffectiveComposerModelState, } from "../../composerDraftStore"; +import { + MAX_STASH_ENTRIES, + partitionStashAttachments, + usePromptStashStore, + type PromptStashEntry, +} from "../../promptStashStore"; +import { ComposerStashBadge } from "./ComposerStashBadge"; +import { ComposerStashMenu } from "./ComposerStashMenu"; +import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression"; +import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { getTerminalFocusOwner } from "../../lib/terminalFocus"; +import { resolveShortcutCommand } from "../../keybindings"; import { type TerminalContextDraft, type TerminalContextSelection, + INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; @@ -79,6 +93,7 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; +import { ComposerControl, ComposerControlIcon, ComposerSelectControl } from "./ComposerControl"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { @@ -151,7 +166,7 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: ); } import { Button } from "../ui/button"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Select, SelectItem, SelectPopup, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, ListTodoIcon, PencilRulerIcon, XIcon } from "lucide-react"; @@ -181,8 +196,6 @@ import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; -const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; - const COMPOSER_FLOATING_LAYER_SELECTOR = [ '[data-slot="popover-popup"]', '[data-slot="menu-popup"]', @@ -253,15 +266,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {props.interactionMode === "plan" ? ( - + ) : ( - + )} {props.interactionMode === "plan" ? "Plan" : "Build"} @@ -292,16 +303,9 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onValueChange={(value) => props.onRuntimeModeChange(value!)} > - } + render={} > - + {runtimeModeOption.label} @@ -337,22 +341,21 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop } > - {props.planSidebarLabel} @@ -381,6 +384,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( showPlanFollowUpPrompt: boolean; promptHasText: boolean; isSendBusy: boolean; + sendDisabledReason: string | null; isConnecting: boolean; isEnvironmentUnavailable: boolean; hasSendableContent: boolean; @@ -407,6 +411,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} promptHasText={props.promptHasText} isSendBusy={props.isSendBusy} + sendDisabledReason={props.sendDisabledReason} isConnecting={props.isConnecting} isEnvironmentUnavailable={props.isEnvironmentUnavailable} isPreparingWorktree={props.isPreparingWorktree} @@ -487,6 +492,7 @@ export interface ChatComposerProps { phase: SessionPhase; isConnecting: boolean; isSendBusy: boolean; + sendDisabledReason: string | null; isPreparingWorktree: boolean; environmentUnavailable: { readonly label: string; @@ -598,6 +604,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) phase, isConnecting, isSendBusy, + sendDisabledReason, isPreparingWorktree, environmentUnavailable, activePendingApproval, @@ -651,6 +658,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setThreadError, onExpandImage, } = props; + const isSendDisabled = sendDisabledReason !== null; // ------------------------------------------------------------------ // Store subscriptions (prompt / images / terminal contexts) @@ -689,6 +697,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); + const clearComposerDraftPromptAndImages = useComposerDraftStore( + (store) => store.clearComposerPromptAndImages, + ); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); @@ -927,6 +938,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); + const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); + const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ + key: 0, + active: false, + }); const isMobileViewport = useMediaQuery("max-sm"); const isComposerCollapsedMobile = isMobileViewport && !forceExpandedOnMobile && !isComposerFocused; @@ -946,6 +962,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); const dragDepthRef = useRef(0); + const stashPulseKeyRef = useRef(0); + const stashPulseTimeoutRef = useRef(null); + /** + * Snapshots currently being encoded, keyed by target+prompt+image ids. + * Keyed rather than boolean so a genuinely different prompt (or a different + * thread) can still be stashed while an earlier encode is running. + */ + const stashInFlightRef = useRef>(new Set()); + /** + * Count of pasted images still being compressed, per thread. Reserved + * against the attachment limit so concurrent pastes can't overshoot it, + * and checked by `submitComposer` so a send can't race an image into the + * next draft. + */ + const pendingImageCompressionsRef = useRef>(new Map()); // ------------------------------------------------------------------ // Derived: composer send state @@ -1184,6 +1215,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const collapsedComposerPrimaryActionDisabled = phase === "running" || isSendBusy || + isSendDisabled || isConnecting || noProviderAvailable || projectSelectionRequired || @@ -1728,6 +1760,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!isMobileViewport) return false; if ( isSendBusy || + isSendDisabled || isConnecting || noProviderAvailable || environmentUnavailable !== null || @@ -1747,6 +1780,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting, isMobileViewport, isSendBusy, + isSendDisabled, noProviderAvailable, phase, showPlanFollowUpPrompt, @@ -1754,16 +1788,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const submitComposer = useCallback( (event?: { preventDefault: () => void }) => { - if (noProviderAvailable) { + if (noProviderAvailable || isSendDisabled) { event?.preventDefault(); return; } + // A send while a pasted image is still compressing would strand that + // image: the turn snapshot wouldn't include it, and it would surface + // in the *next* draft instead. Only oversized images hit this — small + // files clear the pending counter within a microtask. + if (activeThreadId && (pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) { + event?.preventDefault(); + toastManager.add({ + type: "info", + title: "Still compressing a pasted image.", + description: "Send again once its thumbnail appears.", + }); + return; + } onSend(event); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, - [blurMobileComposerAfterSend, noProviderAvailable, onSend, shouldBlurMobileComposerOnSubmit], + [ + activeThreadId, + blurMobileComposerAfterSend, + isSendDisabled, + noProviderAvailable, + onSend, + shouldBlurMobileComposerOnSubmit, + ], ); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { @@ -1827,10 +1881,384 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return false; }; + // ------------------------------------------------------------------ + // Prompt stash (⌘S) + // ------------------------------------------------------------------ + // One global queue. Stashed prompts carry only text + images so they can be + // restored into any thread or provider — stash, switch, restore is the + // whole point. + const stashQueue = usePromptStashStore((state) => state.entries); + const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); + const takeStashEntry = usePromptStashStore((state) => state.takeEntry); + const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages); + + useEffect(() => { + return () => { + if (stashPulseTimeoutRef.current !== null) { + window.clearTimeout(stashPulseTimeoutRef.current); + } + }; + }, []); + + /** Briefly highlight the badge so the save registers without a flourish. */ + const pulseStashBadge = useCallback(() => { + stashPulseKeyRef.current += 1; + setStashPulse({ key: stashPulseKeyRef.current, active: true }); + if (stashPulseTimeoutRef.current !== null) { + window.clearTimeout(stashPulseTimeoutRef.current); + } + stashPulseTimeoutRef.current = window.setTimeout(() => { + stashPulseTimeoutRef.current = null; + setStashPulse((current) => ({ ...current, active: false })); + }, 1200); + }, []); + + const restoreStashEntry = useCallback( + (entry: PromptStashEntry) => { + // Remove first so a double activation (click + Enter) can't restore twice. + const { entry: taken, durable } = takeStashEntry(entry.id); + if (!taken) return; + if (!durable) { + toastManager.add({ + type: "warning", + title: "Restored prompt may reappear in the stash", + description: + "Browser storage rejected the update, so this entry could still be there after a reload.", + data: { hideCopyButton: true }, + }); + } + setIsStashMenuOpen(false); + + const currentPrompt = promptRef.current; + // An image-only stash must not append blank lines to whatever is + // already in the composer. + const nextPrompt = + entry.prompt.length === 0 + ? currentPrompt + : currentPrompt.trim().length + ? `${currentPrompt.replace(/\s+$/, "")}\n\n${entry.prompt}` + : entry.prompt; + const promptChanged = nextPrompt !== currentPrompt; + if (promptChanged) { + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + } + + let unrestoredImageNames: string[] = []; + if (entry.attachments.length > 0) { + const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); + // The draft store also dedupes by mimeType+sizeBytes+name, so filter + // on the same key here. Counting a duplicate against capacity would + // burn a slot the store then refuses to fill, pushing a genuinely + // unique image into the overflow list for nothing. + const existingDedupKeys = new Set( + composerImagesRef.current.map( + (image) => `${image.mimeType}${image.sizeBytes}${image.name}`, + ), + ); + const capacity = Math.max( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - composerImagesRef.current.length, + ); + const pending = entry.attachments.filter( + (attachment) => + !existingIds.has(attachment.id) && + !existingDedupKeys.has( + `${attachment.mimeType}${attachment.sizeBytes}${attachment.name}`, + ), + ); + // Anything past the attachment limit cannot be restored. The entry is + // already out of the queue, so report the overflow by name instead of + // discarding it silently. + unrestoredImageNames = pending.slice(capacity).map((attachment) => attachment.name); + const restoredImages = hydrateImagesFromPersisted(pending.slice(0, capacity)); + if (restoredImages.length > 0) { + addComposerDraftImages(composerDraftTarget, restoredImages); + } + } + + // Deliberately no model/provider restore: the stash exists to carry a + // prompt across threads and providers, so whatever the composer has + // selected right now stays selected. + + // Each cause gets its own sentence so "too large" is never blamed for a + // file that actually failed to decode, or for one the composer simply + // had no room to take back. + const missingImageReasons: string[] = []; + if (entry.droppedImageNames.length > 0) { + missingImageReasons.push( + `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`, + ); + } + if (entry.unreadableImageNames && entry.unreadableImageNames.length > 0) { + missingImageReasons.push( + `${entry.unreadableImageNames.join(", ")} could not be read when this prompt was saved.`, + ); + } + if (unrestoredImageNames.length > 0) { + missingImageReasons.push( + `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit.`, + ); + } + if (missingImageReasons.length > 0) { + toastManager.add({ + type: "warning", + title: "Some images were not restored", + description: missingImageReasons.join(" "), + }); + } + + // Only yank the caret to the end when text was actually inserted; + // restoring images alone should leave the user where they were typing. + if (promptChanged) { + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAtEnd(); + }); + } + }, + [ + addComposerDraftImages, + composerDraftTarget, + composerImagesRef, + promptRef, + setComposerDraftPrompt, + takeStashEntry, + ], + ); + + const deleteStashEntry = useCallback( + (entry: PromptStashEntry) => { + const { durable } = takeStashEntry(entry.id); + if (!durable) { + toastManager.add({ + type: "warning", + title: "Stash entry may come back", + description: + "Browser storage rejected the delete, so this prompt could reappear after a reload.", + data: { hideCopyButton: true }, + }); + } + }, + [takeStashEntry], + ); + + const stashCurrentPrompt = useCallback(async () => { + // Terminal-context placeholders reference live sessions the stash can't + // round-trip, so they are stripped from the stashed prompt. + const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim(); + const images = [...composerImagesRef.current]; + if (prompt.length === 0 && images.length === 0) { + setIsStashMenuOpen((open) => !open); + return; + } + // A repeat ⌘S on the *same* still-unencoded snapshot would stash it + // twice. Guard on the snapshot itself rather than a bare boolean: once + // the composer has been cleared the user can type something genuinely + // new (or switch threads) while encoding continues, and that deserves its + // own entry. + const snapshotKey = `${String(composerDraftTarget)}${prompt}${images + .map((image) => image.id) + .join(",")}`; + if (stashInFlightRef.current.has(snapshotKey)) return; + stashInFlightRef.current.add(snapshotKey); + + const stashTarget = composerDraftTarget; + const entryId = randomUUID(); + try { + // Persist the text-only entry *first*, then clear. Ordering matters in + // both directions: writing before clearing means a crash or closed tab + // mid-encode still leaves the prompt recoverable, while clearing before + // the async image work means edits typed during encoding are not wiped. + // Images are appended to the stored entry as they finish encoding. + const { evicted, written, durable } = stashEntryToQueue({ + id: entryId, + createdAt: new Date().toISOString(), + prompt, + attachments: [], + droppedImageNames: [], + unreadableImageNames: [], + pendingImageCount: images.length, + }); + + // Clearing the composer is only safe once the write actually landed. + // If it was rejected (quota) the store has already rolled itself back, + // so leave the composer untouched rather than making it the second + // casualty of a reload. + if (!written) { + toastManager.add({ + type: "error", + title: "Could not stash this prompt", + description: + "Browser storage rejected the write, so the composer was left as-is. Free up site data and try again.", + data: { hideCopyButton: true }, + }); + return; + } + // Written but only into the in-memory fallback (localStorage blocked): + // the entry is visible and restorable this session, so proceed with the + // clear, but say it won't survive a reload. + if (!durable) { + toastManager.add({ + type: "warning", + title: "Stashed prompt will not survive a reload", + description: + "Browser storage is unavailable, so this stash is kept in memory only for this session.", + data: { hideCopyButton: true }, + }); + } + + // Only the prompt and images are cleared — terminal/element contexts, + // preview annotations, and review comments are not stashable, so + // destroying them here would be unrecoverable. + promptRef.current = ""; + clearComposerDraftPromptAndImages(stashTarget); + setComposerCursor(0); + setComposerTrigger(null); + pulseStashBadge(); + + if (evicted) { + toastManager.add({ + type: "warning", + title: "Oldest stashed prompt discarded", + description: `The stash holds ${MAX_STASH_ENTRIES} prompts; the oldest was removed to make room.`, + data: { hideCopyButton: true }, + }); + } + + // Images are re-encoded for the stash rather than stored verbatim: the + // composer allows up to 10MB per image, but localStorage gives the whole + // origin ~5MB. Only the stashed copy shrinks; the live attachment (and + // anything sent without stashing) keeps the original file. + const candidateAttachments: PersistedComposerImageAttachment[] = []; + const oversizedImageNames: string[] = []; + const unreadableImageNames: string[] = []; + for (const image of images) { + const result = await compressImageForStash(image.file); + if (!result.ok) { + // "too large" and "could not be read" are distinct outcomes; the + // menu and restore toast report them separately. + (result.reason === "too-large" ? oversizedImageNames : unreadableImageNames).push( + image.name, + ); + continue; + } + candidateAttachments.push({ + id: image.id, + name: image.name, + mimeType: result.image.mimeType, + sizeBytes: result.image.sizeBytes, + dataUrl: result.image.dataUrl, + }); + } + const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); + + const { attached, durable: imagesDurable } = finalizeStashEntryImages(entryId, { + attachments: kept, + droppedImageNames: [...oversizedImageNames, ...droppedNames], + unreadableImageNames, + }); + if (attached) { + // The second phase can be rejected on its own: the text-only entry + // fit, but adding image payloads pushed past the quota. Disk would + // then still hold the phase-one entry with pendingImageCount set, + // which reads as an orphan after reload — so say so now. Gated on the + // entry write having been durable: on the in-memory fallback nothing + // is ever durable, and the session-only warning already covered it. + if (!imagesDurable && durable && images.length > 0) { + toastManager.add({ + type: "warning", + title: "Stashed images were not saved", + description: + "The prompt was stashed, but browser storage rejected its images. They will be missing if you reload.", + data: { hideCopyButton: true }, + }); + } + } else if (kept.length > 0) { + // The entry was restored or deleted before its images finished + // encoding, so they have nowhere to land. Say so rather than letting + // them evaporate. + toastManager.add({ + type: "warning", + title: "Stashed images did not attach", + description: `That prompt was restored or deleted before ${kept.length} image${kept.length === 1 ? "" : "s"} finished saving. Re-attach ${kept.length === 1 ? "it" : "them"} if you still need ${kept.length === 1 ? "it" : "them"}.`, + data: { hideCopyButton: true }, + }); + } + } finally { + // Must clear on every path: a throw that left this set would wedge this + // snapshot's ⌘S until the composer remounts. + stashInFlightRef.current.delete(snapshotKey); + } + }, [ + clearComposerDraftPromptAndImages, + composerDraftTarget, + composerImagesRef, + finalizeStashEntryImages, + promptRef, + pulseStashBadge, + stashEntryToQueue, + ]); + + const toggleStashMenu = useCallback(() => { + setIsStashMenuOpen((open) => !open); + }, []); + + // Close the stash menu whenever the trigger-driven command menu opens so + // the two popovers never stack in the same layer, and when the user + // resumes typing (the menu is a transient picker, not a panel). + useEffect(() => { + if (composerMenuOpen) { + setIsStashMenuOpen(false); + } + }, [composerMenuOpen]); + useEffect(() => { + setIsStashMenuOpen(false); + }, [prompt]); + + useEffect(() => { + const handler = (event: globalThis.KeyboardEvent) => { + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: getTerminalFocusOwner() !== null, + terminalOpen, + modelPickerOpen: isComposerModelPickerOpen, + }, + }); + if (command !== "composer.stash") return; + // Always claim the shortcut so the browser save dialog never opens, + // even when the composer is in a state that can't stash. + event.preventDefault(); + event.stopPropagation(); + if ( + isCommandPaletteOpen() || + isComposerApprovalState || + pendingUserInputs.length > 0 || + projectSelectionRequired || + activePendingProgress !== null + ) { + return; + } + void stashCurrentPrompt(); + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [ + activePendingProgress, + isComposerApprovalState, + isComposerModelPickerOpen, + keybindings, + pendingUserInputs.length, + projectSelectionRequired, + stashCurrentPrompt, + terminalOpen, + ]); + // ------------------------------------------------------------------ // Callbacks: images // ------------------------------------------------------------------ - const addComposerImages = (files: File[]) => { + const addComposerImages = async (files: File[]) => { if (!activeThreadId || files.length === 0) return; if (pendingUserInputs.length > 0) { toastManager.add({ @@ -1839,40 +2267,81 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return; } - const nextImages: ComposerImageAttachment[] = []; - let nextImageCount = composerImagesRef.current.length; + // Captured before the awaits below: the user may switch threads while a + // large image is being compressed, and the attachments and errors belong + // to the thread the paste happened in. + const threadId = activeThreadId; + + // Validation happens synchronously so concurrent pastes see each other: + // accepted files reserve their attachment slots (via the pending counter) + // before the first await, keeping the total under the limit. + const pendingCount = pendingImageCompressionsRef.current.get(threadId) ?? 0; + let reservedCount = composerImagesRef.current.length + pendingCount; + const acceptedFiles: File[] = []; let error: string | null = null; for (const file of files) { if (!file.type.startsWith("image/")) { error = `Unsupported file type for '${file.name}'. Please attach image files only.`; continue; } - if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; - continue; - } - if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; break; } - const previewUrl = URL.createObjectURL(file); - nextImages.push({ - type: "image", - id: randomUUID(), - name: file.name || "image", - mimeType: file.type, - sizeBytes: file.size, - previewUrl, - file, - }); - nextImageCount += 1; + acceptedFiles.push(file); + reservedCount += 1; } - if (nextImages.length === 1 && nextImages[0]) { - addComposerImage(nextImages[0]); - } else if (nextImages.length > 1) { - addComposerImagesToDraft(nextImages); + setThreadError(threadId, error); + if (acceptedFiles.length === 0) return; + + pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedFiles.length); + try { + const nextImages: ComposerImageAttachment[] = []; + let compressionError: string | null = null; + for (const file of acceptedFiles) { + // Images over the wire cap are downscaled to fit rather than + // refused; files already within it pass through byte-for-byte. + const compressed = await compressImageToByteLimit(file, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES); + if (!compressed.ok) { + compressionError = + compressed.reason === "unreadable" + ? `'${file.name}' could not be read as an image.` + : `'${file.name}' is too large to attach, even after compression.`; + continue; + } + const attachmentFile = compressed.file; + const previewUrl = URL.createObjectURL(attachmentFile); + nextImages.push({ + type: "image", + id: randomUUID(), + name: attachmentFile.name || "image", + mimeType: attachmentFile.type, + sizeBytes: attachmentFile.size, + previewUrl, + file: attachmentFile, + }); + } + if (nextImages.length === 1 && nextImages[0]) { + addComposerImage(nextImages[0]); + } else if (nextImages.length > 1) { + addComposerImagesToDraft(nextImages); + } + // Only failures are reported here. Success must not pass `null`: by + // now other work (a failed send, an overlapping paste) may have set a + // thread error this call knows nothing about, and clearing it would + // swallow that message. + if (compressionError !== null) { + setThreadError(threadId, compressionError); + } + } finally { + const remaining = + (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedFiles.length; + if (remaining > 0) { + pendingImageCompressionsRef.current.set(threadId, remaining); + } else { + pendingImageCompressionsRef.current.delete(threadId); + } } - setThreadError(activeThreadId, error); }; const removeComposerImage = (imageId: string) => { @@ -1888,7 +2357,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (imageFiles.length === 0) return; event.preventDefault(); - addComposerImages(imageFiles); + void addComposerImages(imageFiles); }; const onComposerDragEnter = (event: React.DragEvent) => { @@ -1922,7 +2391,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) dragDepthRef.current = 0; setIsDragOverComposer(false); const files = Array.from(event.dataTransfer.files); - addComposerImages(files); + void addComposerImages(files); focusComposer(); }; @@ -2297,6 +2766,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanFollowUpPrompt={false} promptHasText={false} isSendBusy={isSendBusy} + sendDisabledReason={sendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || @@ -2368,6 +2838,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerCollapsedMobile && "hidden", )} > + + + {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( + + setIsStashMenuOpen(false)} + /> + + )} + {composerMenuOpen && !isComposerApprovalState && ( 0} isSendBusy={isSendBusy} + sendDisabledReason={sendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 3a7d57859a8..0adeed6ffa6 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -34,6 +34,7 @@ interface ChatHeaderProps { availableEditors: ReadonlyArray; rightPanelOpen: boolean; gitCwd: string | null; + onNewThreadInProject: () => void; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: ( @@ -69,6 +70,7 @@ export const ChatHeader = memo(function ChatHeader({ availableEditors, rightPanelOpen, gitCwd, + onNewThreadInProject, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, @@ -92,16 +94,26 @@ export const ChatHeader = memo(function ChatHeader({ doesn't answer it. */} {activeProjectName ? ( - - - - {activeProjectName} - - + + + } + > + + {activeProjectName} + + New thread in {activeProjectName} + / diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 3f8f56f041a..699c9cfd9c4 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -93,7 +93,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro {showCollapsedStackCap ? (
{item.icon} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 9021b6b4609..73fc6348905 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -139,7 +139,10 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ); }} > -
+
{props.items.length > 0 ? ( {groups.map((group, groupIndex) => ( diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx new file mode 100644 index 00000000000..8eab75171c8 --- /dev/null +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -0,0 +1,71 @@ +import type { ComponentProps } from "react"; +import { ChevronDownIcon, type LucideIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { SelectTrigger } from "../ui/select"; + +const composerControlClassName = + "h-7 min-h-7 gap-1.5 px-2.5 text-muted-foreground/70 transition-none hover:text-foreground/80 [&_svg[data-composer-control-icon]]:mx-0 [&_svg[data-composer-control-chevron]]:-mx-0.5"; + +export function ComposerControl({ + className, + size = "sm", + variant = "ghost", + ...props +}: ComponentProps) { + return ( + @@ -163,7 +170,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ size="sm" className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" {...pointerFocusProps} - disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} + disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} > {isConnecting || isSendBusy ? "Sending..." : "Implement"} @@ -176,7 +183,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" aria-label="Implementation actions" {...pointerFocusProps} - disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} + disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} /> } > @@ -184,7 +191,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ void onImplementPlanInNewThread()} > Implement in a new thread @@ -205,17 +212,25 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ : "bg-primary/90 enabled:shadow-primary/24 hover:bg-primary", )} {...pointerFocusProps} - disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent} + disabled={ + isSendBusy || + isSendDisabled || + isConnecting || + isEnvironmentUnavailable || + !hasSendableContent + } aria-label={ isEnvironmentUnavailable ? "Environment disconnected" - : isConnecting - ? "Connecting" - : isPreparingWorktree - ? "Preparing worktree" - : isSendBusy - ? "Sending" - : "Send message" + : sendDisabledReason + ? sendDisabledReason + : isConnecting + ? "Connecting" + : isPreparingWorktree + ? "Preparing worktree" + : isSendBusy + ? "Sending" + : "Send message" } > {stageBackdropVariant ? ( diff --git a/apps/web/src/components/chat/ComposerStashBadge.tsx b/apps/web/src/components/chat/ComposerStashBadge.tsx new file mode 100644 index 00000000000..79ed301a5d5 --- /dev/null +++ b/apps/web/src/components/chat/ComposerStashBadge.tsx @@ -0,0 +1,57 @@ +import { BookmarkIcon } from "lucide-react"; +import { memo } from "react"; + +import { cn } from "~/lib/utils"; + +/** + * Bookmark pill perched on the composer's top-right shoulder. Shows the + * stash count and doubles as the click target for opening the stash menu. + * + * On save the badge gives one quiet acknowledgement: it lifts to full + * opacity and the count ticks over. `pulseKey` changes per stash, remounting + * the count so the transition replays without a continuous animation. + */ +export const ComposerStashBadge = memo(function ComposerStashBadge(props: { + count: number; + pulseKey: number; + pulsing: boolean; + menuOpen: boolean; + onToggleMenu: () => void; +}) { + if (props.count === 0) return null; + + return ( + + ); +}); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx new file mode 100644 index 00000000000..bfc2d88ba76 --- /dev/null +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -0,0 +1,176 @@ +import { BookmarkIcon, XIcon } from "lucide-react"; +import { memo, useEffect, useState } from "react"; + +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { cn } from "~/lib/utils"; +import { type PromptStashEntry } from "../../promptStashStore"; +import { Command, CommandGroup, CommandGroupLabel, CommandItem, CommandList } from "../ui/command"; +import { Button } from "../ui/button"; + +const SNIPPET_MAX_CHARS = 90; + +/** Images that did not make it into the entry, whatever the reason. */ +function missingImageCount(entry: PromptStashEntry): number { + return entry.droppedImageNames.length + (entry.unreadableImageNames?.length ?? 0); +} + +function stashEntrySnippet(entry: PromptStashEntry): string { + const trimmed = entry.prompt.trim().replace(/\s+/g, " "); + if (trimmed.length > 0) { + return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed; + } + const imageCount = entry.attachments.length + entry.droppedImageNames.length; + return imageCount > 0 ? `(${imageCount} image${imageCount === 1 ? "" : "s"})` : "(empty)"; +} + +/** + * Popover listing the stashed prompts. Keyboard-first: opened by ⌘S on an + * empty composer, navigated with arrows, restored with Enter, dismissed + * with Escape. The listener runs capture-phase on window so it wins over + * the Lexical editor's handlers while the menu is open. + */ +export const ComposerStashMenu = memo(function ComposerStashMenu(props: { + entries: ReadonlyArray; + onRestore: (entry: PromptStashEntry) => void; + onDelete: (entry: PromptStashEntry) => void; + onClose: () => void; +}) { + const { entries, onRestore, onDelete, onClose } = props; + const [highlightedId, setHighlightedId] = useState(entries[0]?.id ?? null); + + const highlightedEntry = entries.find((entry) => entry.id === highlightedId) ?? entries[0]; + + useEffect(() => { + if (entries.length === 0) return; + if (!entries.some((entry) => entry.id === highlightedId)) { + setHighlightedId(entries[0]?.id ?? null); + } + }, [entries, highlightedId]); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + if (entries.length === 0) return; + event.preventDefault(); + event.stopPropagation(); + const currentIndex = entries.findIndex((entry) => entry.id === highlightedId); + const offset = event.key === "ArrowDown" ? 1 : -1; + const normalizedIndex = currentIndex >= 0 ? currentIndex : offset === 1 ? -1 : 0; + const nextIndex = (normalizedIndex + offset + entries.length) % entries.length; + setHighlightedId(entries[nextIndex]?.id ?? null); + return; + } + if (event.key === "Enter") { + // A focused control inside the row (the delete button) owns its own + // activation; swallowing Enter here would restore instead of delete. + if (event.target instanceof HTMLElement && event.target.closest("button[aria-label]")) { + return; + } + if (!highlightedEntry) return; + event.preventDefault(); + event.stopPropagation(); + onRestore(highlightedEntry); + return; + } + if (event.key === "Backspace" && (event.metaKey || event.ctrlKey)) { + if (!highlightedEntry) return; + event.preventDefault(); + event.stopPropagation(); + onDelete(highlightedEntry); + } + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [entries, highlightedEntry, highlightedId, onClose, onDelete, onRestore]); + + return ( + +
+ + + + + {entries.length === 0 ? ( +

+ Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it. +

+ ) : ( + entries.map((entry) => ( + { + if (highlightedId !== entry.id) setHighlightedId(entry.id); + }} + onMouseDown={(event) => { + event.preventDefault(); + }} + onClick={() => { + onRestore(entry); + }} + > + {entry.attachments.length > 0 ? ( + + {entry.attachments.slice(0, 3).map((attachment) => ( + + ))} + + ) : ( + + )} + + {stashEntrySnippet(entry)} + + {entry.pendingImageCount ? ( + + saving {entry.pendingImageCount} image + {entry.pendingImageCount === 1 ? "" : "s"}… + + ) : missingImageCount(entry) > 0 ? ( + + {missingImageCount(entry)} image + {missingImageCount(entry) === 1 ? "" : "s"} dropped + + ) : null} + + {formatRelativeTimeLabel(entry.createdAt)} + + + + )) + )} +
+
+
+
+ ); +}); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 98091f9aab6..4407e621525 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -101,11 +101,11 @@ export function DraftHeroHeadline({ {activeProjectDisplayName ?? "Choose a project"} - + { @@ -138,7 +138,7 @@ export function DraftHeroHeadline({ diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 2ec4be70994..e86435561a2 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -50,7 +50,7 @@ export const ModelListRow = memo(function ModelListRow(props: { disabled={Boolean(props.disabledReason)} contentClassName="flex w-full items-center gap-3" className={cn( - "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2.5 transition-[background-color,box-shadow,color]", + "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2 transition-[background-color,box-shadow,color]", "hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-highlighted:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-selected:bg-foreground/[0.08] data-selected:text-foreground data-selected:ring-0 [&[data-highlighted][data-selected]]:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))]", props.disabledReason && "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent", diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index bbdbd8bd9d9..d317ee6061c 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -43,6 +43,10 @@ type ModelPickerItem = { const EMPTY_MODEL_JUMP_LABELS = new Map(); +function ModelListSeparator() { + return
; +} + // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs // can contain colons (e.g. some vendor model ids), so we only split on the // first colon — anything after that is the slug. @@ -521,7 +525,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
{/* Sidebar */} @@ -571,12 +575,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
{/* Search bar */} -
-
+
+
+ } value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} @@ -618,8 +622,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
{/* Model list */} -
- +
+ ref={modelListRef} data={filteredModelKeys} @@ -656,12 +660,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { estimatedItemSize={60} drawDistance={480} recycleItems + contentContainerClassName="pl-2 pr-px" + ItemSeparatorComponent={ModelListSeparator} onLayout={updateModelListScrollFades} onScroll={updateModelListScrollFades} className={cn( - "scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", - showTopScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", - showBottomScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", + "model-picker-list h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", + showTopScrollFade && "model-picker-list-scroll-fade-top", + showBottomScrollFade && "model-picker-list-scroll-fade-bottom", )} /> diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 36a608888b2..24ec66cd614 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -78,34 +78,20 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { if (!content) { return; } - const selectedButton = Array.from( + const selectedItem = Array.from( content.querySelectorAll("[data-model-picker-provider]"), - ).find((button) => button.dataset.modelPickerProvider === props.selectedInstanceId); - if (!selectedButton) { + ).find((item) => item.dataset.modelPickerProvider === props.selectedInstanceId); + if (!selectedItem) { setSelectedIndicatorTop(null); return; } - const contentRect = content.getBoundingClientRect(); - const selectedButtonRect = selectedButton.getBoundingClientRect(); - setSelectedIndicatorTop( - selectedButtonRect.top - - contentRect.top + - content.scrollTop + - selectedButtonRect.height / 2 - - 10, - ); + setSelectedIndicatorTop(selectedItem.offsetTop + selectedItem.offsetHeight / 2 - 10); }, [props.instanceEntries, props.selectedInstanceId, showFavorites]); return ( -
+
-
+
{selectedIndicatorTop !== null ? (
-
+ <> +
handleSelect("favorites")} type="button" - data-model-picker-provider="favorites" aria-label="Favorites" > @@ -146,7 +131,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
-
+
+ {versionMismatch ? ( + + ) : null} {isWslEnvironment ? ( = { + 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 }, @@ -72,7 +75,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { return ( <> - + {SETTINGS_NAV_ITEMS.map((item) => { const Icon = item.icon; @@ -80,22 +83,10 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { return ( handleSectionClick(item.to)} > - + {item.label} @@ -106,15 +97,11 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { -
- +
+ - - + + Back diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 8c35278073e..09fcd773128 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 ( - - + + Settings diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index deb2fbfe8f9..53999c20f0d 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -65,7 +65,7 @@ function CommandDialogPopup({ +
} + startAddon={} {...props} />
@@ -198,7 +198,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) { return ( ) { return (
) { +const inputGroupVariants = cva( + "relative inline-flex w-full min-w-0 items-center rounded-lg border text-base text-foreground ring-ring/24 transition-shadow has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--radius-md)-1px)]", + { + defaultVariants: { + variant: "default", + }, + variants: { + variant: { + default: + "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)]", + ghost: + "border-transparent bg-transparent shadow-none hover:bg-muted/40 has-[input:focus-visible,textarea:focus-visible]:bg-background", + }, + }, + }, +); + +function InputGroup({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { return (
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 9c30aa727f7..949beddd05c 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -77,8 +77,10 @@ function SelectTrigger({ size = "default", variant = "default", children, + icon, ...props -}: SelectPrimitive.Trigger.Props & VariantProps) { +}: SelectPrimitive.Trigger.Props & + VariantProps & { icon?: React.ReactNode }) { return ( {children} - + {icon ?? } ); diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx index 904f8664772..b57ff200510 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -50,13 +50,35 @@ describe("sidebar interactive cursors", () => { expect(html).toContain("size-[var(--workspace-titlebar-control-size)]!"); }); - it("uses a pointer cursor for menu buttons by default", () => { + it("uses shared geometry and icon constraints for menu buttons by default", () => { const html = renderSidebarButton(); expect(html).toContain('data-slot="sidebar-menu-button"'); + expect(html).toContain("h-8"); + expect(html).toContain("rounded-md"); + expect(html).toContain("px-2"); + expect(html).toContain("py-1.5"); + expect(html).toContain("]:size-4"); + expect(html).toContain("]:shrink-0"); expect(html).toContain("cursor-pointer"); }); + it("applies the shared default treatment to icon-only menu buttons", () => { + const html = renderToStaticMarkup( + + + + + + , + ); + + expect(html).toContain("size-8"); + expect(html).toContain("justify-center"); + expect(html).toContain("p-0"); + expect(html).toContain("font-medium"); + expect(html).toContain("text-sidebar-muted-foreground/80"); + }); + it("lets project drag handles override the default pointer cursor", () => { const html = renderSidebarButton("cursor-grab"); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 2cd28316cea..fb9450dcde4 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -691,19 +691,28 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps) { +function SidebarContent({ + className, + fixedHeader, + ...props +}: React.ComponentProps<"div"> & { + fixedHeader?: React.ReactNode; +}) { return ( - -
- + <> + {fixedHeader ?
{fixedHeader}
: null} + +
+ + ); } @@ -790,7 +799,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full cursor-pointer items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "peer/menu-button flex w-full cursor-pointer items-center gap-2 overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-muted-foreground [&>svg]:opacity-60 hover:[&>svg]:text-sidebar-foreground hover:[&>svg]:opacity-100 active:[&>svg]:text-sidebar-foreground active:[&>svg]:opacity-100 data-[active=true]:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:opacity-100", { defaultVariants: { size: "default", @@ -798,14 +807,14 @@ const sidebarMenuButtonVariants = cva( }, variants: { size: { - default: "h-8 text-sm", - lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", - sm: "h-7 text-xs", + default: "h-8 rounded-md px-2.5 py-1.5 text-sm", + icon: "size-8 justify-center rounded-md p-0", + lg: "h-12 rounded-lg p-2 text-sm group-data-[collapsible=icon]:p-0!", + sm: "h-7 rounded-lg p-2 text-xs", }, variant: { - default: "hover:bg-sidebar-row-hover hover:text-sidebar-foreground", - outline: - "bg-sidebar-control-surface ring-1 ring-sidebar-border hover:bg-sidebar-row-hover hover:text-sidebar-foreground", + default: "font-medium text-sidebar-muted-foreground/80", + outline: "bg-sidebar-control-surface ring-1 ring-sidebar-border", }, }, }, diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index e58b2bde7b5..cf3c0b8fefa 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -587,7 +587,7 @@ function Toasts({ position }: { position: ToastPosition }) { return ( { }); }); - it("clears branch and worktree context when remapping a draft to another environment", () => { + it("clears branch and worktree but keeps env mode when remapping a draft to another environment", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId, branch: "feature/local-only", worktreePath: "/tmp/local-worktree", envMode: "worktree", + startFromOrigin: true, }); store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), remoteProjectRef, draftId, { @@ -1121,17 +1122,19 @@ describe("composerDraftStore project draft thread mapping", () => { projectId, branch: null, worktreePath: null, - envMode: "local", + envMode: "worktree", + startFromOrigin: true, }); }); - it("clears branch and worktree context when changing a draft thread project ref", () => { + it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId, branch: "feature/local-only", worktreePath: "/tmp/local-worktree", envMode: "worktree", + startFromOrigin: true, }); store.setDraftThreadContext(draftId, { @@ -1143,7 +1146,8 @@ describe("composerDraftStore project draft thread mapping", () => { projectId, branch: null, worktreePath: null, - envMode: "local", + envMode: "worktree", + startFromOrigin: true, }); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d4f38adcacd..95dde6187c8 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -495,6 +495,13 @@ interface ComposerDraftStoreState { attachments: PersistedComposerImageAttachment[], ) => void; clearComposerContent: (threadRef: ComposerThreadTarget) => void; + /** + * Clears only the prompt text and image attachments, preserving terminal / + * element contexts, preview annotations, and review comments. Used by the + * prompt stash, which can only round-trip text + images: clearing the + * session-bound contexts would destroy state nothing can restore. + */ + clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; } export interface EffectiveComposerModelState { @@ -1333,6 +1340,10 @@ function createDraftThreadState( interactionMode?: ProviderInteractionMode; }, ): DraftThreadState { + // A project change (including switching environments within a logical + // project) invalidates machine-specific context: the branch may not exist + // there and the worktree path certainly doesn't. The user's *intent* — + // env mode and start-from-origin — is machine-independent and carries. const projectChanged = existingThread !== undefined && (existingThread.environmentId !== projectRef.environmentId || @@ -1351,9 +1362,7 @@ function createDraftThreadState( : (options.branch ?? null); const nextStartFromOrigin = options?.startFromOrigin === undefined - ? projectChanged - ? false - : (existingThread?.startFromOrigin ?? false) + ? (existingThread?.startFromOrigin ?? false) : options.startFromOrigin; return { threadId, @@ -1367,12 +1376,7 @@ function createDraftThreadState( branch: nextBranch, worktreePath: nextWorktreePath, envMode: - options?.envMode ?? - (nextWorktreePath - ? "worktree" - : projectChanged - ? "local" - : (existingThread?.envMode ?? "local")), + options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, promotedTo: null, }; @@ -2091,7 +2095,7 @@ function hydratePersistedComposerImageAttachment( } } -function hydrateImagesFromPersisted( +export function hydrateImagesFromPersisted( attachments: ReadonlyArray, ): ComposerImageAttachment[] { return attachments.flatMap((attachment) => { @@ -2336,6 +2340,9 @@ const composerDraftStore = create()( ) { return state; } + // Mirrors createDraftThreadState: a project/environment change + // drops machine-specific context (branch, worktree path) but + // keeps the user's env mode and start-from-origin intent. const projectChanged = nextProjectRef.environmentId !== existing.environmentId || nextProjectRef.projectId !== existing.projectId; @@ -2353,9 +2360,7 @@ const composerDraftStore = create()( : (options.branch ?? null); const nextStartFromOrigin = options.startFromOrigin === undefined - ? projectChanged - ? false - : existing.startFromOrigin + ? existing.startFromOrigin : options.startFromOrigin; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, @@ -2371,12 +2376,7 @@ const composerDraftStore = create()( branch: nextBranch, worktreePath: nextWorktreePath, envMode: - options.envMode ?? - (nextWorktreePath - ? "worktree" - : projectChanged - ? "local" - : (existing.envMode ?? "local")), + options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, promotedTo: existing.promotedTo ?? null, }; @@ -3347,6 +3347,35 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + clearComposerPromptAndImages: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + for (const image of current.images) { + revokeObjectPreviewUrl(image.previewUrl); + } + const nextDraft: ComposerThreadDraftState = { + ...current, + prompt: ensureInlineTerminalContextPlaceholders("", current.terminalContexts.length), + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, }; }, { diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 9b3bb94dbce..50f4340e22d 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -166,9 +166,9 @@ export function showContextMenuFallback( const menu = document.createElement("div"); menu.className = - "fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg border border-border bg-popover bg-clip-padding text-popover-foreground shadow-lg/5 outline-none"; + "dropdown-glass fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg bg-clip-padding text-popover-foreground outline-none"; menu.style.cssText = - "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);border:1px solid var(--border);background:var(--popover);background-clip:padding-box;color:var(--popover-foreground);box-shadow:0 10px 15px -3px rgb(0 0 0 / 0.05),0 4px 6px -4px rgb(0 0 0 / 0.05);outline:none;pointer-events:auto;"; + "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--popover-foreground);outline:none;pointer-events:auto;"; menu.style.left = `${preferredLeft}px`; menu.style.top = `${preferredTop}px`; menu.dataset.level = String(level); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 623006ef8dd..03b564934e0 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -217,8 +217,7 @@ export function useNewThreadHandler() { // The workspace context must also ride along here: when projectRef // targets a different physical member of the logical project, // createDraftThreadState treats the remap as a project change and - // would otherwise wipe branch/worktree and force "local" mode, - // undoing the write above. + // would otherwise wipe branch/worktree, undoing the write above. setLogicalProjectDraftThreadId( logicalProjectKey, projectRef, diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 1f034f89683..0f9bcb6fe97 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -10,6 +10,7 @@ import { buildLegacyClientSettingsMigrationPatch, buildLegacyServerSettingsMigrationPatch, mergeEnvironmentSettings, + resolveEnvironmentIdentificationMode, } from "./useSettings"; describe("buildLegacyClientSettingsMigrationPatch", () => { @@ -46,6 +47,17 @@ describe("buildLegacyServerSettingsMigrationPatch", () => { }); }); +describe("resolveEnvironmentIdentificationMode", () => { + 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", () => { const serverSettings = { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 1c6434f7428..3f22c605e46 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -21,9 +21,12 @@ import { type ClientSettingsPatch, type ClientSettings, DEFAULT_CLIENT_SETTINGS, + type EnvironmentIdentificationMode, type UnifiedSettings, } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { APP_STAGE_LABEL } from "~/branding"; +import { resolveSidebarV2Enabled } from "~/branding.logic"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; @@ -270,6 +273,46 @@ 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 + * dev, off for production). Every consumer must read through this rather than + * `settings.sidebarV2Enabled`, which is only meaningful alongside + * `sidebarV2ConfiguredByUser`. + * + * Held at v1 until client settings hydrate. The pre-hydration snapshot is just + * the schema defaults, so resolving against it would mount one sidebar and then + * swap it out once persisted settings land — remounting the whole tree. + */ +export function useSidebarV2Enabled(): boolean { + const settingsHydrated = useClientSettingsHydrated(); + const settings = useClientSettingsValue(); + return useMemo( + () => + resolveSidebarV2Enabled({ + enabled: settings.sidebarV2Enabled, + configuredByUser: settings.sidebarV2ConfiguredByUser, + settingsHydrated, + stageLabel: APP_STAGE_LABEL, + }), + [settings.sidebarV2Enabled, settings.sidebarV2ConfiguredByUser, settingsHydrated], + ); +} + /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( environmentId: EnvironmentId, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 29dd99b8e6d..599455fa9b2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -77,6 +77,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil :root { --app-scrollbar-width: 6px; + --app-scrollbar-thumb: rgb(217 217 217); + --app-scrollbar-thumb-hover: rgb(191 191 191); --glass-blur: 12px; --glass-opacity: 80%; --glass-saturation: 1.14; @@ -91,6 +93,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } .dark { + --app-scrollbar-thumb: rgb(255 255 255 / 8%); + --app-scrollbar-thumb-hover: rgb(255 255 255 / 12%); --glass-blur: 16px; --glass-saturation: 1.08; } @@ -327,12 +331,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil rgb(0 0 0 / 96%) 82%, black 100% ), - linear-gradient(black, black); - -webkit-mask-position: top, bottom; + linear-gradient(black, black), linear-gradient(black, black); + -webkit-mask-position: top, bottom, right; -webkit-mask-repeat: no-repeat; -webkit-mask-size: 100% var(--topbar-scroll-fade-height), - 100% calc(100% - var(--topbar-scroll-fade-height)); + 100% calc(100% - var(--topbar-scroll-fade-height)), + var(--app-scrollbar-width) 100%; mask-image: linear-gradient( to bottom, @@ -344,12 +349,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil rgb(0 0 0 / 96%) 82%, black 100% ), - linear-gradient(black, black); - mask-position: top, bottom; + linear-gradient(black, black), linear-gradient(black, black); + mask-position: top, bottom, right; mask-repeat: no-repeat; mask-size: 100% var(--topbar-scroll-fade-height), - 100% calc(100% - var(--topbar-scroll-fade-height)); + 100% calc(100% - var(--topbar-scroll-fade-height)), + var(--app-scrollbar-width) 100%; } .workspace-titlebar-controls { @@ -1035,20 +1041,54 @@ code { } ::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); + background: var(--app-scrollbar-thumb); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.25); + background: var(--app-scrollbar-thumb-hover); } -.dark ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.1); +.model-picker-list::-webkit-scrollbar-track { + margin-block: 0.5rem; } -.dark ::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.18); +.model-picker-list-scroll-fade-top, +.model-picker-list-scroll-fade-bottom { + -webkit-mask-image: var(--model-picker-list-scroll-mask), linear-gradient(black, black); + -webkit-mask-position: left, right; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: + calc(100% - var(--app-scrollbar-width)) 100%, + var(--app-scrollbar-width) 100%; + mask-image: var(--model-picker-list-scroll-mask), linear-gradient(black, black); + mask-position: left, right; + mask-repeat: no-repeat; + mask-size: + calc(100% - var(--app-scrollbar-width)) 100%, + var(--app-scrollbar-width) 100%; +} + +.model-picker-list-scroll-fade-top { + --model-picker-list-scroll-mask: linear-gradient(to bottom, transparent, black var(--fade-size)); +} + +.model-picker-list-scroll-fade-bottom { + --model-picker-list-scroll-mask: linear-gradient( + to bottom, + black calc(100% - var(--fade-size)), + transparent + ); +} + +.model-picker-list-scroll-fade-top.model-picker-list-scroll-fade-bottom { + --model-picker-list-scroll-mask: linear-gradient( + to bottom, + transparent, + black var(--fade-size), + black calc(100% - var(--fade-size)), + transparent + ); } .turn-chip-strip { @@ -1432,6 +1472,30 @@ label:has(> select#reasoning-effort) select { margin-top: 0.125rem; } +/* Prompt-stash save acknowledgement: the new count fades up from just below + its resting position, once, then stops. One-shot and event-driven (React + remounts the element by key on each stash) — no continuous animation. */ +@keyframes prompt-stash-count-enter { + from { + opacity: 0; + transform: translateY(2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.prompt-stash-count-enter { + animation: prompt-stash-count-enter 180ms ease-out both; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-stash-count-enter { + animation: none; + } +} + @keyframes provider-update-pill-countdown { from { transform: scaleX(1); @@ -1545,28 +1609,6 @@ label:has(> select#reasoning-effort) select { animation: ultrathink-rainbow 10s linear infinite; } -/* Thin scrollbar for model picker */ -.model-picker-list::-webkit-scrollbar { - width: 4px; -} - -.model-picker-list::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.1); - border-radius: 2px; -} - -.model-picker-list::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.2); -} - -.dark .model-picker-list::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.08); -} - -.dark .model-picker-list::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.15); -} - /* Composer chips are non-editable decorators, so the browser skips them when painting text selection; this overlay stands in for the native highlight. */ .composer-inline-chip[data-composer-chip-selected]::after { diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index e75a893d6b3..8ab8b25bb41 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildPatchCacheKey, getRenderablePatch } from "./diffRendering"; +import { buildPatchCacheKey, getDiffLineStat, getRenderablePatch } from "./diffRendering"; describe("buildPatchCacheKey", () => { it("returns a stable cache key for identical content", () => { @@ -82,3 +82,33 @@ describe("getRenderablePatch", () => { expect(parsed.files[0]?.hunks[0]?.unifiedLineStart).toBe(47); }); }); + +describe("getDiffLineStat", () => { + it("totals additions and deletions across every file and hunk", () => { + const patch = [ + "diff --git a/example.ts b/example.ts", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -1,2 +1,3 @@", + "-before", + "+after", + "+added", + " context", + "@@ -10,2 +11,1 @@", + "-removed", + " context", + "diff --git a/README.md b/README.md", + "--- a/README.md", + "+++ b/README.md", + "@@ -1 +1,2 @@", + " title", + "+description", + ].join("\n"); + + const parsed = getRenderablePatch(patch); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + + expect(getDiffLineStat(parsed.files)).toEqual({ additions: 3, deletions: 2 }); + }); +}); diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index cb8318b3d2d..493474d8aa2 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -52,6 +52,25 @@ export type RenderablePatch = reason: string; }; +export interface DiffLineStat { + additions: number; + deletions: number; +} + +export function getDiffLineStat(files: ReadonlyArray): DiffLineStat { + return files.reduce( + (total, file) => { + for (const hunk of file.hunks) { + total.additions += hunk.additionLines; + total.deletions += hunk.deletionLines; + } + + return total; + }, + { additions: 0, deletions: 0 }, + ); +} + interface RenderablePatchOptions { /** * Pierre's partial-patch parser keeps hunk render starts in source-file diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts new file mode 100644 index 00000000000..63712ca7e29 --- /dev/null +++ b/apps/web/src/lib/imageCompression.test.ts @@ -0,0 +1,253 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + compressImageForStash, + compressImageToByteLimit, + MAX_COMPRESSIBLE_SOURCE_BYTES, + MAX_STASH_IMAGE_DATA_URL_CHARS, +} from "./imageCompression"; + +/** + * jsdom has no real canvas/codec, so the re-encode path is exercised with + * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a + * payload whose size scales with quality, mirroring how a real JPEG encoder + * shrinks as quality drops — enough to verify the ladder logic and budget + * enforcement without pulling in a native canvas. + */ + +const originalCreateImageBitmap = globalThis.createImageBitmap; +const originalOffscreenCanvas = globalThis.OffscreenCanvas; + +function makeFile(sizeBytes: number, type = "image/png"): File { + return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type }); +} + +/** + * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`. + * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed + * blob for WebP requests, which is how a real browser signals it cannot + * encode that format. + */ +function stubCanvasPipeline( + sizeForQuality: (quality: number) => number, + options?: { supportsWebp?: boolean }, +) { + const supportsWebp = options?.supportsWebp ?? true; + const close = vi.fn(); + const fillRect = vi.fn(); + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => ({ width: 4000, height: 3000, close })), + ); + vi.stubGlobal( + "OffscreenCanvas", + class { + constructor( + public width: number, + public height: number, + ) {} + getContext() { + return { + fillStyle: "", + fillRect, + drawImage: vi.fn(), + }; + } + async convertToBlob({ type, quality }: { type: string; quality: number }) { + const resolvedType = type === "image/webp" && !supportsWebp ? "image/png" : type; + return new Blob([new Uint8Array(sizeForQuality(quality))], { type: resolvedType }); + } + }, + ); + return { close, fillRect }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + globalThis.createImageBitmap = originalCreateImageBitmap; + globalThis.OffscreenCanvas = originalOffscreenCanvas; +}); + +describe("compressImageForStash", () => { + it("stores a small image verbatim without re-encoding", async () => { + const bitmapSpy = vi.fn(); + vi.stubGlobal("createImageBitmap", bitmapSpy); + + const result = await compressImageForStash(makeFile(1024)); + + expect(result.ok).toBe(true); + expect(result.ok && result.image.recompressed).toBe(false); + expect(result.ok && result.image.mimeType).toBe("image/png"); + expect(result.ok && result.image.dataUrl.startsWith("data:image/png")).toBe(true); + // Untouched payloads must not pay for a decode. + expect(bitmapSpy).not.toHaveBeenCalled(); + }); + + it("re-encodes an oversized image to WebP within the budget", async () => { + // Comfortably under budget at the very first quality step. + const { close, fillRect } = stubCanvasPipeline(() => 120_000); + + const result = await compressImageForStash(makeFile(4_000_000)); + + expect(result.ok).toBe(true); + expect(result.ok && result.image.recompressed).toBe(true); + expect(result.ok && result.image.mimeType).toBe("image/webp"); + expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true); + // sizeBytes should describe the re-encoded payload, not the 4MB original. + expect(result.ok && result.image.sizeBytes).toBeLessThan(4_000_000); + // WebP keeps alpha, so no white matte should be painted. + expect(fillRect).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); + }); + + it("falls back to JPEG with a white matte when WebP encoding is unavailable", async () => { + const { fillRect } = stubCanvasPipeline(() => 120_000, { supportsWebp: false }); + + const result = await compressImageForStash(makeFile(4_000_000)); + + expect(result.ok && result.image.recompressed).toBe(true); + expect(result.ok && result.image.mimeType).toBe("image/jpeg"); + // JPEG has no alpha, so transparent regions must be matted white. + expect(fillRect).toHaveBeenCalled(); + }); + + it("steps quality down until the encoded image fits", async () => { + // Only the lowest quality step (0.68) lands under the budget. + const { close } = stubCanvasPipeline((quality) => (quality <= 0.68 ? 400_000 : 3_000_000)); + + const result = await compressImageForStash(makeFile(9_000_000)); + + expect(result.ok && result.image.recompressed).toBe(true); + expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true); + expect(close).toHaveBeenCalled(); + }); + + it("reports too-large when even the smallest encoding overflows the budget", async () => { + const { close } = stubCanvasPipeline(() => 8_000_000); + + const result = await compressImageForStash(makeFile(9_000_000)); + + expect(result).toEqual({ ok: false, reason: "too-large" }); + // The bitmap must still be released on the give-up path. + expect(close).toHaveBeenCalled(); + }); + + it("reports too-large for an oversized image when the browser cannot re-encode", async () => { + vi.stubGlobal("createImageBitmap", undefined); + vi.stubGlobal("OffscreenCanvas", undefined); + + expect(await compressImageForStash(makeFile(4_000_000))).toEqual({ + ok: false, + reason: "too-large", + }); + }); + + it("reports unreadable when the image fails to decode", async () => { + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => { + throw new Error("corrupt image"); + }), + ); + vi.stubGlobal( + "OffscreenCanvas", + class { + getContext() { + return null; + } + }, + ); + + expect(await compressImageForStash(makeFile(4_000_000))).toEqual({ + ok: false, + reason: "unreadable", + }); + }); + + it("compressImageToByteLimit passes small files through byte-for-byte", async () => { + const bitmapSpy = vi.fn(); + vi.stubGlobal("createImageBitmap", bitmapSpy); + + const original = makeFile(1024); + const result = await compressImageToByteLimit(original, 10 * 1024 * 1024); + + expect(result.ok).toBe(true); + expect(result.ok && result.recompressed).toBe(false); + // Pass-through must be the same File object, not a copy. + expect(result.ok && result.file).toBe(original); + expect(bitmapSpy).not.toHaveBeenCalled(); + }); + + it("compressImageToByteLimit re-encodes an oversized file under the byte cap", async () => { + stubCanvasPipeline(() => 200_000); + + const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000); + + expect(result.ok).toBe(true); + expect(result.ok && result.recompressed).toBe(true); + expect(result.ok && result.file.type).toBe("image/webp"); + // The re-encoded name must match the new container format. + expect(result.ok && result.file.name).toBe("shot.webp"); + expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000); + }); + + it("compressImageToByteLimit refuses sources above the decode-safety ceiling", async () => { + const bitmapSpy = vi.fn(); + vi.stubGlobal("createImageBitmap", bitmapSpy); + + const result = await compressImageToByteLimit( + makeFile(MAX_COMPRESSIBLE_SOURCE_BYTES + 1), + 10 * 1024 * 1024, + ); + + expect(result).toEqual({ ok: false, reason: "too-large" }); + // The whole point of the ceiling is to never decode such a file. + expect(bitmapSpy).not.toHaveBeenCalled(); + }); + + it("compressImageToByteLimit reports too-large when no encoding fits", async () => { + const { close } = stubCanvasPipeline(() => 3_000_000); + + const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000); + + expect(result).toEqual({ ok: false, reason: "too-large" }); + expect(close).toHaveBeenCalled(); + }); + + it("shrinks below the source size when the image is already under MAX_DIMENSION", async () => { + // A small-but-heavy source (e.g. a dense PNG): only a real downscale can + // get it under budget, since quality alone is stubbed to never suffice. + let smallestRequested = Number.POSITIVE_INFINITY; + const close = vi.fn(); + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => ({ width: 800, height: 600, close })), + ); + vi.stubGlobal( + "OffscreenCanvas", + class { + constructor( + public width: number, + public height: number, + ) { + smallestRequested = Math.min(smallestRequested, width); + } + getContext() { + return { fillStyle: "", fillRect: vi.fn(), drawImage: vi.fn() }; + } + async convertToBlob({ type }: { type: string; quality: number }) { + // Only a genuinely downscaled pass fits the budget. + const size = smallestRequested < 800 ? 100_000 : 5_000_000; + return new Blob([new Uint8Array(size)], { type }); + } + }, + ); + + const result = await compressImageForStash(makeFile(4_000_000)); + + expect(result.ok).toBe(true); + // Fallback passes must scale off the bitmap, not a fixed 2048 ceiling + // that would never go below an 800px source. + expect(smallestRequested).toBeLessThan(800); + }); +}); diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts new file mode 100644 index 00000000000..1c57fdad1a5 --- /dev/null +++ b/apps/web/src/lib/imageCompression.ts @@ -0,0 +1,335 @@ +/** + * Downscale + re-encode for image attachments that are too big for where + * they're headed. Two consumers share the same pipeline: + * + * - The prompt stash persists images as base64 in localStorage (~5MB origin + * quota), so `compressImageForStash` targets a per-image character budget. + * - The composer accepts pasted/dropped images larger than the provider's + * `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` wire cap and shrinks them to fit + * via `compressImageToByteLimit` instead of rejecting the paste. + * + * Images already within budget pass through untouched. + */ + +/** + * Longest edge kept when an image has to be re-encoded. Sized so a typical + * retina screenshot (3024px wide) stays legible rather than being halved. + */ +const MAX_DIMENSION = 2048; +/** Base64 budget for a single stashed image (~975KB of binary). */ +export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000; +/** + * Ceiling on the *source* file handed to the re-encoder. File size is a + * proxy for pixel count, and decoding hundreds of megapixels into an + * ImageBitmap can OOM the tab — beyond this we refuse rather than risk it. + */ +export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024; +/** + * Quality ladder tried in order until the encoded image fits the budget. + * The floor stays high enough to avoid visible blocking on UI screenshots; + * if even that overflows we drop resolution instead of quality. + */ +const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const; +/** Extra downscale passes applied when even the lowest quality overflows. */ +const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const; + +export interface CompressedStashImage { + dataUrl: string; + mimeType: string; + sizeBytes: number; + /** True when the payload was re-encoded rather than stored verbatim. */ + recompressed: boolean; +} + +/** + * Why an image could not be compressed. Callers report these differently: + * "too large" is a budget outcome, "unreadable" is a decode failure. + */ +export type ImageCompressionFailureReason = "too-large" | "unreadable"; + +export type CompressStashImageResult = + | { ok: true; image: CompressedStashImage } + | { ok: false; reason: ImageCompressionFailureReason }; + +export type CompressImageFileResult = + | { ok: true; file: File; recompressed: boolean } + | { ok: false; reason: ImageCompressionFailureReason }; + +/** Chunked so a large image can't blow the argument limit of `fromCharCode`. */ +const BASE64_CHUNK_SIZE = 0x8000; + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE)); + } + return btoa(binary); +} + +/** + * Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so + * the module works anywhere `Blob` does (including non-DOM test runners). + */ +async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Promise { + const buffer = await blob.arrayBuffer(); + const mimeType = mimeTypeOverride || blob.type || "application/octet-stream"; + return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`; +} + +/** Approximate decoded byte count for a base64 data URL. */ +function dataUrlByteLength(dataUrl: string): number { + const commaIndex = dataUrl.indexOf(","); + const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1); + const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0; + return Math.max(0, Math.floor((payload.length * 3) / 4) - padding); +} + +/** Base64 payload of a data URL decoded back into a `File`. */ +function dataUrlToFile(dataUrl: string, name: string, mimeType: string): File { + const payload = dataUrl.slice(dataUrl.indexOf(",") + 1); + const binary = atob(payload); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new File([bytes], name, { type: mimeType }); +} + +/** + * Re-encoding changes the container, so a name like `shot.png` would lie + * about its contents. Swap the extension to match the encoded mime type. + */ +function fileNameForMimeType(name: string, mimeType: string): string { + const extension = mimeType === "image/webp" ? ".webp" : ".jpg"; + const dotIndex = name.lastIndexOf("."); + const base = dotIndex > 0 ? name.slice(0, dotIndex) : name; + return `${base}${extension}`; +} + +function canRecompress(): boolean { + return ( + typeof createImageBitmap === "function" && + (typeof OffscreenCanvas === "function" || typeof document !== "undefined") + ); +} + +interface Canvas2D { + canvas: OffscreenCanvas | HTMLCanvasElement; + context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; +} + +function createCanvas(width: number, height: number): Canvas2D | null { + if (typeof OffscreenCanvas === "function") { + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d"); + if (!context) return null; + return { canvas, context }; + } + if (typeof document === "undefined") return null; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return null; + return { canvas, context }; +} + +/** + * WebP is preferred: at matched visual quality it lands roughly 25-35% + * smaller than JPEG, so the same budget buys more resolution and detail — + * and it keeps alpha, so screenshots with transparency survive intact. + * Browsers that can't encode it silently fall back to JPEG. + */ +async function encodeToDataUrl( + canvas: OffscreenCanvas | HTMLCanvasElement, + quality: number, + mimeType: string, +): Promise<{ dataUrl: string; mimeType: string } | null> { + if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) { + const dataUrl = canvas.toDataURL(mimeType, quality); + // toDataURL silently returns a PNG when the requested type is unsupported. + if (!dataUrl.startsWith(`data:${mimeType}`)) return null; + return { dataUrl, mimeType }; + } + const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); + if (blob.type && blob.type !== mimeType) return null; + return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; +} + +/** + * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping + * quality down until the data URL fits `budgetChars`. Returns the smallest + * encoding produced, even if it still exceeds the budget, so the caller can + * decide whether to keep or drop it. + */ +async function encodeWithinBudget( + bitmap: ImageBitmap, + maxDimension: number, + budgetChars: number, +): Promise<{ dataUrl: string; mimeType: string } | null> { + const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + const target = createCanvas(width, height); + if (!target) return null; + + // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to + // happen before drawing and depends on which codec we end up using. + const probe = await encodeToDataUrl(target.canvas, QUALITY_STEPS[0], "image/webp"); + const mimeType = probe ? "image/webp" : "image/jpeg"; + + if (mimeType === "image/jpeg") { + target.context.fillStyle = "#ffffff"; + target.context.fillRect(0, 0, width, height); + } + target.context.drawImage(bitmap, 0, 0, width, height); + + let smallest: { dataUrl: string; mimeType: string } | null = null; + for (const quality of QUALITY_STEPS) { + const encoded = await encodeToDataUrl(target.canvas, quality, mimeType); + if (!encoded) break; + if (smallest === null || encoded.dataUrl.length < smallest.dataUrl.length) { + smallest = encoded; + } + if (encoded.dataUrl.length <= budgetChars) { + return encoded; + } + } + return smallest; +} + +type ReencodeResult = + | { ok: true; dataUrl: string; mimeType: string } + | { ok: false; reason: ImageCompressionFailureReason }; + +/** + * Shared re-encode loop: decodes `file`, then walks the quality ladder and + * fallback downscale passes until an encoding fits `budgetChars`. + */ +async function reencodeWithinBudget(file: File, budgetChars: number): Promise { + if (!canRecompress()) { + return { ok: false, reason: "too-large" }; + } + + let bitmap: ImageBitmap; + try { + bitmap = await createImageBitmap(file); + } catch { + return { ok: false, reason: "unreadable" }; + } + + try { + // Each pass shrinks relative to the *previous target*, capped by + // MAX_DIMENSION. Scaling a fixed ceiling instead would be a no-op for + // images already smaller than that ceiling — the fallback passes would + // all resolve to the source size and never actually reduce resolution. + const baseDimension = Math.min(MAX_DIMENSION, Math.max(bitmap.width, bitmap.height)); + // Tracks whether the *last* attempt threw, so a run of encoder failures + // is reported as unreadable while a run of merely-too-big results is + // reported as too-large. + let encodeFailed = false; + for (const dimensionScale of [1, ...FALLBACK_SCALE_STEPS]) { + const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale)); + let encoded: { dataUrl: string; mimeType: string } | null; + try { + encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars); + } catch { + // Canvas allocation, drawing, or the codec itself can throw — often + // precisely *because* the target is too big (OOM on a large bitmap). + // Keep trying the smaller fallback scales rather than giving up: a + // reduced pass may well succeed. The exception must never escape, + // though, since callers finalize state after this returns and a + // throw would strand it (e.g. a stash entry stuck "still saving"). + encodeFailed = true; + continue; + } + encodeFailed = false; + if (encoded && encoded.dataUrl.length <= budgetChars) { + return { ok: true, dataUrl: encoded.dataUrl, mimeType: encoded.mimeType }; + } + } + return { ok: false, reason: encodeFailed ? "unreadable" : "too-large" }; + } finally { + bitmap.close(); + } +} + +/** + * Produces the payload to persist for a stashed image. + * + * Small images are stored verbatim (preserving PNG transparency and exact + * pixels). Anything over budget is downscaled and re-encoded; if it still + * doesn't fit after the fallback passes, reports a failure so the caller + * can record it as dropped. + */ +export async function compressImageForStash( + file: File, + budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS, +): Promise { + let originalDataUrl: string; + try { + originalDataUrl = await blobToDataUrl(file); + } catch { + return { ok: false, reason: "unreadable" }; + } + if (originalDataUrl.length <= budgetChars) { + return { + ok: true, + image: { + dataUrl: originalDataUrl, + mimeType: file.type, + sizeBytes: file.size, + recompressed: false, + }, + }; + } + const reencoded = await reencodeWithinBudget(file, budgetChars); + if (!reencoded.ok) { + return reencoded; + } + return { + ok: true, + image: { + dataUrl: reencoded.dataUrl, + mimeType: reencoded.mimeType, + sizeBytes: dataUrlByteLength(reencoded.dataUrl), + recompressed: true, + }, + }; +} + +/** + * Shrinks `file` until its binary size fits `maxBytes`, returning a new + * `File` (WebP or JPEG). Files already within the limit pass through + * untouched, preserving their exact bytes and format. Sources above + * `MAX_COMPRESSIBLE_SOURCE_BYTES` are refused outright — decoding them is + * the risk, so no amount of output budget makes them safe. + */ +export async function compressImageToByteLimit( + file: File, + maxBytes: number, +): Promise { + if (file.size <= maxBytes) { + return { ok: true, file, recompressed: false }; + } + if (file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) { + return { ok: false, reason: "too-large" }; + } + // The re-encode loop budgets in data-URL characters. Base64 turns 3 bytes + // into 4 chars; flooring keeps the budget a hair conservative instead of + // admitting an encoding right at the byte cap. + const budgetChars = Math.floor(maxBytes / 3) * 4; + const reencoded = await reencodeWithinBudget(file, budgetChars); + if (!reencoded.ok) { + return reencoded; + } + return { + ok: true, + file: dataUrlToFile( + reencoded.dataUrl, + fileNameForMimeType(file.name || "image", reencoded.mimeType), + reencoded.mimeType, + ), + recompressed: true, + }; +} diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 5691ffa8895..9fc29613867 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 1e24de8bb1d..a6dba941b8a 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/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts new file mode 100644 index 00000000000..20894713d1d --- /dev/null +++ b/apps/web/src/promptStashStore.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { removeLocalStorageItem } from "./hooks/useLocalStorage"; + +import { + MAX_STASH_ENTRIES, + PROMPT_STASH_STORAGE_KEY, + MAX_STASH_ENTRY_ATTACHMENT_CHARS, + partitionStashAttachments, + usePromptStashStore, + writePromptStashStorageForTest, + type PromptStashEntry, +} from "./promptStashStore"; + +function makeEntry(input: { + id: string; + prompt?: string; + attachmentChars?: number; +}): PromptStashEntry { + return { + id: input.id, + createdAt: "2026-07-24T12:00:00.000Z", + prompt: input.prompt ?? `prompt ${input.id}`, + attachments: + input.attachmentChars !== undefined + ? [ + { + id: `${input.id}-img`, + name: "shot.png", + mimeType: "image/png", + sizeBytes: input.attachmentChars, + dataUrl: "x".repeat(input.attachmentChars), + }, + ] + : [], + droppedImageNames: [], + }; +} + +function resetPromptStashStore() { + usePromptStashStore.setState({ entries: [] }); + writePromptStashStorageForTest(""); + removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY); +} + +describe("partitionStashAttachments", () => { + it("keeps attachments within the budget and reports dropped names in order", () => { + const small = { + id: "a", + name: "small.png", + mimeType: "image/png", + sizeBytes: 10, + dataUrl: "x".repeat(10), + }; + const huge = { + id: "b", + name: "huge.png", + mimeType: "image/png", + sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS, + dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS), + }; + const alsoSmall = { + id: "c", + name: "also-small.png", + mimeType: "image/png", + sizeBytes: 10, + dataUrl: "x".repeat(10), + }; + const { kept, droppedNames } = partitionStashAttachments([small, huge, alsoSmall]); + expect(kept.map((attachment) => attachment.id)).toEqual(["a", "c"]); + expect(droppedNames).toEqual(["huge.png"]); + }); + + it("admits a single attachment that exactly fits the budget", () => { + const exact = { + id: "a", + name: "exact.png", + mimeType: "image/png", + sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS, + dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS), + }; + const { kept, droppedNames } = partitionStashAttachments([exact]); + expect(kept).toHaveLength(1); + expect(droppedNames).toEqual([]); + }); +}); + +describe("promptStashStore", () => { + beforeEach(() => { + resetPromptStashStore(); + }); + + afterEach(() => { + resetPromptStashStore(); + }); + + it("prepends entries so the newest stash is first", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "first" })); + store.stashEntry(makeEntry({ id: "second" })); + const entries = usePromptStashStore.getState().entries; + expect(entries.map((entry) => entry.id)).toEqual(["second", "first"]); + }); + + it("evicts the oldest entry past the cap and returns it", () => { + const store = usePromptStashStore.getState(); + for (let index = 0; index < MAX_STASH_ENTRIES; index += 1) { + expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull(); + } + const { evicted } = store.stashEntry(makeEntry({ id: "overflow" })); + expect(evicted?.id).toBe("entry-0"); + const entries = usePromptStashStore.getState().entries; + expect(entries).toHaveLength(MAX_STASH_ENTRIES); + expect(entries[0]?.id).toBe("overflow"); + }); + + // This test environment has no `localStorage`, so the store runs on its + // in-memory fallback — the exact "kept for this session, gone on reload" + // case the composer must distinguish from an outright write failure. + it("distinguishes a memory-only write (written, not durable) from a failed one", () => { + const store = usePromptStashStore.getState(); + const result = store.stashEntry(makeEntry({ id: "memory-only" })); + expect(result.written).toBe(true); + expect(result.durable).toBe(false); + expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual([ + "memory-only", + ]); + }); + + it("takeEntry removes and returns the entry; second take returns null", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "keep" })); + store.stashEntry(makeEntry({ id: "take" })); + expect(store.takeEntry("take").entry?.id).toBe("take"); + expect(store.takeEntry("take").entry).toBeNull(); + const entries = usePromptStashStore.getState().entries; + expect(entries.map((entry) => entry.id)).toEqual(["keep"]); + }); + + it("finalizeEntryImages attaches images and clears the pending count", () => { + const store = usePromptStashStore.getState(); + store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 }); + + const { attached } = store.finalizeEntryImages("pending", { + attachments: [ + { + id: "img-1", + name: "a.webp", + mimeType: "image/webp", + sizeBytes: 10, + dataUrl: "data:image/webp;base64,AAAA", + }, + ], + droppedImageNames: ["big.png"], + unreadableImageNames: [], + }); + + expect(attached).toBe(true); + const entry = usePromptStashStore.getState().entries[0]; + expect(entry?.attachments).toHaveLength(1); + expect(entry?.droppedImageNames).toEqual(["big.png"]); + expect(entry?.pendingImageCount).toBe(0); + }); + + it("finalizeEntryImages reports false when the entry was already taken", () => { + const store = usePromptStashStore.getState(); + store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 }); + // Restored (or deleted) while its images were still encoding. + store.takeEntry("racing"); + + const { attached } = store.finalizeEntryImages("racing", { + attachments: [], + droppedImageNames: [], + unreadableImageNames: [], + }); + + expect(attached).toBe(false); + }); + + it("settles a pending count left behind by a crashed or closed session", () => { + writePromptStashStorageForTest( + JSON.stringify({ + version: 2, + state: { + entries: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], + }, + }), + ); + + // Hydration must settle the stale count, or the entry would stay stuck + // showing "saving…" with images that no longer exist anywhere. + const entry = usePromptStashStore.getState().entries[0]; + expect(entry?.pendingImageCount).toBe(0); + expect(entry?.unreadableImageNames).toHaveLength(2); + }); + + it("ignores an unreadable v1 payload seeded under the current key", () => { + // The v1 shape (per-provider queues) does not decode as v2; hydration + // must fall back to an empty stash rather than throw. + writePromptStashStorageForTest( + JSON.stringify({ + version: 1, + state: { queuesByScopeKey: { "provider:claudeAgent": [] } }, + }), + ); + expect(usePromptStashStore.getState().entries).toEqual([]); + }); +}); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts new file mode 100644 index 00000000000..d7c541e7a94 --- /dev/null +++ b/apps/web/src/promptStashStore.ts @@ -0,0 +1,293 @@ +import * as Schema from "effect/Schema"; +import { create } from "zustand"; + +import { PersistedComposerImageAttachment } from "./composerDraftStore"; +import { createMemoryStorage, type StateStorage } from "./lib/storage"; + +export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2"; +/** + * v1 bucketed entries into per-provider-instance queues and stored a model + * selection with each prompt. The stash is provider-agnostic now, so the old + * payload is deleted at startup rather than migrated — left behind it would + * silently hold megabytes of the origin's ~5MB localStorage quota forever. + */ +const LEGACY_PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; +const PROMPT_STASH_STORAGE_VERSION = 2; + +export const MAX_STASH_ENTRIES = 20; +/** + * Budget for an entry's serialized attachment payload. localStorage is a + * ~5MB origin-wide quota shared with the composer draft store, so oversized + * images are dropped (tracked in `droppedImageNames`) rather than persisted. + * + * Sized to hold two images at the per-image compression budget + * (`MAX_STASH_IMAGE_DATA_URL_CHARS`) so a typical before/after screenshot + * pair survives intact. + */ +export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000; + +/** + * A stashed prompt carries only what every provider can accept: text and + * image attachments. Deliberately no provider instance or model selection — + * the point of stashing is to move a prompt into a different thread or + * provider, so restoring must never drag the old model choice along. + */ +const StashEntrySchema = Schema.Struct({ + id: Schema.String, + createdAt: Schema.String, + prompt: Schema.String, + attachments: Schema.Array(PersistedComposerImageAttachment), + /** Names of images that exceeded the attachment budget and were not saved. */ + droppedImageNames: Schema.Array(Schema.String), + /** + * Names of images that could not be decoded or re-encoded at all — a + * distinct failure from exceeding the size budget, so the menu can explain + * which actually happened. Optional: entries written before this field + * existed decode without it. + */ + unreadableImageNames: Schema.optionalKey(Schema.Array(Schema.String)), + /** + * Images still being encoded when the entry was written. The entry is + * persisted before its images so a crash mid-encode cannot lose the prompt; + * this field lets the UI show "N images still saving" until + * `finalizeEntryImages` lands, and flags entries orphaned by a reload. + */ + pendingImageCount: Schema.optionalKey(Schema.Number), +}); +export type PromptStashEntry = typeof StashEntrySchema.Type; + +const PersistedPromptStashState = Schema.Struct({ + entries: Schema.Array(StashEntrySchema), +}); +type PersistedPromptStashState = typeof PersistedPromptStashState.Type; + +const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState); + +/** + * `pendingImageCount` only has meaning within the session that wrote it: the + * encode loop that would clear it does not survive a reload. Any entry that + * comes back from storage still pending was orphaned by a closed tab or a + * crash mid-encode, so the count is settled here — otherwise the entry would + * be stuck showing "saving…" and refuse to restore forever. + * + * The images are genuinely gone (they were never written), so they are + * recorded as unreadable to keep the prompt itself restorable. + */ +function clearOrphanedPendingImages( + entries: ReadonlyArray, +): ReadonlyArray { + return entries.map((entry) => { + if (!entry.pendingImageCount) return entry; + const lostCount = entry.pendingImageCount; + return { + ...entry, + pendingImageCount: 0, + unreadableImageNames: [ + ...(entry.unreadableImageNames ?? []), + ...Array.from( + { length: lostCount }, + (_, index) => `image ${index + 1} (not saved before reload)`, + ), + ], + }; + }); +} + +/** + * Splits candidate attachments into a persistable set within the entry + * budget plus the names of any that had to be dropped. Attachments are + * admitted in order so the earliest-added images win. + */ +export function partitionStashAttachments( + attachments: ReadonlyArray, +): { + kept: PersistedComposerImageAttachment[]; + droppedNames: string[]; +} { + const kept: PersistedComposerImageAttachment[] = []; + const droppedNames: string[] = []; + let usedChars = 0; + for (const attachment of attachments) { + if (usedChars + attachment.dataUrl.length > MAX_STASH_ENTRY_ATTACHMENT_CHARS) { + droppedNames.push(attachment.name); + continue; + } + usedChars += attachment.dataUrl.length; + kept.push(attachment); + } + return { kept, droppedNames }; +} + +/** + * Reading the `localStorage` property itself can throw `SecurityError` when + * storage is blocked by policy or the page is a sandboxed iframe — so the + * access has to be guarded, not just the get/set calls on it. Otherwise + * importing this module would crash the app at load. + * + * `durable` is false for the in-memory fallback: writes there "succeed" but + * vanish on reload, and callers clear the composer on the strength of a + * successful stash, so they must be told the difference. + */ +function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { + try { + if (typeof localStorage !== "undefined") { + return { storage: localStorage, durable: true }; + } + } catch { + // Fall through to the in-memory store. + } + return { storage: createMemoryStorage(), durable: false }; +} + +const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage(); + +/** + * Persists the queue, immediately rather than debounced. Stashing is a + * deliberate, infrequent keystroke — not a per-character autosave — so there + * is nothing to coalesce, and the caller clears the composer on the strength + * of this write landing, which a debounce timer cannot honestly report. + * + * Returns whether the write will survive a reload: false on a quota rejection + * or when only the in-memory fallback is available. + */ +function persistEntries(entries: ReadonlyArray): { + /** The write succeeded (possibly only into the in-memory fallback). */ + written: boolean; + /** The write will survive a reload. */ + durable: boolean; +} { + try { + baseStashStorage.setItem( + PROMPT_STASH_STORAGE_KEY, + JSON.stringify({ + version: PROMPT_STASH_STORAGE_VERSION, + state: { entries }, + }), + ); + return { written: true, durable: storageIsDurable }; + } catch (error) { + console.error("[PROMPT-STASH] Could not persist stash (storage quota?).", error); + return { written: false, durable: false }; + } +} + +/** Reads the persisted queue, settling stale pending counts. */ +function readPersistedEntries(): ReadonlyArray | null { + try { + const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY); + if (typeof raw !== "string" || raw.length === 0) return null; + const parsed: unknown = JSON.parse(raw); + const state = (parsed as { state?: unknown } | null)?.state; + if (!state) return null; + return clearOrphanedPendingImages(decodePersistedPromptStashState(state).entries); + } catch { + return null; + } +} + +interface PromptStashStoreState { + entries: ReadonlyArray; + /** + * Prepends an entry to the queue, evicting the oldest entry past the cap. + * Returns the evicted entry (for messaging) if any. + */ + stashEntry: (entry: PromptStashEntry) => { + evicted: PromptStashEntry | null; + /** False when the write failed outright (e.g. quota); nothing was kept. */ + written: boolean; + /** + * False when the write will not survive a reload: either it failed, or it + * landed only in the in-memory fallback because localStorage is blocked. + */ + durable: boolean; + }; + /** + * Removes and returns an entry from the queue (restore + delete). + * `durable` is false when the removal could not be persisted, meaning a + * reload would resurrect the entry. + */ + takeEntry: (entryId: string) => { entry: PromptStashEntry | null; durable: boolean }; + /** + * Attaches the encoded images to an entry written earlier by `stashEntry`, + * clearing its pending count. Returns attached=false when the entry is gone + * (restored or deleted while encoding was still running) so the caller can + * tell the user their images did not make it. + */ + finalizeEntryImages: ( + entryId: string, + images: { + attachments: ReadonlyArray; + droppedImageNames: ReadonlyArray; + unreadableImageNames: ReadonlyArray; + }, + ) => { attached: boolean; durable: boolean }; +} + +export const usePromptStashStore = create()((set, get) => ({ + entries: [], + stashEntry: (entry) => { + const nextEntries = [entry, ...get().entries]; + const evicted = nextEntries.length > MAX_STASH_ENTRIES ? (nextEntries.pop() ?? null) : null; + const { written, durable } = persistEntries(nextEntries); + // A rejected write must not leave the entry visible either: the caller + // keeps the composer intact on failure, so a stashed copy would + // duplicate the prompt. Eviction likewise only sticks on success. + if (!written) { + return { evicted: null, written: false, durable: false }; + } + set(() => ({ entries: nextEntries })); + return { evicted, written: true, durable }; + }, + takeEntry: (entryId) => { + const entries = get().entries; + const entry = entries.find((candidate) => candidate.id === entryId) ?? null; + if (!entry) return { entry: null, durable: true }; + const nextEntries = entries.filter((candidate) => candidate.id !== entryId); + const { durable } = persistEntries(nextEntries); + set(() => ({ entries: nextEntries })); + return { entry, durable }; + }, + finalizeEntryImages: (entryId, images) => { + const entries = get().entries; + const index = entries.findIndex((candidate) => candidate.id === entryId); + const existing = index === -1 ? undefined : entries[index]; + // Restored or deleted mid-encode: nothing to attach to. + if (!existing) return { attached: false, durable: true }; + const nextEntries = [...entries]; + nextEntries[index] = { + ...existing, + attachments: images.attachments, + droppedImageNames: images.droppedImageNames, + unreadableImageNames: images.unreadableImageNames, + pendingImageCount: 0, + }; + const { durable } = persistEntries(nextEntries); + set(() => ({ entries: nextEntries })); + return { attached: true, durable }; + }, +})); + +// Hydrate once at startup. Like the app's other persisted stores, tabs are +// last-write-wins: no cross-tab merging or storage-event syncing. +{ + try { + baseStashStorage.removeItem(LEGACY_PROMPT_STASH_STORAGE_KEY); + } catch { + // Purging the v1 payload is best-effort; a storage policy that rejects + // the delete must not take down module init. + } + const persisted = readPersistedEntries(); + if (persisted) { + usePromptStashStore.setState({ entries: persisted }); + } +} + +/** + * Test seam: seeds the persisted payload through the same storage the store + * reads and rehydrates, without needing a real `localStorage` global. + * Pass an empty string to clear. + */ +export function writePromptStashStorageForTest(raw: string): void { + baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); + usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] }); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 563d1b43755..58ab4c3a714 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/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index ce9de113beb..5ac4665cf8a 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -5,6 +5,7 @@ import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; +import { resolveThreadSyncPhase } from "../threadSync"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, @@ -48,6 +49,11 @@ function ChatThreadRouteView() { serverThreadDetailDeleted: serverThreadStatus === "deleted", draftThreadExists, }); + const threadSyncPhase = resolveThreadSyncPhase({ + detailExists: serverThreadDetail !== null, + shellExists: serverThreadShell !== null, + status: serverThreadStatus, + }); const serverThreadStarted = threadHasStarted(serverThreadDetail); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; @@ -68,17 +74,20 @@ function ChatThreadRouteView() { finalizePromotedDraftThreadByRef(threadRef); }, [draftThread, serverThreadStarted, threadRef]); - if (!threadRef || renderState !== "ready") { + if (!threadRef) { return null; } return ( - + {renderState === "ready" || (renderState === "loading" && serverThreadShell !== null) ? ( + + ) : null} ); } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index d3cf003d99c..75c517dc33f 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,7 +3,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useSidebarV2Enabled } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; @@ -28,7 +28,7 @@ function ChatRouteGlobalShortcuts() { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarV2Enabled = useSidebarV2Enabled(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); diff --git a/apps/web/src/routes/settings.appearance.tsx b/apps/web/src/routes/settings.appearance.tsx new file mode 100644 index 00000000000..60c33fff307 --- /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/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 4a99aebe18d..ed2c132aae4 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -74,11 +74,11 @@ function SettingsContentLayout() { {!isElectron && (
-
+
Settings {showRestoreDefaults ? (
diff --git a/apps/web/src/state/entities.test.ts b/apps/web/src/state/entities.test.ts new file mode 100644 index 00000000000..0d7611ec41a --- /dev/null +++ b/apps/web/src/state/entities.test.ts @@ -0,0 +1,36 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadDetailRef } from "./entities"; + +const threadRef = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1")); + +describe("resolveThreadDetailRef", () => { + it("does not subscribe to a reserved draft thread before it enters the shell index", () => { + expect( + resolveThreadDetailRef(threadRef, { + shellExists: false, + waitForShell: true, + }), + ).toBeNull(); + }); + + it("subscribes once the reserved draft thread enters the shell index", () => { + expect( + resolveThreadDetailRef(threadRef, { + shellExists: true, + waitForShell: true, + }), + ).toBe(threadRef); + }); + + it("keeps direct server-thread lookups enabled when the shell has not loaded it", () => { + expect( + resolveThreadDetailRef(threadRef, { + shellExists: false, + waitForShell: false, + }), + ).toBe(threadRef); + }); +}); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 9bd20070c23..552468e04d9 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -152,10 +152,35 @@ export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadS ); } +export function resolveThreadDetailRef( + ref: ScopedThreadRef | null, + options: { + shellExists: boolean; + waitForShell: boolean; + }, +): ScopedThreadRef | null { + return ref !== null && (!options.waitForShell || options.shellExists) ? ref : null; +} + /** Detail collections composed with shell-authoritative thread/workspace metadata. */ -export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null { +export function useThread( + ref: ScopedThreadRef | null, + options?: { + /** + * Client-reserved draft thread ids do not exist on the server until the + * first send. Waiting for the shell index avoids polling the detail + * endpoint for an intentionally missing thread during that window. + */ + waitForShell?: boolean; + }, +): EnvironmentThread | null { const shell = useThreadShell(ref); - const detail = useThreadDetail(ref); + const detail = useThreadDetail( + resolveThreadDetailRef(ref, { + shellExists: shell !== null, + waitForShell: options?.waitForShell === true, + }), + ); return useMemo(() => mergeEnvironmentThread(detail, shell), [detail, shell]); } diff --git a/apps/web/src/threadSync.test.ts b/apps/web/src/threadSync.test.ts new file mode 100644 index 00000000000..ba91e33078f --- /dev/null +++ b/apps/web/src/threadSync.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadSyncPhase, threadSyncLabel } from "./threadSync"; + +describe("resolveThreadSyncPhase", () => { + it("loads when only shell data is available", () => { + expect( + resolveThreadSyncPhase({ + detailExists: false, + shellExists: true, + status: "synchronizing", + }), + ).toBe("loading"); + }); + + it("syncs when cached detail is already visible", () => { + expect( + resolveThreadSyncPhase({ + detailExists: true, + shellExists: true, + status: "cached", + }), + ).toBe("syncing"); + }); + + it("does not report a sync phase without a shell or after going live", () => { + expect( + resolveThreadSyncPhase({ + detailExists: false, + shellExists: false, + status: "empty", + }), + ).toBeNull(); + expect( + resolveThreadSyncPhase({ + detailExists: true, + shellExists: true, + status: "live", + }), + ).toBeNull(); + }); +}); + +describe("threadSyncLabel", () => { + it("uses the same loading and syncing language as mobile", () => { + expect(threadSyncLabel("loading")).toBe("Loading messages..."); + expect(threadSyncLabel("syncing")).toBe("Syncing messages..."); + }); +}); diff --git a/apps/web/src/threadSync.ts b/apps/web/src/threadSync.ts new file mode 100644 index 00000000000..a8b5446add2 --- /dev/null +++ b/apps/web/src/threadSync.ts @@ -0,0 +1,27 @@ +import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; + +export type ThreadSyncPhase = "loading" | "syncing"; + +export function resolveThreadSyncPhase(input: { + readonly detailExists: boolean; + readonly shellExists: boolean; + readonly status: EnvironmentThreadStatus; +}): ThreadSyncPhase | null { + if (!input.shellExists) { + return null; + } + + switch (input.status) { + case "empty": + case "cached": + case "synchronizing": + return input.detailExists ? "syncing" : "loading"; + case "deleted": + case "live": + return null; + } +} + +export function threadSyncLabel(phase: ThreadSyncPhase): string { + return phase === "loading" ? "Loading messages..." : "Syncing messages..."; +} diff --git a/package.json b/package.json index ce973c4c822..960f2ed6000 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", + "connect:announce-ga": "node scripts/announce-connect-ga.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus", "sync:repos": "node scripts/sync-reference-repos.ts" }, diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..d966c1e7e61 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -63,6 +63,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomOut", "preview.resetZoom", "commandPalette.toggle", + "composer.stash", "chat.new", "chat.newLocal", "editor.openFavorite", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1e2f6b95c74..2bc61d72f21 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({}); @@ -56,6 +74,31 @@ describe("ClientSettings sidebar v2", () => { expect(settings.sidebarAutoSettleAfterDays).toBe(3); }); + it("treats settings written before the beta had a per-channel default as unconfigured", () => { + // The stored blob always carries `sidebarV2Enabled`, so only the companion + // flag can distinguish "user opted out" from "never touched it". + expect(decodeClientSettings({ sidebarV2Enabled: false }).sidebarV2ConfiguredByUser).toBe(false); + expect(decodeClientSettings({ sidebarV2Enabled: true }).sidebarV2ConfiguredByUser).toBe(false); + }); + + it("preserves an explicit beta choice", () => { + const settings = decodeClientSettings({ + sidebarV2Enabled: false, + sidebarV2ConfiguredByUser: true, + }); + expect(settings.sidebarV2Enabled).toBe(false); + expect(settings.sidebarV2ConfiguredByUser).toBe(true); + }); + + it("carries an explicit beta opt-out through the patch the beta toggle writes", () => { + const patch = decodeClientSettingsPatch({ + sidebarV2Enabled: false, + sidebarV2ConfiguredByUser: true, + }); + expect(patch.sidebarV2Enabled).toBe(false); + expect(patch.sidebarV2ConfiguredByUser).toBe(true); + }); + it("allows auto-settle by inactivity to be disabled", () => { expect( decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 885ccda48a0..0c42faa1fcc 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)), ), @@ -115,6 +121,12 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Whether `sidebarV2Enabled` reflects an explicit choice in Settings → Beta. + // Client settings persist as a whole blob, so every user who has ever touched + // any setting already has `sidebarV2Enabled: false` stored — without this bit + // there is no way to tell that apart from "left alone", and a channel-derived + // default could never reach them. Mirrors `updateChannelConfiguredByUser`. + sidebarV2ConfiguredByUser: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -802,6 +814,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( @@ -833,6 +846,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), + sidebarV2ConfiguredByUser: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), }); diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..0688cf07254 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -35,6 +35,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, + { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, diff --git a/scripts/announce-connect-ga.ts b/scripts/announce-connect-ga.ts new file mode 100644 index 00000000000..88a4db56cc7 --- /dev/null +++ b/scripts/announce-connect-ga.ts @@ -0,0 +1,226 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +const CLERK_API_URL = "https://api.clerk.com/v1"; +const PAGE_SIZE = 500; + +export class WaitlistEntry extends Schema.Class("WaitlistEntry")({ + id: Schema.String, + email_address: Schema.String, + status: Schema.Literals(["pending", "invited", "completed", "rejected"]), +}) {} + +const ClerkWaitlistResponse = Schema.Struct({ + data: Schema.Array(WaitlistEntry), + total_count: Schema.Int, +}); +const PositiveInteger = Schema.Int.check(Schema.isGreaterThan(0)); +const ClerkSecretKey = Config.string("CLERK_SECRET_KEY"); + +export interface ConnectGaOptions { + readonly invite: boolean; + readonly limit: number | undefined; +} + +export class ConnectGaRequestError extends Schema.TaggedErrorClass()( + "ConnectGaRequestError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Clerk ${this.operation} request failed.`; + } +} + +export class ConnectGaResponseError extends Schema.TaggedErrorClass()( + "ConnectGaResponseError", + { + operation: Schema.String, + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Clerk ${this.operation} returned status ${this.status}.`; + } +} + +const executeClerkJsonRequest = Effect.fn("executeClerkJsonRequest")(function* < + S extends Schema.Top, +>(request: HttpClientRequest.HttpClientRequest, schema: S, operation: string) { + const client = (yield* HttpClient.HttpClient).pipe( + HttpClient.retryTransient({ + retryOn: "errors-and-responses", + times: 3, + }), + ); + const response = yield* client + .execute(request) + .pipe(Effect.mapError((cause) => new ConnectGaRequestError({ operation, cause }))); + const success = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.mapError( + (cause) => + new ConnectGaResponseError({ + operation, + status: response.status, + cause, + }), + ), + ); + return yield* HttpClientResponse.schemaBodyJson(schema)(success).pipe( + Effect.mapError( + (cause) => + new ConnectGaResponseError({ + operation, + status: response.status, + cause, + }), + ), + ); +}); + +const fetchWaitlistPage = Effect.fn("fetchWaitlistPage")(function* ( + secretKey: string, + offset: number, +) { + const url = new URL(`${CLERK_API_URL}/waitlist_entries`); + url.searchParams.set("status", "pending"); + url.searchParams.set("limit", String(PAGE_SIZE)); + url.searchParams.set("offset", String(offset)); + url.searchParams.set("order_by", "+created_at"); + const request = HttpClientRequest.get(url.href).pipe( + HttpClientRequest.bearerToken(secretKey), + HttpClientRequest.setHeader("Clerk-API-Version", "2026-05-12"), + ); + return yield* executeClerkJsonRequest( + request, + ClerkWaitlistResponse, + "list pending waitlist entries", + ); +}); + +export const fetchPendingWaitlistEntries = Effect.fn("fetchPendingWaitlistEntries")(function* ( + secretKey: string, + limit?: number, +) { + const entries: Array = []; + while (true) { + if (limit !== undefined && entries.length >= limit) break; + const page = yield* fetchWaitlistPage(secretKey, entries.length); + entries.push(...page.data); + if (entries.length >= page.total_count || page.data.length === 0) break; + } + return limit === undefined ? entries : entries.slice(0, limit); +}); + +export const inviteWaitlistEntry = Effect.fn("inviteWaitlistEntry")(function* ( + secretKey: string, + entry: WaitlistEntry, +) { + const request = HttpClientRequest.post( + `${CLERK_API_URL}/waitlist_entries/${encodeURIComponent(entry.id)}/invite`, + ).pipe( + HttpClientRequest.bearerToken(secretKey), + HttpClientRequest.setHeader("Clerk-API-Version", "2026-05-12"), + ); + return yield* executeClerkJsonRequest( + request, + WaitlistEntry, + `invite waitlist entry ${entry.id}`, + ); +}); + +export const announceConnectGa = Effect.fn("announceConnectGa")(function* ( + options: ConnectGaOptions, +) { + const clerkSecretKey = yield* ClerkSecretKey; + const entries = yield* fetchPendingWaitlistEntries(clerkSecretKey, options.limit); + + yield* Effect.logInfo( + options.invite ? "Connect GA waitlist invitations starting" : "Connect GA dry run", + ).pipe( + Effect.annotateLogs({ + pendingEntries: entries.length, + }), + ); + + if (!options.invite) { + for (const entry of entries) { + yield* Effect.logInfo("pending waitlist entry").pipe( + Effect.annotateLogs({ + waitlistEntryId: entry.id, + emailAddress: entry.email_address, + }), + ); + } + yield* Effect.logInfo("No invitation was sent. Re-run with --invite after reviewing the list."); + return; + } + + for (const [index, entry] of entries.entries()) { + const invited = yield* inviteWaitlistEntry(clerkSecretKey, entry); + yield* Effect.logInfo("Clerk waitlist invitation sent").pipe( + Effect.annotateLogs({ + waitlistEntryId: invited.id, + completed: index + 1, + total: entries.length, + }), + ); + } +}); + +export const announceConnectGaCommand = Command.make( + "announce-connect-ga", + { + invite: Flag.boolean("invite").pipe( + Flag.withDefault(false), + Flag.withDescription( + "Invite pending entries through Clerk. Without this flag, only print a dry-run list.", + ), + ), + limit: Flag.integer("limit").pipe( + Flag.withSchema(PositiveInteger), + Flag.optional, + Flag.withDescription("Process at most this many pending waitlist entries."), + ), + }, + ({ invite, limit }) => + announceConnectGa({ + invite, + limit: Option.getOrUndefined(limit), + }), +).pipe( + Command.withDescription( + "Invite pending Clerk waitlist members now that T3 Connect is generally available.", + ), +); + +if (import.meta.main) { + Command.run(announceConnectGaCommand, { version: "0.0.0" }).pipe( + Effect.provide( + Layer.mergeAll( + Logger.layer([Logger.consolePretty()]), + NodeServices.layer, + FetchHttpClient.layer, + ), + ), + NodeRuntime.runMain, + ); +}