diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index ee983b44ef0..1debc8f7de7 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -43,6 +43,7 @@ import { getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, + getWindowFullscreenState, isNotificationSupported, openExternal, pickFolder, @@ -58,6 +59,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 22c8df8758b..55b9f52a2dd 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -10,6 +10,8 @@ export const DRAIN_NOTIFICATION_ACTIONS_CHANNEL = "desktop:drain-notification-ac export const ACK_NOTIFICATION_ACTIONS_CHANNEL = "desktop:ack-notification-actions"; export const NOTIFICATION_ACTION_CHANNEL = "desktop:notification-action"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; +export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index a67b3246fb4..13e6e8d3956 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -1,10 +1,14 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import type * as Electron from "electron"; + import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; -import { getLocalEnvironmentBootstraps } from "./window.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", @@ -126,3 +130,19 @@ describe("getLocalEnvironmentBootstraps", () => { }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); }); }); + +describe("getWindowFullscreenState", () => { + it.effect("reads the current native window state", () => { + const window = { isFullScreen: () => true } as Electron.BrowserWindow; + + return Effect.gen(function* () { + assert.isTrue(yield* getWindowFullscreenState.handler()); + }).pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + }), + ), + ); + }); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index cddd7fd9db1..b7fd80ee585 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -58,6 +58,16 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.getWindowFullscreenState")(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.currentMainOrFirst; + return Option.isSome(window) && window.value.isFullScreen(); + }), +}); + export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 8bbe50284de..84242accb46 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -171,6 +171,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + getWindowFullscreenState: () => + ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, + onWindowFullscreenStateChange: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => { + if (typeof fullscreen !== "boolean") return; + listener(fullscreen); + }; + + ipcRenderer.on(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); + }; + }, getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index cf24010eef5..19f1584e0fd 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -28,7 +28,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -46,6 +46,7 @@ const environmentInput = { } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; function makeFakeBrowserWindow() { + const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); const addWebContentsListener = ( eventName: string, @@ -75,13 +76,16 @@ function makeFakeBrowserWindow() { close: vi.fn(), focus: vi.fn(), isDestroyed: vi.fn(() => false), + isFullScreen: vi.fn(() => false), isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), loadURL: vi.fn((url: string) => { currentUrl = url; return Promise.resolve(); }), - on: vi.fn(), + on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), once: vi.fn(), restore: vi.fn(), setBackgroundColor: vi.fn(), @@ -100,6 +104,7 @@ function makeFakeBrowserWindow() { send: webContents.send, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, + windowListeners, }; } @@ -403,6 +408,37 @@ describe("DesktopWindow", () => { }), ); + it.effect("publishes native macOS fullscreen changes to the renderer", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen"); + const leaveFullscreen = fakeWindow.windowListeners.get("leave-full-screen"); + if (!enterFullscreen || !leaveFullscreen) { + return yield* Effect.die("fullscreen listeners were not registered"); + } + + enterFullscreen(); + leaveFullscreen(); + assert.deepEqual(fakeWindow.send.mock.calls, [ + [WINDOW_FULLSCREEN_STATE_CHANNEL, true], + [WINDOW_FULLSCREEN_STATE_CHANNEL, false], + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("recovers when the development renderer is temporarily unreachable", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 7ad9f6f19b2..899e584ea19 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -18,7 +18,7 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; const TITLEBAR_HEIGHT = 40; @@ -497,6 +497,15 @@ export const make = Effect.gen(function* () { window.setTitle(environment.displayName); }); + if (environment.platform === "darwin") { + window.on("enter-full-screen", () => { + window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); + }); + window.on("leave-full-screen", () => { + window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false); + }); + } + let developmentLoadRetryIndex = 0; let developmentLoadRetryFiber: Fiber.Fiber | undefined; const clearDevelopmentLoadRetry = () => { diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 552216bf353..0eb865cb79b 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -35,8 +35,8 @@ vp run ios:dev ``` If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the -reduced-capability local build. Personal Team builds omit the widget extension, push entitlement, and -native Sign in with Apple entitlement; builds without this opt-in are unchanged. +reduced-capability local build. Personal Team builds omit the widget and share extensions, push +entitlement, and native Sign in with Apple entitlement; builds without this opt-in are unchanged. ```bash T3CODE_IOS_PERSONAL_TEAM=1 \ diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index b7795cff2db..a0cb3af4969 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -87,6 +87,9 @@ function resolveAppVariant(value: string | undefined): AppVariant { } const variant = VARIANT_CONFIG[APP_VARIANT]; +const iosBundleIdentifier = isIosPersonalTeamBuild + ? personalTeamBundleIdentifier! + : variant.iosBundleIdentifier; const firstNonEmpty = (...values: ReadonlyArray): string | undefined => values.find((value) => value !== undefined && value.trim().length > 0); const easProjectId = @@ -134,8 +137,8 @@ const dmSansFonts = { const widgetsPlugin: NonNullable[number] = [ "expo-widgets", { - bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, - groupIdentifier: `group.${variant.iosBundleIdentifier}`, + bundleIdentifier: `${iosBundleIdentifier}.widgets`, + groupIdentifier: `group.${iosBundleIdentifier}`, enablePushNotifications: true, // Agent activity can update many times an hour; without the // frequent-updates entitlement iOS throttles the update budget sooner. @@ -151,6 +154,30 @@ const widgetsPlugin: NonNullable[number] = [ }, ]; +const sharingPlugin: NonNullable[number] = [ + "expo-sharing", + { + ios: { + // Personal Teams cannot sign App Groups or extension targets. Keep the + // reduced-capability local build usable while release builds expose the + // real system share target. + enabled: !isIosPersonalTeamBuild, + extensionBundleIdentifier: `${iosBundleIdentifier}.sharing`, + appGroupId: `group.${iosBundleIdentifier}`, + activationRule: { + supportsText: true, + supportsWebUrlWithMaxCount: 1, + supportsImageWithMaxCount: 8, + }, + }, + android: { + enabled: true, + singleShareMimeTypes: ["text/plain", "image/*"], + multipleShareMimeTypes: ["image/*"], + }, + }, +]; + // These aliases match the fonts' PostScript names on iOS. Register the same // names on Android so React Native and the native composer use one set of // family names without waiting for runtime font loading. @@ -180,7 +207,7 @@ const config: ExpoConfig = { ios: { icon: variant.assets.iosIcon, supportsTablet: true, - bundleIdentifier: variant.iosBundleIdentifier, + bundleIdentifier: iosBundleIdentifier, // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` // does not fall back to a personal team (which cannot sign app groups, // Sign in with Apple, or push notification entitlements). @@ -242,6 +269,9 @@ const config: ExpoConfig = { ], "expo-secure-store", "expo-sqlite", + ...(isIosPersonalTeamBuild + ? [sharingPlugin] + : ["./plugins/withShareExtensionDisplayName.cjs", sharingPlugin]), [ "expo-notifications", { diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index 39e1874d5d7..1c8978d4e5b 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -7,7 +7,7 @@ public class T3TerminalModule: Module { // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug // logs so a stale native binary is distinguishable from a broken key pipeline. Constants([ - "hardwareKeyRevision": 2, + "hardwareKeyRevision": 3, ]) View(T3TerminalView.self) { diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 14cdb4b7802..191d08db28d 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -119,6 +119,21 @@ private enum TerminalHardwareKeyEncoder { } } +private enum TerminalInputSequence { + /// Terminal Enter is carriage return. Sending line feed instead is Ctrl+J, + /// which raw-mode TUIs may interpret as the literal J key. + static let carriageReturn = "\r" + + static func normalizingReturn(_ input: String) -> String { + switch input { + case "\n", "\r\n": + return carriageReturn + default: + return input + } + } +} + private final class TerminalInputField: UITextField { var onDeleteBackward: (() -> Void)? var onInsert: ((String) -> Void)? @@ -352,7 +367,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { if !string.isEmpty { - emitInput(string) + // Some software keyboards deliver Return through this delegate instead of + // textFieldShouldReturn, so normalize that path too. + emitInput(TerminalInputSequence.normalizingReturn(string)) return false } @@ -360,7 +377,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } public func textFieldShouldReturn(_ textField: UITextField) -> Bool { - emitInput("\n") + emitInput(TerminalInputSequence.carriageReturn) textField.text = "" return false } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 1f87560c8ca..5c5046cc582 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -91,6 +91,7 @@ "expo-paste-input": "^0.1.15", "expo-quick-actions": "^6.0.2", "expo-secure-store": "~56.0.4", + "expo-sharing": "~56.0.18", "expo-splash-screen": "~56.0.10", "expo-sqlite": "~56.0.5", "expo-symbols": "~56.0.6", diff --git a/apps/mobile/plugins/withShareExtensionDisplayName.cjs b/apps/mobile/plugins/withShareExtensionDisplayName.cjs new file mode 100644 index 00000000000..27c57925e8a --- /dev/null +++ b/apps/mobile/plugins/withShareExtensionDisplayName.cjs @@ -0,0 +1,93 @@ +"use strict"; + +// expo-sharing intentionally uses a fixed target name and also uses that +// internal target name as CFBundleDisplayName. Brand the extension while +// leaving its initially-empty signing team intact: Expo uses that state to +// select ios.appleTeamId and enable first-time profile provisioning. +// +// ORDERING: list this plugin BEFORE expo-sharing. Expo runs same-type mods in +// reverse registration order, so this executes after the extension exists. + +const fs = require("fs"); +const path = require("path"); +const { withDangerousMod, withXcodeProject } = require("expo/config-plugins"); + +const TARGET_NAME = "expo-sharing-extension"; + +function stripComments(section) { + return Object.fromEntries( + Object.entries(section ?? {}).filter(([key]) => !key.endsWith("_comment")), + ); +} + +function findByName(section, name) { + return Object.entries(stripComments(section)).find(([, value]) => value?.name === name) ?? null; +} + +function escapeXml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function withDisplayNamePlist(config, displayName) { + return withDangerousMod(config, [ + "ios", + (cfg) => { + const plistPath = path.join(cfg.modRequest.platformProjectRoot, TARGET_NAME, "Info.plist"); + if (!fs.existsSync(plistPath)) { + throw new Error( + `${TARGET_NAME}/Info.plist was not generated. Register withShareExtensionDisplayName before expo-sharing.`, + ); + } + const source = fs.readFileSync(plistPath, "utf8"); + const displayNamePattern = /(CFBundleDisplayName<\/key>\s*)[^<]*(<\/string>)/; + if (!displayNamePattern.test(source)) { + throw new Error(`Could not update CFBundleDisplayName in ${plistPath}.`); + } + const next = source.replace(displayNamePattern, `$1${escapeXml(displayName)}$2`); + if (next !== source) { + fs.writeFileSync(plistPath, next); + } + return cfg; + }, + ]); +} + +function withExtensionDisplayNameBuildSetting(config, displayName) { + return withXcodeProject(config, (cfg) => { + const objects = cfg.modResults.hash.project.objects; + const targetEntry = findByName(objects.PBXNativeTarget, TARGET_NAME); + if (!targetEntry) { + throw new Error( + `${TARGET_NAME} Xcode target was not generated. Register withShareExtensionDisplayName before expo-sharing.`, + ); + } + const target = targetEntry[1]; + const configurationList = stripComments(objects.XCConfigurationList)[ + target.buildConfigurationList + ]; + if (!configurationList) { + throw new Error(`Could not find build configurations for ${TARGET_NAME}.`); + } + const buildConfigurations = stripComments(objects.XCBuildConfiguration); + for (const reference of configurationList.buildConfigurations ?? []) { + const buildConfiguration = buildConfigurations[reference.value]; + if (buildConfiguration?.buildSettings) { + buildConfiguration.buildSettings.INFOPLIST_KEY_CFBundleDisplayName = `"${displayName}"`; + } + } + return cfg; + }); +} + +module.exports = function withShareExtensionDisplayName(config) { + const displayName = config.name; + return withExtensionDisplayNameBuildSetting( + withDisplayNamePlist(config, displayName), + displayName, + ); +}; diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index c76d3010081..1f198c33bf3 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -11,6 +11,7 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati import { RegistryContext } from "@effect/atom-react"; import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; +import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; @@ -26,7 +27,10 @@ const appLinking = { // ://expo-development-client/?url= — that URL addresses // the launcher, not app navigation. Without this filter it falls through // to the NotFound wildcard route on every dev launch. - filter: (url: string) => !url.includes("expo-development-client"), + // expo-sharing uses a private lifecycle URL only to wake the app. The + // persisted share inbox below owns navigation once the payload is durable. + filter: (url: string) => + !url.includes("expo-development-client") && !url.includes("://expo-sharing"), }; const Navigation = createStaticNavigation(RootStack); @@ -58,10 +62,12 @@ export default function App() { the system is in dark mode. */} {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} - + + + {/* Anchored-menu overlays render here — in-window, so the diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 3837e5dde56..9d66839e040 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -10,6 +10,7 @@ import { createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; +import { useEffect, useRef } from "react"; import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native"; import { useResolveClassNames } from "uniwind"; @@ -52,6 +53,11 @@ import { SettingsLegalDocumentExternalHeaderButton, } from "./features/settings/components/SettingsLegalDocumentRouteScreen"; import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts"; +import { useIncomingShare } from "./features/sharing/IncomingShareProvider"; +import { + EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + transitionIncomingSharePresentation, +} from "./features/sharing/incoming-share-presentation"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; @@ -275,12 +281,30 @@ function RootStackLayout(props: { readonly children: React.ReactNode; readonly state: NavigationState; }) { + const navigation = useNavigation(); + const { pendingShare } = useIncomingShare(); + const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); useAgentNotificationNavigation(); useThreadOutboxDrain(); // Presents the T3 Connect onboarding sheet after an in-session sign-in. useConnectOnboardingNavigation(); // Launcher app shortcuts: routes shortcut taps and tracks opened threads. useAppShortcuts(props.state); + useEffect(() => { + const topRouteName = props.state.routes[props.state.index]?.name; + const transition = transitionIncomingSharePresentation(sharePresentationRef.current, { + isShareSheetPresented: topRouteName === "NewTaskSheet", + pendingShareId: pendingShare?.id ?? null, + }); + sharePresentationRef.current = transition.state; + if (!transition.shareIdToPresent) { + return; + } + navigation.navigate("NewTaskSheet", { + screen: "NewTask", + params: { incomingShareId: transition.shareIdToPresent }, + }); + }, [navigation, pendingShare, props.state]); // Full pathname (sheets included) for keyboard-command scoping; the // workspace layout only reacts to the underlying non-overlay route. const path = getPathFromState(props.state, navigationPathConfig); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts new file mode 100644 index 00000000000..9e1480d1de9 --- /dev/null +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { NativeReviewDiffRow } from "./nativeReviewDiffSurface"; +import type { NativeReviewDiffFile } from "./nativeReviewDiffTypes"; +import { highlightNativeReviewDiffVisibleRows } from "./nativeReviewDiffHighlighter"; + +const TYPESCRIPT_FILE: NativeReviewDiffFile = { + id: "file-1", + path: "example.ts", + language: "typescript", + additions: 0, + deletions: 0, +}; + +function makeLine( + input: Pick, +): NativeReviewDiffRow { + return { + kind: "line", + fileId: TYPESCRIPT_FILE.id, + ...input, + }; +} + +function makeHunk(id: string): NativeReviewDiffRow { + return { + kind: "hunk", + id, + fileId: TYPESCRIPT_FILE.id, + text: "@@", + }; +} + +function highlight( + rows: ReadonlyArray, + alreadyHighlightedRowIds?: ReadonlySet, +) { + return highlightNativeReviewDiffVisibleRows({ + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark", + engine: "javascript", + firstRowIndex: 0, + lastRowIndex: rows.length - 1, + overscanRows: 0, + maxRows: 100, + alreadyHighlightedRowIds, + }); +} + +describe("highlightNativeReviewDiffVisibleRows", () => { + it("does not carry grammar state across hunk boundaries", async () => { + const exportRow = makeLine({ + id: "export-row", + content: "export async function run() {}", + change: "add", + oldLineNumber: null, + newLineNumber: 100, + }); + const rows = [ + makeHunk("hunk-1"), + makeLine({ + id: "import-open", + content: "import {", + change: "context", + oldLineNumber: 1, + newLineNumber: 1, + }), + makeLine({ + id: "import-entry", + content: " Model,", + change: "context", + oldLineNumber: 2, + newLineNumber: 2, + }), + makeHunk("hunk-2"), + exportRow, + ]; + + const [highlighted, standalone] = await Promise.all([ + highlight(rows), + highlight([makeHunk("standalone-hunk"), exportRow]), + ]); + + expect(highlighted.tokensByRowId[exportRow.id]).toEqual(standalone.tokensByRowId[exportRow.id]); + }); + + it("keeps grammar state across inline comment rows", async () => { + const openingRow = makeLine({ + id: "template-open", + content: "const message = `open", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }); + const closingRow = makeLine({ + id: "template-close", + content: "closed`;", + change: "add", + oldLineNumber: null, + newLineNumber: 2, + }); + const trailingRow = makeLine({ + id: "trailing-row", + content: "export const answer = 42;", + change: "add", + oldLineNumber: null, + newLineNumber: 3, + }); + const commentRow: NativeReviewDiffRow = { + kind: "comment", + id: "comment-1", + fileId: TYPESCRIPT_FILE.id, + commentText: "Review note", + }; + + const [withComment, contiguous] = await Promise.all([ + highlight([openingRow, commentRow, closingRow, trailingRow]), + highlight([openingRow, closingRow, trailingRow]), + ]); + + expect(withComment.tokensByRowId).toEqual(contiguous.tokensByRowId); + }); + + it("does not join unhighlighted rows across cached gaps", async () => { + const trailingRow = makeLine({ + id: "trailing-row", + content: "export const answer = 42;", + change: "add", + oldLineNumber: null, + newLineNumber: 3, + }); + const rows = [ + makeLine({ + id: "template-open", + content: "const message = `open", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }), + makeLine({ + id: "template-close", + content: "closed`;", + change: "add", + oldLineNumber: null, + newLineNumber: 2, + }), + trailingRow, + ]; + + const [highlighted, standalone] = await Promise.all([ + highlight(rows, new Set(["template-close"])), + highlight([trailingRow]), + ]); + + expect(highlighted.tokensByRowId[trailingRow.id]).toEqual( + standalone.tokensByRowId[trailingRow.id], + ); + }); + + it("keeps deletion grammar state out of addition rows", async () => { + const additionRow = makeLine({ + id: "addition-row", + content: "export const answer = 42;", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }); + const rows = [ + makeLine({ + id: "deletion-row", + content: "const removed = `open", + change: "delete", + oldLineNumber: 1, + newLineNumber: null, + }), + additionRow, + ]; + + const [highlighted, standalone] = await Promise.all([ + highlight(rows), + highlight([additionRow]), + ]); + + expect(highlighted.tokensByRowId[additionRow.id]).toEqual( + standalone.tokensByRowId[additionRow.id], + ); + }); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 6c8c957f541..14158e61c7d 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -56,6 +56,11 @@ interface NativeReviewDiffLineRow extends NativeReviewDiffRow { readonly content: string; } +interface IndexedNativeReviewDiffLineRow { + readonly row: NativeReviewDiffLineRow; + readonly rowIndex: number; +} + export interface NativeReviewDiffTokenChunk { readonly chunkIndex: number; readonly fileId: string; @@ -308,6 +313,58 @@ function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDi return row.kind === "line" && typeof row.fileId === "string" && typeof row.content === "string"; } +function hasConsecutiveLineNumbers( + previous: number | null | undefined, + next: number | null | undefined, +): boolean { + return typeof previous === "number" && typeof next === "number" && next === previous + 1; +} + +function hasOnlyCommentRowsBetween( + rows: ReadonlyArray, + previousRowIndex: number, + nextRowIndex: number, +): boolean { + for (let rowIndex = previousRowIndex + 1; rowIndex < nextRowIndex; rowIndex += 1) { + if (rows[rowIndex]?.kind !== "comment") { + return false; + } + } + return true; +} + +function canShareGrammarContext( + previous: IndexedNativeReviewDiffLineRow, + next: IndexedNativeReviewDiffLineRow, + rows: ReadonlyArray, +): boolean { + if ( + next.row.fileId !== previous.row.fileId || + !hasOnlyCommentRowsBetween(rows, previous.rowIndex, next.rowIndex) + ) { + return false; + } + + if (previous.row.change === "delete" || next.row.change === "delete") { + return ( + previous.row.change !== "add" && + next.row.change !== "add" && + hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) + ); + } + + if (previous.row.change === "add" || next.row.change === "add") { + return hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber); + } + + return ( + previous.row.change === "context" && + next.row.change === "context" && + hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) && + hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber) + ); +} + function groupLineRowsByFileId(rows: ReadonlyArray) { const rowsByFileId = new Map(); for (const row of rows) { @@ -360,7 +417,7 @@ export async function highlightNativeReviewDiffVisibleRows( const maxRows = input.maxRows ?? NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS; const startIndex = clampRowIndex(input.firstRowIndex - overscanRows, input.rows); const endIndex = clampRowIndex(input.lastRowIndex + overscanRows, input.rows); - const selectedRows: NativeReviewDiffLineRow[] = []; + const selectedRows: IndexedNativeReviewDiffLineRow[] = []; for ( let rowIndex = startIndex; @@ -374,12 +431,12 @@ export async function highlightNativeReviewDiffVisibleRows( !input.alreadyHighlightedRowIds?.has(row.id) && fileMap.has(row.fileId) ) { - selectedRows.push(row); + selectedRows.push({ row, rowIndex }); } } const tokensByRowId: Record> = {}; - let segmentRows: NativeReviewDiffLineRow[] = []; + let segmentRows: IndexedNativeReviewDiffLineRow[] = []; let segmentFile: NativeReviewDiffFile | undefined; const flushSegment = () => { @@ -389,27 +446,34 @@ export async function highlightNativeReviewDiffVisibleRows( return; } - const code = segmentRows.map((row) => row.content).join("\n"); + const code = segmentRows.map(({ row }) => row.content).join("\n"); const tokenLines = highlighter.tokenize(code, { lang: segmentFile.language, theme }); - segmentRows.forEach((row, rowIndex) => { + segmentRows.forEach(({ row }, rowIndex) => { tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); }); segmentRows = []; segmentFile = undefined; }; - for (const row of selectedRows) { + for (const selectedRow of selectedRows) { + const { row } = selectedRow; const file = fileMap.get(row.fileId); if (!file) { continue; } - if (segmentFile && segmentFile.id !== file.id) { + const previousRow = segmentRows.at(-1); + if ( + segmentFile && + (segmentFile.id !== file.id || + (previousRow !== undefined && + !canShareGrammarContext(previousRow, selectedRow, input.rows))) + ) { flushSegment(); } segmentFile = file; - segmentRows.push(row); + segmentRows.push(selectedRow); } flushSegment(); diff --git a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx new file mode 100644 index 00000000000..04d371e5d2f --- /dev/null +++ b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx @@ -0,0 +1,310 @@ +import Constants from "expo-constants"; +import * as Crypto from "expo-crypto"; +import { + clearSharedPayloads, + getResolvedSharedPayloadsAsync, + getSharedPayloads, + type ResolvedSharePayload, + type SharePayload, +} from "expo-sharing"; +import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { Alert, AppState, Platform } from "react-native"; + +import { + buildIncomingShareDraft, + type IncomingShareDestination, + type IncomingShareDraft, +} from "./incoming-share-model"; +import { IncomingShareInbox } from "./incoming-share-inbox"; +import { + loadIncomingShareDrafts, + removeIncomingShareDraft, + writeIncomingShareDraft, +} from "./incoming-share-storage"; + +type IncomingShareContextValue = { + readonly pendingShare: IncomingShareDraft | null; + readonly isLoading: boolean; + readonly error: Error | null; + readonly getShare: (shareId: string) => IncomingShareDraft | null; + readonly reserveShare: (shareId: string, destination: IncomingShareDestination) => Promise; + readonly releaseShareReservation: ( + shareId: string, + expectedDestination: IncomingShareDestination, + ) => Promise; + readonly consumeShare: (shareId: string) => Promise; + readonly refresh: () => Promise; +}; + +const IncomingShareContext = React.createContext(null); + +function receiveSharingEnabled(): boolean { + if (Platform.OS === "android") { + return true; + } + if (Platform.OS !== "ios") { + return false; + } + return Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true; +} + +async function resolvedPayloadsForImages(): Promise> { + try { + return await getResolvedSharedPayloadsAsync(); + } catch (error) { + // iOS already gives the containing app a copied file:// URL, so raw + // payloads remain usable. Android normally resolves content:// into a + // private cache file; its modern File API can still read the raw URI when + // resolution fails. + console.warn("[incoming-share] could not resolve shared file metadata", error); + return []; + } +} + +async function incomingShareIdForPayloads(payloads: ReadonlyArray): Promise { + const fingerprint = JSON.stringify( + payloads.map((payload) => ({ + shareType: payload.shareType, + mimeType: payload.mimeType ?? null, + value: payload.value, + })), + ); + const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, fingerprint); + return `share-${digest}`; +} + +async function readBase64(uri: string): Promise { + const { File } = await import("expo-file-system"); + return new File(uri).base64(); +} + +async function removeOwnedFile(uri: string): Promise { + if (!uri.startsWith("file:")) { + return; + } + try { + const { File } = await import("expo-file-system"); + const file = new File(uri); + if (file.exists) { + file.delete(); + } + } catch (error) { + console.warn("[incoming-share] could not remove temporary shared file", error); + } +} + +async function removeReplayedImagePayloadFiles( + payloads: ReadonlyArray, +): Promise { + const uris = new Set(); + for (const payload of payloads) { + if (payload.shareType === "image") { + uris.add(payload.value); + } + } + if (uris.size === 0) { + return; + } + const resolvedPayloads = await resolvedPayloadsForImages(); + for (const payload of resolvedPayloads) { + if (payload.shareType === "image" && payload.contentUri) { + uris.add(payload.contentUri); + } + } + await Promise.all([...uris].map(removeOwnedFile)); +} + +// Keep one operation queue across provider remounts (including development +// Strict Mode remounts) so two app lifecycle notifications cannot ingest the +// same native handoff independently. +const incomingShareInbox = new IncomingShareInbox({ + loadDrafts: loadIncomingShareDrafts, + writeDraft: writeIncomingShareDraft, + removeDraft: removeIncomingShareDraft, + getPayloads: getSharedPayloads, + clearPayloads: clearSharedPayloads, + buildDraft: async ({ payloads, id, createdAt }) => { + const cleanupUris = new Set(); + const resolvedPayloads = payloads.some((payload) => payload.shareType === "image") + ? await resolvedPayloadsForImages() + : []; + const draft = await buildIncomingShareDraft({ + payloads, + resolvedPayloads, + fileReader: { + readBase64, + removeOwnedFile: (uri) => { + cleanupUris.add(uri); + }, + }, + id, + createdAt, + }); + return { + draft, + cleanup: async () => { + await Promise.all([...cleanupUris].map(removeOwnedFile)); + }, + }; + }, + cleanupReplayedPayloads: removeReplayedImagePayloadFiles, + idForPayloads: incomingShareIdForPayloads, + now: () => new Date().toISOString(), + onClearError: (error) => { + console.warn("[incoming-share] could not acknowledge native payload", error); + }, + onCleanupError: (error) => { + console.warn("[incoming-share] could not remove temporary shared file", error); + }, +}); + +export function IncomingShareProvider(props: React.PropsWithChildren) { + const enabled = receiveSharingEnabled(); + const [drafts, setDrafts] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const refreshPromiseRef = useRef | null>(null); + const mountedRef = useRef(false); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const refresh = useCallback(async () => { + if (refreshPromiseRef.current) { + return refreshPromiseRef.current; + } + + const operation = (async () => { + try { + const snapshot = await incomingShareInbox.refresh({ ingestNative: enabled }); + if (mountedRef.current) { + setDrafts(snapshot); + setError(null); + } + } catch (cause) { + const persisted = await incomingShareInbox + .refresh({ ingestNative: false }) + .catch(() => null); + if (mountedRef.current) { + if (persisted) { + setDrafts(persisted); + } + setError(cause instanceof Error ? cause : new Error("Could not import shared content.")); + } + } + })().finally(() => { + refreshPromiseRef.current = null; + }); + + refreshPromiseRef.current = operation; + return operation; + }, [enabled]); + + useEffect(() => { + void refresh().finally(() => { + if (mountedRef.current) { + setIsLoading(false); + } + }); + }, [refresh]); + + const refreshOnAppActive = useEffectEvent(() => { + void refresh(); + }); + + useEffect(() => { + if (!enabled) { + return; + } + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") { + refreshOnAppActive(); + } + }); + return () => subscription.remove(); + }, [enabled]); + + useEffect(() => { + if (!error) { + return; + } + Alert.alert("Could not import shared content", error.message, [ + { text: "Dismiss", style: "cancel", onPress: () => setError(null) }, + { + text: "Retry", + onPress: () => { + setError(null); + void refresh(); + }, + }, + ]); + }, [error, refresh]); + + const consumeShare = useCallback(async (shareId: string) => { + const snapshot = await incomingShareInbox.consume(shareId); + if (mountedRef.current) { + setDrafts(snapshot); + } + }, []); + const reserveShare = useCallback( + async (shareId: string, destination: IncomingShareDestination) => { + const snapshot = await incomingShareInbox.reserve(shareId, destination); + if (mountedRef.current) { + setDrafts(snapshot); + } + }, + [], + ); + const releaseShareReservation = useCallback( + async (shareId: string, expectedDestination: IncomingShareDestination) => { + const snapshot = await incomingShareInbox.releaseReservation(shareId, expectedDestination); + if (mountedRef.current) { + setDrafts(snapshot); + } + }, + [], + ); + const getShare = useCallback( + (shareId: string) => drafts.find((draft) => draft.id === shareId) ?? null, + [drafts], + ); + + const value = useMemo( + () => ({ + pendingShare: drafts[0] ?? null, + isLoading, + error, + getShare, + releaseShareReservation, + reserveShare, + consumeShare, + refresh, + }), + [ + consumeShare, + drafts, + error, + getShare, + isLoading, + refresh, + releaseShareReservation, + reserveShare, + ], + ); + + return ( + {props.children} + ); +} + +export function useIncomingShare(): IncomingShareContextValue { + const value = React.use(IncomingShareContext); + if (value === null) { + throw new Error("useIncomingShare must be used within IncomingShareProvider."); + } + return value; +} diff --git a/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts b/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts new file mode 100644 index 00000000000..ff50c6a917a --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import type { SharePayload } from "expo-sharing"; + +import type { IncomingShareDraft } from "./incoming-share-model"; +import { IncomingShareInbox, type IncomingShareInboxDependencies } from "./incoming-share-inbox"; + +const PAYLOAD: SharePayload = { + shareType: "text", + mimeType: "text/plain", + value: "Fix this", +}; + +function draft(id: string, createdAt = "2026-07-16T08:00:00.000Z"): IncomingShareDraft { + return { + schemaVersion: 1, + id, + createdAt, + text: PAYLOAD.value, + attachments: [], + warnings: [], + }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function createHarness(overrides: Partial = {}) { + const persisted = new Map(); + let payloads: ReadonlyArray = [PAYLOAD]; + const dependencies: IncomingShareInboxDependencies = { + loadDrafts: async () => [...persisted.values()], + writeDraft: async (value) => { + persisted.set(value.id, value); + }, + removeDraft: async (shareId) => { + persisted.delete(shareId); + }, + getPayloads: () => payloads, + clearPayloads: () => { + payloads = []; + }, + buildDraft: async ({ id, createdAt }) => ({ + draft: draft(id, createdAt), + cleanup: async () => undefined, + }), + idForPayloads: async () => "share-stable", + now: () => "2026-07-16T08:00:00.000Z", + ...overrides, + }; + return { inbox: new IncomingShareInbox(dependencies), persisted }; +} + +describe("IncomingShareInbox", () => { + it("coalesces a replay of an already-persisted native handoff", async () => { + const buildDraft = vi.fn(async ({ id, createdAt }) => ({ + draft: draft(id, createdAt), + cleanup: async () => undefined, + })); + const cleanupReplayedPayloads = vi.fn(async () => undefined); + const { inbox, persisted } = createHarness({ buildDraft, cleanupReplayedPayloads }); + persisted.set("share-stable", draft("share-stable")); + + await expect(inbox.refresh({ ingestNative: true })).resolves.toEqual([draft("share-stable")]); + expect(buildDraft).not.toHaveBeenCalled(); + expect(cleanupReplayedPayloads).toHaveBeenCalledWith([PAYLOAD]); + }); + + it("serializes concurrent refreshes so one native payload creates one inbox item", async () => { + const building = deferred(); + const buildDraft = vi.fn(async ({ id, createdAt }) => { + await building.promise; + return { draft: draft(id, createdAt), cleanup: async () => undefined }; + }); + const { inbox } = createHarness({ buildDraft }); + + const first = inbox.refresh({ ingestNative: true }); + const second = inbox.refresh({ ingestNative: true }); + building.resolve(); + + await expect(Promise.all([first, second])).resolves.toEqual([ + [draft("share-stable")], + [draft("share-stable")], + ]); + expect(buildDraft).toHaveBeenCalledTimes(1); + }); + + it("orders consumption after an in-flight refresh without restoring stale state", async () => { + const building = deferred(); + const { inbox, persisted } = createHarness({ + buildDraft: async ({ id, createdAt }) => { + await building.promise; + return { draft: draft(id, createdAt), cleanup: async () => undefined }; + }, + }); + + const refresh = inbox.refresh({ ingestNative: true }); + const consume = inbox.consume("share-stable"); + building.resolve(); + + await expect(refresh).resolves.toEqual([draft("share-stable")]); + await expect(consume).resolves.toEqual([]); + expect([...persisted.values()]).toEqual([]); + }); + + it("consumes only the addressed share when another share has identical content", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-first", draft("share-first", "2026-07-16T07:59:00.000Z")); + persisted.set("share-second", draft("share-second")); + + await expect(inbox.refresh({ ingestNative: false })).resolves.toEqual([ + draft("share-second"), + draft("share-first", "2026-07-16T07:59:00.000Z"), + ]); + await expect(inbox.consume("share-second")).resolves.toEqual([ + draft("share-first", "2026-07-16T07:59:00.000Z"), + ]); + expect([...persisted.values()]).toEqual([draft("share-first", "2026-07-16T07:59:00.000Z")]); + }); + + it("keeps content-identical shares addressable by their own ids", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-open-flow", draft("share-open-flow", "2026-07-16T07:59:00.000Z")); + persisted.set("share-newer", draft("share-newer")); + + await expect(inbox.refresh({ ingestNative: false })).resolves.toEqual([ + draft("share-newer"), + draft("share-open-flow", "2026-07-16T07:59:00.000Z"), + ]); + }); + + it("does not acknowledge a supported payload when its durable write fails", async () => { + const clearPayloads = vi.fn(); + const cleanup = vi.fn(async () => undefined); + const { inbox } = createHarness({ + clearPayloads, + buildDraft: async ({ id, createdAt }) => ({ + draft: draft(id, createdAt), + cleanup, + }), + writeDraft: async () => { + throw new Error("disk full"); + }, + }); + + await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full"); + expect(clearPayloads).not.toHaveBeenCalled(); + expect(cleanup).not.toHaveBeenCalled(); + }); + + it("durably reserves a share for one project before draft import", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-stable", draft("share-stable")); + const destination = { environmentId: "environment-1", projectId: "project-1" }; + + await expect(inbox.reserve("share-stable", destination)).resolves.toEqual([ + { ...draft("share-stable"), destination }, + ]); + expect(persisted.get("share-stable")?.destination).toEqual(destination); + await expect(inbox.reserve("share-stable", destination)).resolves.toEqual([ + { ...draft("share-stable"), destination }, + ]); + }); + + it("rejects moving a reserved share to another project", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-stable", { + ...draft("share-stable"), + destination: { environmentId: "environment-1", projectId: "project-1" }, + }); + + await expect( + inbox.reserve("share-stable", { + environmentId: "environment-1", + projectId: "project-2", + }), + ).rejects.toThrow("already reserved"); + expect(persisted.get("share-stable")?.destination?.projectId).toBe("project-1"); + }); + + it("conditionally releases a reservation when its project is unavailable", async () => { + const { inbox, persisted } = createHarness(); + const destination = { environmentId: "environment-1", projectId: "project-1" }; + persisted.set("share-stable", { ...draft("share-stable"), destination }); + + await expect(inbox.releaseReservation("share-stable", destination)).resolves.toEqual([ + draft("share-stable"), + ]); + expect(persisted.get("share-stable")?.destination).toBeUndefined(); + + await expect( + inbox.reserve("share-stable", { + environmentId: "environment-2", + projectId: "project-2", + }), + ).resolves.toEqual([ + { + ...draft("share-stable"), + destination: { environmentId: "environment-2", projectId: "project-2" }, + }, + ]); + }); + + it("does not release a reservation that changed concurrently", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-stable", { + ...draft("share-stable"), + destination: { environmentId: "environment-2", projectId: "project-2" }, + }); + + await expect( + inbox.releaseReservation("share-stable", { + environmentId: "environment-1", + projectId: "project-1", + }), + ).rejects.toThrow("reservation changed"); + expect(persisted.get("share-stable")?.destination?.projectId).toBe("project-2"); + }); + + it("treats an already-removed share as an already-released reservation", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-other", draft("share-other")); + + await expect( + inbox.releaseReservation("share-stable", { + environmentId: "environment-1", + projectId: "project-1", + }), + ).resolves.toEqual([draft("share-other")]); + expect([...persisted.values()]).toEqual([draft("share-other")]); + }); +}); diff --git a/apps/mobile/src/features/sharing/incoming-share-inbox.ts b/apps/mobile/src/features/sharing/incoming-share-inbox.ts new file mode 100644 index 00000000000..1f61ea710bb --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-inbox.ts @@ -0,0 +1,193 @@ +import type { SharePayload } from "expo-sharing"; + +import { SerializedAsyncQueue } from "../../lib/serialized-async-queue"; +import { + hasIncomingShareContent, + type IncomingShareDestination, + type IncomingShareDraft, +} from "./incoming-share-model"; + +export interface IncomingShareInboxDependencies { + readonly loadDrafts: () => Promise>; + readonly writeDraft: (draft: IncomingShareDraft) => Promise; + readonly removeDraft: (shareId: string) => Promise; + readonly getPayloads: () => ReadonlyArray; + readonly clearPayloads: () => void; + readonly buildDraft: (input: { + readonly payloads: ReadonlyArray; + readonly id: string; + readonly createdAt: string; + }) => Promise<{ + readonly draft: IncomingShareDraft; + readonly cleanup: () => Promise; + }>; + readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray) => Promise; + readonly idForPayloads: (payloads: ReadonlyArray) => Promise; + readonly now: () => string; + readonly onClearError?: (error: unknown) => void; + readonly onCleanupError?: (error: unknown) => void; +} + +export function sortAndDedupeIncomingShares( + drafts: ReadonlyArray, +): ReadonlyArray { + const ids = new Set(); + return [...drafts] + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .filter((draft) => { + if (ids.has(draft.id)) { + return false; + } + ids.add(draft.id); + return true; + }); +} + +/** + * Serializes every durable inbox mutation. This prevents a stale storage load + * or a foreground refresh from restoring an item after it has been consumed. + */ +export class IncomingShareInbox { + private readonly operations = new SerializedAsyncQueue(); + + constructor(private readonly dependencies: IncomingShareInboxDependencies) {} + + private runExclusive(operation: () => Promise): Promise { + return this.operations.run(operation); + } + + private clearNativePayloads(): void { + try { + this.dependencies.clearPayloads(); + } catch (error) { + this.dependencies.onClearError?.(error); + } + } + + private async cleanup(operation: () => Promise): Promise { + try { + await operation(); + } catch (error) { + this.dependencies.onCleanupError?.(error); + } + } + + refresh(options: { readonly ingestNative: boolean }): Promise> { + return this.runExclusive(async () => { + const loaded = await this.dependencies.loadDrafts(); + const persisted = sortAndDedupeIncomingShares(loaded); + if (!options.ingestNative) { + return persisted; + } + + const payloads = this.dependencies.getPayloads(); + if (payloads.length === 0) { + return persisted; + } + + // A share extension payload remains available until the containing app + // acknowledges it. Use a content-derived id so a crash after the durable + // write but before acknowledgement reuses the same inbox item. + const shareId = await this.dependencies.idForPayloads(payloads); + if (loaded.some((draft) => draft.id === shareId)) { + if (this.dependencies.cleanupReplayedPayloads) { + await this.cleanup(() => this.dependencies.cleanupReplayedPayloads!(payloads)); + } + this.clearNativePayloads(); + return persisted; + } + + const built = await this.dependencies.buildDraft({ + payloads, + id: shareId, + createdAt: this.dependencies.now(), + }); + const { draft } = built; + if (!hasIncomingShareContent(draft)) { + // Unsupported native payloads cannot become actionable on retry and + // would otherwise reopen the project picker on every foreground. + await this.cleanup(built.cleanup); + this.clearNativePayloads(); + throw new Error( + draft.warnings[0] ?? "The shared content is not supported by the composer.", + ); + } + + // The durable inbox write is the transaction boundary. Never clear the + // native handoff first: a process termination must leave one recoverable + // copy on one side of the boundary. + await this.dependencies.writeDraft(draft); + await this.cleanup(built.cleanup); + this.clearNativePayloads(); + return sortAndDedupeIncomingShares([draft, ...persisted]); + }); + } + + consume(shareId: string): Promise> { + return this.runExclusive(async () => { + // The stable payload-derived id already coalesces retries of the same + // native handoff. Payload equality cannot identify duplicate handoffs: + // users may intentionally share identical content more than once. + await this.dependencies.removeDraft(shareId); + return sortAndDedupeIncomingShares(await this.dependencies.loadDrafts()); + }); + } + + reserve( + shareId: string, + destination: IncomingShareDestination, + ): Promise> { + return this.runExclusive(async () => { + const persisted = await this.dependencies.loadDrafts(); + const target = persisted.find((draft) => draft.id === shareId); + if (!target) { + throw new Error("The shared content is no longer available."); + } + if (target.destination) { + if ( + target.destination.environmentId !== destination.environmentId || + target.destination.projectId !== destination.projectId + ) { + throw new Error("The shared content is already reserved for another project draft."); + } + return sortAndDedupeIncomingShares(persisted); + } + + const reserved = { ...target, destination }; + await this.dependencies.writeDraft(reserved); + return sortAndDedupeIncomingShares( + persisted.map((draft) => (draft.id === shareId ? reserved : draft)), + ); + }); + } + + releaseReservation( + shareId: string, + expectedDestination: IncomingShareDestination, + ): Promise> { + return this.runExclusive(async () => { + const persisted = await this.dependencies.loadDrafts(); + const target = persisted.find((draft) => draft.id === shareId); + if (!target) { + // Conditional release is idempotent: if another operation already + // consumed the share, no reservation remains to clean up. + return sortAndDedupeIncomingShares(persisted); + } + if (!target.destination) { + return sortAndDedupeIncomingShares(persisted); + } + if ( + target.destination.environmentId !== expectedDestination.environmentId || + target.destination.projectId !== expectedDestination.projectId + ) { + throw new Error("The shared content reservation changed before it could be released."); + } + + const { destination: _destination, ...unreserved } = target; + await this.dependencies.writeDraft(unreserved); + return sortAndDedupeIncomingShares( + persisted.map((draft) => (draft.id === shareId ? unreserved : draft)), + ); + }); + } +} diff --git a/apps/mobile/src/features/sharing/incoming-share-model.test.ts b/apps/mobile/src/features/sharing/incoming-share-model.test.ts new file mode 100644 index 00000000000..07ede18b8ef --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-model.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "@t3tools/contracts"; +import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; + +import { buildIncomingShareDraft, hasIncomingShareContent } from "./incoming-share-model"; + +describe("incoming native shares", () => { + it("converts shared text, URLs, and images into a durable composer draft", async () => { + const image: SharePayload = { + shareType: "image", + value: "file:///shared/Screenshot.png", + mimeType: "image/png", + }; + const payloads: SharePayload[] = [ + { shareType: "text", value: "Please explain this error" }, + { shareType: "url", value: "https://example.com/issue/1" }, + { shareType: "text", value: "Please explain this error" }, + image, + ]; + const resolvedImage: ResolvedSharePayload = { + ...image, + contentUri: image.value, + contentType: "image", + contentMimeType: "image/png", + contentSize: 3, + originalName: "Screenshot.png", + }; + const removeOwnedFile = vi.fn(() => Promise.resolve()); + + const result = await buildIncomingShareDraft({ + id: "share-1", + createdAt: "2026-07-15T10:00:00.000Z", + payloads, + resolvedPayloads: [resolvedImage], + fileReader: { + readBase64: async () => "YWJj", + removeOwnedFile, + }, + }); + + expect(result).toEqual({ + schemaVersion: 1, + id: "share-1", + createdAt: "2026-07-15T10:00:00.000Z", + text: "Please explain this error\n\nhttps://example.com/issue/1", + attachments: [ + { + id: "share-1:image:3", + type: "image", + name: "Screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "data:image/png;base64,YWJj", + }, + ], + warnings: [], + }); + expect(removeOwnedFile).toHaveBeenCalledWith(image.value); + expect(hasIncomingShareContent(result)).toBe(true); + }); + + it("skips oversized images and releases the temporary native file", async () => { + const image: SharePayload = { + shareType: "image", + value: "file:///shared/huge.png", + mimeType: "image/png", + }; + const readBase64 = vi.fn(async () => "unused"); + const removeOwnedFile = vi.fn(() => Promise.resolve()); + + const result = await buildIncomingShareDraft({ + id: "share-2", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [image], + resolvedPayloads: [ + { + ...image, + contentUri: image.value, + contentType: "image", + contentMimeType: "image/png", + contentSize: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1, + originalName: "huge.png", + }, + ], + fileReader: { readBase64, removeOwnedFile }, + }); + + expect(result.attachments).toEqual([]); + expect(result.warnings).toEqual(["'huge.png' exceeds the 10 MB attachment limit."]); + expect(readBase64).not.toHaveBeenCalled(); + expect(removeOwnedFile).toHaveBeenCalledWith(image.value); + expect(hasIncomingShareContent(result)).toBe(false); + }); + + it("releases every temporary file when a share exceeds the attachment limit", async () => { + const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({ + shareType: "image" as const, + value: `file:///shared/${index}.png`, + mimeType: "image/png", + })); + const removeOwnedFile = vi.fn(() => Promise.resolve()); + const readBase64 = vi.fn(async () => "YWJj"); + + const result = await buildIncomingShareDraft({ + id: "share-3", + createdAt: "2026-07-15T10:00:00.000Z", + payloads, + resolvedPayloads: [], + fileReader: { readBase64, removeOwnedFile }, + }); + + expect(result.attachments).toHaveLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS); + expect(result.warnings).toEqual([ + `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`, + ]); + expect(readBase64).toHaveBeenCalledTimes(PROVIDER_SEND_TURN_MAX_ATTACHMENTS); + expect(removeOwnedFile).toHaveBeenCalledTimes(payloads.length); + }); + + it("maps duplicate image payloads to distinct resolved files", async () => { + const duplicate: SharePayload = { + shareType: "image", + value: "content://shared/screenshot", + mimeType: "image/png", + }; + const resolvedPayloads: ResolvedSharePayload[] = [ + { + ...duplicate, + contentUri: "file:///cache/first.png", + contentType: "image", + contentMimeType: "image/png", + contentSize: 3, + originalName: "first.png", + }, + { + ...duplicate, + contentUri: "file:///cache/second.png", + contentType: "image", + contentMimeType: "image/png", + contentSize: 3, + originalName: "second.png", + }, + ]; + const readBase64 = vi.fn(async (uri: string) => + uri.includes("first") ? "Zmlyc3Q=" : "c2Vjb25k", + ); + const removeOwnedFile = vi.fn(async () => undefined); + + const result = await buildIncomingShareDraft({ + id: "share-duplicates", + createdAt: "2026-07-16T08:00:00.000Z", + payloads: [duplicate, duplicate], + resolvedPayloads, + fileReader: { readBase64, removeOwnedFile }, + }); + + expect(readBase64.mock.calls.map(([uri]) => uri)).toEqual([ + "file:///cache/first.png", + "file:///cache/second.png", + ]); + expect(result.attachments.map((attachment) => attachment.name)).toEqual([ + "first.png", + "second.png", + ]); + expect(removeOwnedFile).toHaveBeenCalledWith("file:///cache/first.png"); + expect(removeOwnedFile).toHaveBeenCalledWith("file:///cache/second.png"); + }); + + it("keeps imported content when temporary-file cleanup fails", async () => { + const image: SharePayload = { + shareType: "image", + value: "file:///shared/screenshot.png", + mimeType: "image/png", + }; + + const result = await buildIncomingShareDraft({ + id: "share-cleanup-failure", + createdAt: "2026-07-16T08:00:00.000Z", + payloads: [image], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "YWJj", + removeOwnedFile: async () => { + throw new Error("file is busy"); + }, + }, + }); + + expect(result.attachments).toHaveLength(1); + expect(result.warnings).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/sharing/incoming-share-model.ts b/apps/mobile/src/features/sharing/incoming-share-model.ts new file mode 100644 index 00000000000..873574209fc --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-model.ts @@ -0,0 +1,217 @@ +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; + +import { DraftComposerImageAttachmentSchema } from "../../lib/composer-image-schema"; +import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import { estimateBase64ByteSize } from "../../lib/base64"; + +export interface IncomingShareDraft { + readonly schemaVersion: 1; + readonly id: string; + readonly createdAt: string; + readonly destination?: IncomingShareDestination; + readonly text: string; + readonly attachments: ReadonlyArray; + readonly warnings: ReadonlyArray; +} + +export interface IncomingShareDestination { + readonly environmentId: string; + readonly projectId: string; +} + +const IncomingShareDestinationSchema = Schema.Struct({ + environmentId: Schema.String, + projectId: Schema.String, +}); + +export const IncomingShareDraftSchema = Schema.Struct({ + schemaVersion: Schema.Literal(1), + id: Schema.String, + createdAt: Schema.String, + destination: Schema.optional(IncomingShareDestinationSchema), + text: Schema.String, + attachments: Schema.Array(DraftComposerImageAttachmentSchema), + warnings: Schema.Array(Schema.String), +}); + +const decodeIncomingShareDraftSync = Schema.decodeUnknownSync(IncomingShareDraftSchema); + +export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft { + return decodeIncomingShareDraftSync(value); +} + +export interface IncomingShareFileReader { + readonly readBase64: (uri: string) => Promise; + readonly removeOwnedFile: (uri: string) => Promise | void; +} + +function sharedText(payloads: ReadonlyArray): string { + const seen = new Set(); + const values: string[] = []; + for (const payload of payloads) { + if (payload.shareType !== "text" && payload.shareType !== "url") { + continue; + } + const value = payload.value.trim(); + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + values.push(value); + } + return values.join("\n\n"); +} + +function resolvedImageFor( + payload: SharePayload, + index: number, + resolvedPayloads: ReadonlyArray, + consumedIndexes: Set, +): ResolvedSharePayload | undefined { + const sameIndex = resolvedPayloads[index]; + if ( + !consumedIndexes.has(index) && + sameIndex?.shareType === payload.shareType && + sameIndex.value === payload.value + ) { + consumedIndexes.add(index); + return sameIndex; + } + const matchingIndex = resolvedPayloads.findIndex( + (candidate, candidateIndex) => + !consumedIndexes.has(candidateIndex) && + candidate.shareType === payload.shareType && + candidate.value === payload.value, + ); + if (matchingIndex < 0) { + return undefined; + } + consumedIndexes.add(matchingIndex); + return resolvedPayloads[matchingIndex]; +} + +async function releaseOwnedFiles( + fileReader: IncomingShareFileReader, + uris: ReadonlyArray, +): Promise { + for (const uri of new Set(uris.filter((candidate): candidate is string => Boolean(candidate)))) { + try { + await fileReader.removeOwnedFile(uri); + } catch { + // Temporary-file cleanup is best-effort and must never discard content + // that was successfully converted into a durable composer attachment. + } + } +} + +function fallbackName(uri: string, index: number, mimeType: string): string { + try { + const pathName = new URL(uri).pathname.split("/").findLast((segment) => segment.length > 0); + if (pathName) { + return decodeURIComponent(pathName); + } + } catch { + // Fall through to a deterministic attachment name. + } + const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png"; + return `shared-image-${index + 1}.${extension}`; +} + +export async function buildIncomingShareDraft(input: { + readonly payloads: ReadonlyArray; + readonly resolvedPayloads: ReadonlyArray; + readonly fileReader: IncomingShareFileReader; + readonly id: string; + readonly createdAt: string; +}): Promise { + const attachments: DraftComposerImageAttachment[] = []; + const warnings: string[] = []; + const consumedResolvedPayloadIndexes = new Set(); + let warnedAttachmentLimit = false; + + for (const [index, payload] of input.payloads.entries()) { + if (payload.shareType !== "image") { + continue; + } + const resolved = resolvedImageFor( + payload, + index, + input.resolvedPayloads, + consumedResolvedPayloadIndexes, + ); + const uri = resolved?.contentUri ?? payload.value; + if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + if (!warnedAttachmentLimit) { + warnings.push( + `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`, + ); + warnedAttachmentLimit = true; + } + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + continue; + } + + const mimeType = (resolved?.contentMimeType ?? payload.mimeType ?? "image/png").toLowerCase(); + if (!uri || !mimeType.startsWith("image/")) { + warnings.push("One shared item was not a supported image."); + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + continue; + } + if ( + resolved?.contentSize !== null && + resolved?.contentSize !== undefined && + resolved.contentSize > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + ) { + warnings.push( + `'${resolved.originalName ?? fallbackName(uri, index, mimeType)}' exceeds the 10 MB attachment limit.`, + ); + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + continue; + } + + try { + const base64 = await input.fileReader.readBase64(uri); + const sizeBytes = resolved?.contentSize ?? estimateBase64ByteSize(base64); + if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + warnings.push( + `'${resolved?.originalName ?? fallbackName(uri, index, mimeType)}' exceeds the 10 MB attachment limit.`, + ); + continue; + } + const dataUrl = `data:${mimeType};base64,${base64}`; + attachments.push({ + id: `${input.id}:image:${index}`, + type: "image", + name: resolved?.originalName ?? fallbackName(uri, index, mimeType), + mimeType, + sizeBytes, + dataUrl, + // The share provider's file is temporary. A data-backed preview keeps + // the composer valid after its source file and App Group entry are gone. + previewUri: dataUrl, + }); + } catch { + warnings.push(`Could not read '${fallbackName(uri, index, mimeType)}'.`); + } finally { + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + } + } + + return { + schemaVersion: 1, + id: input.id, + createdAt: input.createdAt, + text: sharedText(input.payloads), + attachments, + warnings, + }; +} + +export function hasIncomingShareContent(draft: IncomingShareDraft): boolean { + return draft.text.trim().length > 0 || draft.attachments.length > 0; +} diff --git a/apps/mobile/src/features/sharing/incoming-share-presentation.test.ts b/apps/mobile/src/features/sharing/incoming-share-presentation.test.ts new file mode 100644 index 00000000000..c46ee6eb300 --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-presentation.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + transitionIncomingSharePresentation, +} from "./incoming-share-presentation"; + +describe("incoming share presentation", () => { + it("does not reopen a dismissed share when refresh returns a new object for the same id", () => { + const presented = transitionIncomingSharePresentation(EMPTY_INCOMING_SHARE_PRESENTATION_STATE, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }); + expect(presented.shareIdToPresent).toBe("share-1"); + + const whilePresented = transitionIncomingSharePresentation(presented.state, { + isShareSheetPresented: true, + pendingShareId: "share-1", + }); + expect(whilePresented).toEqual({ state: presented.state, shareIdToPresent: null }); + + const dismissed = transitionIncomingSharePresentation(whilePresented.state, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }); + expect(dismissed.state.dismissedShareId).toBe("share-1"); + + expect( + transitionIncomingSharePresentation(dismissed.state, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }), + ).toEqual({ state: dismissed.state, shareIdToPresent: null }); + }); + + it("presents the next queued share immediately after the previous sheet closes", () => { + const first = transitionIncomingSharePresentation(EMPTY_INCOMING_SHARE_PRESENTATION_STATE, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }); + + const next = transitionIncomingSharePresentation(first.state, { + isShareSheetPresented: false, + pendingShareId: "share-2", + }); + expect(next.shareIdToPresent).toBe("share-2"); + expect(next.state).toEqual({ presentedShareId: "share-2", dismissedShareId: null }); + }); + + it("forgets dismissal after consumption so a later handoff may reuse the id", () => { + const dismissed = { + presentedShareId: null, + dismissedShareId: "share-1", + }; + const consumed = transitionIncomingSharePresentation(dismissed, { + isShareSheetPresented: false, + pendingShareId: null, + }); + expect(consumed.state).toEqual(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); + + expect( + transitionIncomingSharePresentation(consumed.state, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }).shareIdToPresent, + ).toBe("share-1"); + }); + + it("forgets a consumed presentation while its sheet is still mounted", () => { + const presented = { + presentedShareId: "share-1", + dismissedShareId: null, + }; + const consumed = transitionIncomingSharePresentation(presented, { + isShareSheetPresented: true, + pendingShareId: null, + }); + expect(consumed.state).toEqual(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); + + const replacementWhileOpen = transitionIncomingSharePresentation(consumed.state, { + isShareSheetPresented: true, + pendingShareId: "share-1", + }); + expect(replacementWhileOpen.shareIdToPresent).toBeNull(); + expect( + transitionIncomingSharePresentation(replacementWhileOpen.state, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }).shareIdToPresent, + ).toBe("share-1"); + }); +}); diff --git a/apps/mobile/src/features/sharing/incoming-share-presentation.ts b/apps/mobile/src/features/sharing/incoming-share-presentation.ts new file mode 100644 index 00000000000..663d9dc17de --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-presentation.ts @@ -0,0 +1,71 @@ +export interface IncomingSharePresentationState { + readonly presentedShareId: string | null; + readonly dismissedShareId: string | null; +} + +export interface IncomingSharePresentationTransition { + readonly state: IncomingSharePresentationState; + readonly shareIdToPresent: string | null; +} + +export const EMPTY_INCOMING_SHARE_PRESENTATION_STATE: IncomingSharePresentationState = { + presentedShareId: null, + dismissedShareId: null, +}; + +/** + * Tracks presentation by durable share id rather than object identity. A user + * dismissal suppresses only that inbox item until it is consumed or replaced. + */ +export function transitionIncomingSharePresentation( + state: IncomingSharePresentationState, + input: { + readonly isShareSheetPresented: boolean; + readonly pendingShareId: string | null; + }, +): IncomingSharePresentationTransition { + if (input.isShareSheetPresented) { + if (state.presentedShareId !== null && input.pendingShareId !== state.presentedShareId) { + // Consumption may happen while the sheet remains mounted. Forget the + // old presentation immediately so a later handoff may reuse its id. + return { + state: EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + shareIdToPresent: null, + }; + } + return { state, shareIdToPresent: null }; + } + + let nextState = state; + if (state.presentedShareId !== null) { + if (input.pendingShareId === state.presentedShareId) { + return { + state: { + presentedShareId: null, + dismissedShareId: state.presentedShareId, + }, + shareIdToPresent: null, + }; + } + nextState = { ...state, presentedShareId: null }; + } + + if (input.pendingShareId === null) { + return { + state: EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + shareIdToPresent: null, + }; + } + + if (nextState.dismissedShareId === input.pendingShareId) { + return { state: nextState, shareIdToPresent: null }; + } + + return { + state: { + presentedShareId: input.pendingShareId, + dismissedShareId: null, + }, + shareIdToPresent: input.pendingShareId, + }; +} diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.ts b/apps/mobile/src/features/sharing/incoming-share-storage.ts new file mode 100644 index 00000000000..8364b4c98a4 --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-storage.ts @@ -0,0 +1,80 @@ +import * as Schema from "effect/Schema"; + +import { decodeIncomingShareDraft, type IncomingShareDraft } from "./incoming-share-model"; + +const INCOMING_SHARE_DIRECTORY = "incoming-shares"; + +export class IncomingShareStorageError extends Schema.TaggedErrorClass()( + "IncomingShareStorageError", + { + operation: Schema.Literals(["load", "write", "remove"]), + shareId: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Incoming share storage operation ${this.operation} failed for ${this.shareId ?? "unknown"}.`; + } +} + +function fileName(shareId: string): string { + return `${encodeURIComponent(shareId)}.json`; +} + +async function getDirectory() { + const { Directory, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, INCOMING_SHARE_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + return directory; +} + +async function getFile(shareId: string) { + const { File } = await import("expo-file-system"); + return new File(await getDirectory(), fileName(shareId)); +} + +export async function loadIncomingShareDrafts(): Promise> { + try { + const { File } = await import("expo-file-system"); + const drafts: IncomingShareDraft[] = []; + for (const entry of (await getDirectory()).list()) { + if (!(entry instanceof File) || !entry.name.endsWith(".json")) { + continue; + } + try { + drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown)); + } catch (cause) { + console.warn( + "[incoming-share] ignored invalid persisted share", + new IncomingShareStorageError({ operation: "load", shareId: null, cause }), + ); + } + } + return drafts.sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + } catch (cause) { + throw new IncomingShareStorageError({ operation: "load", shareId: null, cause }); + } +} + +export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promise { + try { + const file = await getFile(draft.id); + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(draft)); + } catch (cause) { + throw new IncomingShareStorageError({ operation: "write", shareId: draft.id, cause }); + } +} + +export async function removeIncomingShareDraft(shareId: string): Promise { + try { + const file = await getFile(shareId); + if (file.exists) { + file.delete(); + } + } catch (cause) { + throw new IncomingShareStorageError({ operation: "remove", shareId, cause }); + } +} diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 68d214193bd..69037514d0d 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -145,7 +145,8 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter onSubmitEditing={(event) => { const text = event.nativeEvent.text; if (text.length > 0) { - props.onInput(`${text}\n`); + // Terminal Enter is CR. LF is Ctrl+J and raw-mode TUIs can treat it as J. + props.onInput(`${text}\r`); } }} /> diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index a9c508f81d2..8e6819378a6 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = { readonly projectId?: string | string[]; readonly title?: string | string[]; readonly pendingTaskId?: string | string[]; + readonly incomingShareId?: string | string[]; }; export function NewTaskDraftRouteScreen({ route }: StaticScreenProps) { @@ -36,6 +37,9 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps state.isVisible); @@ -82,10 +98,94 @@ export function NewTaskDraftScreen(props: { const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [importingShareKey, setImportingShareKey] = useState(null); + const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); + const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null); + const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false); + const [shareImportAttempt, setShareImportAttempt] = useState(0); + const startedShareImportKeyRef = useRef(null); + const cancellingShareImportKeyRef = useRef(null); + const shareImportDraftBackupRef = useRef(new Map()); + const activeShareImportTokenRef = useRef(null); + const shareImportMountedRef = useRef(true); + const latestDraftKeyRef = useRef(flow.draftKey); + const latestIncomingShareIdRef = useRef(props.incomingShareId); + latestDraftKeyRef.current = flow.draftKey; + latestIncomingShareIdRef.current = props.incomingShareId; + const isImportingShare = importingShareKey !== null; + const alertedUnavailableIncomingShareIdRef = useRef(null); + const incomingShare = props.incomingShareId ? getShare(props.incomingShareId) : null; + const requestedInitialProjectAvailable = Boolean( + props.initialProjectRef?.environmentId && + props.initialProjectRef.projectId && + projects.some( + (project) => + project.environmentId === props.initialProjectRef?.environmentId && + project.id === props.initialProjectRef?.projectId, + ), + ); + const isProjectPickerReturnActive = + isReturningToProjectPicker && !requestedInitialProjectAvailable; + const isIncomingShareTransferPending = Boolean( + incomingShare && cancelledIncomingShareId !== props.incomingShareId, + ); + usePreventRemove( + (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport, + () => undefined, + ); + const hasImportedIncomingShare = Boolean( + props.incomingShareId && + flow.draftKey && + getComposerDraftSnapshot(flow.draftKey).importedShareIds?.includes(props.incomingShareId), + ); + const isIncomingShareUnavailable = Boolean( + props.incomingShareId && + !isIncomingShareInboxLoading && + !incomingShare && + !hasImportedIncomingShare, + ); + const isIncomingShareReady = + !props.incomingShareId || + (hasImportedIncomingShare && !incomingShare) || + isIncomingShareUnavailable; const appliedInitialProjectKeyRef = useRef(null); useEffect(() => { + if (cancelledIncomingShareId === props.incomingShareId) { + navigation.goBack(); + } + }, [cancelledIncomingShareId, navigation, props.incomingShareId]); + useEffect(() => { + if (!isReturningToProjectPicker) { + return; + } + if (requestedInitialProjectAvailable) { + setIsReturningToProjectPicker(false); + return; + } + // Let usePreventRemove commit its disabled state before replacing this + // route, otherwise the transfer guard can swallow the fallback action. + const frame = requestAnimationFrame(() => { + navigation.dispatch( + StackActions.replace("NewTask", { incomingShareId: props.incomingShareId }), + ); + }); + return () => cancelAnimationFrame(frame); + }, [ + isReturningToProjectPicker, + navigation, + props.incomingShareId, + requestedInitialProjectAvailable, + ]); + useEffect(() => { + if (!shareImportMountedRef.current) { + startedShareImportKeyRef.current = null; + } + shareImportMountedRef.current = true; return () => { appliedInitialProjectKeyRef.current = null; + shareImportMountedRef.current = false; + activeShareImportTokenRef.current = null; + cancellingShareImportKeyRef.current = null; }; }, []); @@ -163,6 +263,14 @@ export function NewTaskDraftScreen(props: { setProject(directProject); return; } + + if (projects.length > 0) { + // Never fall through to the flow provider's temporary first-project + // default. Return to the picker with the share id intact so the user + // can choose an available destination. + setIsReturningToProjectPicker(true); + } + return; } if (selectedProject) { @@ -179,6 +287,7 @@ export function NewTaskDraftScreen(props: { logicalProjects, projects, props.initialProjectRef, + props.incomingShareId, props.pendingTaskId, navigation, selectedProject, @@ -198,6 +307,205 @@ export function NewTaskDraftScreen(props: { void flow.loadBranches(); }, [flow.loadBranches, selectedProject]); + useEffect(() => { + const shareId = props.incomingShareId; + const draftKey = flow.draftKey; + const destinationProject = selectedProject; + const initialEnvironmentId = props.initialProjectRef?.environmentId; + const initialProjectId = props.initialProjectRef?.projectId; + const selectedProjectMatchesRoute = + !initialEnvironmentId || + !initialProjectId || + (destinationProject?.environmentId === initialEnvironmentId && + destinationProject.id === initialProjectId); + if ( + !shareId || + !draftKey || + !destinationProject || + !selectedProjectMatchesRoute || + cancelledIncomingShareId === shareId + ) { + return; + } + const importKey = `${shareId}:${draftKey}`; + if ( + startedShareImportKeyRef.current === importKey || + cancellingShareImportKeyRef.current === importKey + ) { + return; + } + + if (!incomingShare) { + if (isIncomingShareUnavailable && alertedUnavailableIncomingShareIdRef.current !== shareId) { + alertedUnavailableIncomingShareIdRef.current = shareId; + Alert.alert( + "Shared content unavailable", + "The shared content is no longer in the inbox. You can continue editing this task draft.", + ); + } + return; + } + + if (alertedUnavailableIncomingShareIdRef.current === shareId) { + alertedUnavailableIncomingShareIdRef.current = null; + } + startedShareImportKeyRef.current = importKey; + const draftBackup = + shareImportDraftBackupRef.current.get(importKey) ?? getComposerDraftSnapshot(draftKey); + shareImportDraftBackupRef.current.set(importKey, draftBackup); + const importToken = Symbol(importKey); + let didReserveShare = false; + let needsDraftRestore = false; + activeShareImportTokenRef.current = importToken; + setImportingShareKey(importKey); + void (async () => { + await reserveShare(shareId, { + environmentId: String(destinationProject.environmentId), + projectId: String(destinationProject.id), + }); + didReserveShare = true; + if ( + !shareImportMountedRef.current || + activeShareImportTokenRef.current !== importToken || + latestDraftKeyRef.current !== draftKey || + latestIncomingShareIdRef.current !== shareId + ) { + return; + } + needsDraftRestore = true; + const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, { + text: incomingShare.text, + attachments: incomingShare.attachments, + sourceShareId: shareId, + }); + if ( + !shareImportMountedRef.current || + activeShareImportTokenRef.current !== importToken || + latestDraftKeyRef.current !== draftKey || + latestIncomingShareIdRef.current !== shareId + ) { + // The durable reservation makes an interrupted transfer resume only + // in this project instead of copying into a second project draft. + return; + } + await consumeShare(shareId); + if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) { + return; + } + const warnings = [...incomingShare.warnings]; + if (skippedAttachmentCount > 0) { + warnings.push( + `${skippedAttachmentCount} shared image${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`, + ); + } + if (warnings.length > 0) { + Alert.alert("Some shared content was skipped", warnings.join("\n")); + } + shareImportDraftBackupRef.current.delete(importKey); + })() + .catch((error) => { + if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) { + return; + } + Alert.alert( + "Could not import shared content", + error instanceof Error ? error.message : "The shared content could not be saved.", + [ + { + text: "Cancel import", + style: "cancel", + onPress: () => { + const cancelImport = async (): Promise => { + if (!shareImportMountedRef.current) { + return; + } + // Latch synchronously before restoring the draft. The + // restore publishes atom state and can re-run the import + // effect before React commits the cancelling state update. + cancellingShareImportKeyRef.current = importKey; + setIsCancellingShareImport(true); + try { + if (needsDraftRestore) { + await restoreComposerDraftSnapshot(draftKey, draftBackup); + needsDraftRestore = false; + } + if (didReserveShare) { + await releaseShareReservation(shareId, { + environmentId: String(destinationProject.environmentId), + projectId: String(destinationProject.id), + }); + } + shareImportDraftBackupRef.current.delete(importKey); + if (shareImportMountedRef.current) { + setIsCancellingShareImport(false); + setCancelledIncomingShareId(shareId); + } + } catch (cancelError) { + if (!shareImportMountedRef.current) { + return; + } + Alert.alert( + "Could not cancel import", + cancelError instanceof Error + ? cancelError.message + : "The shared content could not be restored safely.", + [ + { + text: "Retry import", + onPress: () => { + cancellingShareImportKeyRef.current = null; + setIsCancellingShareImport(false); + setShareImportAttempt((attempt) => attempt + 1); + }, + }, + { + text: "Retry cancel", + onPress: () => void cancelImport(), + }, + ], + { cancelable: false }, + ); + } + }; + void cancelImport(); + }, + }, + { + text: "Retry", + onPress: () => setShareImportAttempt((attempt) => attempt + 1), + }, + ], + { cancelable: false }, + ); + }) + .finally(() => { + if (startedShareImportKeyRef.current === importKey) { + // Every terminal path, including an invalidated operation, must + // release the synchronous start latch so this transfer can retry. + startedShareImportKeyRef.current = null; + } + if (shareImportMountedRef.current && activeShareImportTokenRef.current === importToken) { + activeShareImportTokenRef.current = null; + setImportingShareKey(null); + } + }); + }, [ + consumeShare, + cancelledIncomingShareId, + flow.draftKey, + hasImportedIncomingShare, + incomingShare, + isIncomingShareInboxLoading, + isIncomingShareUnavailable, + props.incomingShareId, + props.initialProjectRef?.environmentId, + props.initialProjectRef?.projectId, + releaseShareReservation, + reserveShare, + selectedProject, + shareImportAttempt, + ]); + useEffect(() => { // Android starts with the collapsed composer pill (like an open thread) // and only expands/focuses when tapped. @@ -223,10 +531,11 @@ export function NewTaskDraftScreen(props: { flow.environments.map((environment) => ({ id: `environment:${environment.environmentId}`, title: environment.environmentLabel, + attributes: isIncomingShareTransferPending ? { disabled: true } : undefined, state: flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined, })), - [flow.environments, flow.selectedEnvironmentId], + [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending], ); const modelMenuActions = useMemo( @@ -391,20 +700,23 @@ export function NewTaskDraftScreen(props: { [currentBranchName, flow.selectedBranchName, flow.workspaceMode], ); function handleModelMenuAction(event: string) { - if (!event.startsWith("model:")) { + if (isIncomingShareTransferPending || !event.startsWith("model:")) { return; } flow.setSelectedModelKey(event.slice("model:".length)); } function handleEnvironmentMenuAction(event: string) { - if (!event.startsWith("environment:")) { + if (isIncomingShareTransferPending || !event.startsWith("environment:")) { return; } flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); } function handleOptionsMenuAction(event: string) { + if (isIncomingShareTransferPending) { + return; + } const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); if (providerOptions) { flow.setSelectedModelOptions(providerOptions); @@ -424,6 +736,9 @@ export function NewTaskDraftScreen(props: { } function handleWorkspaceMenuAction(event: string) { + if (isIncomingShareTransferPending) { + return; + } if (event.startsWith("workspace:mode:")) { flow.setWorkspaceMode( event.slice("workspace:mode:".length) as Parameters[0], @@ -444,6 +759,9 @@ export function NewTaskDraftScreen(props: { } async function handlePickImages(): Promise { + if (isIncomingShareTransferPending) { + return; + } const result = await pickComposerImages({ existingCount: flow.attachments.length }); if (result.images.length > 0) { flow.appendAttachments(result.images); @@ -526,8 +844,7 @@ export function NewTaskDraftScreen(props: { if (editingPendingTask) { flow.finishEditingPendingTask(); } else { - flow.setPrompt(""); - flow.clearAttachments(); + clearComposerDraftContent(draftKey); } navigation.getParent()?.goBack(); return; @@ -585,8 +902,7 @@ export function NewTaskDraftScreen(props: { } flow.finishEditingPendingTask(); } else { - flow.setPrompt(""); - flow.clearAttachments(); + clearComposerDraftContent(draftKey); } navigation.dispatch( StackActions.replace("Thread", { @@ -620,12 +936,15 @@ export function NewTaskDraftScreen(props: { Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && flow.prompt.trim().length > 0 && + isIncomingShareReady && + !isImportingShare && !flow.submitting && !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); const promptEditor = ( void handlePickImages()} showChevron={false} + disabled={isIncomingShareTransferPending} /> } label={flow.selectedModelOption?.label ?? "Model"} /> @@ -677,6 +998,7 @@ export function NewTaskDraftScreen(props: { > @@ -687,6 +1009,7 @@ export function NewTaskDraftScreen(props: { > @@ -697,6 +1020,7 @@ export function NewTaskDraftScreen(props: { > @@ -763,7 +1087,9 @@ export function NewTaskDraftScreen(props: { undefined : flow.removeAttachment + } /> ) : null} @@ -807,7 +1133,7 @@ export function NewTaskDraftScreen(props: { undefined : flow.removeAttachment} imageSize={88} imageBorderRadius={20} /> diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index ca857f6786a..99985385ea7 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,9 +1,9 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useNavigation } from "@react-navigation/native"; +import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import { useMemo } from "react"; -import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; +import { useEffect, useMemo, useRef } from "react"; +import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { cn } from "../../lib/cn"; @@ -16,6 +16,11 @@ import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { useIncomingShare } from "../sharing/IncomingShareProvider"; + +type NewTaskRouteParams = { + readonly incomingShareId?: string | string[]; +}; function deriveProjectEmptyState(catalogState: WorkspaceState): { readonly title: string; @@ -72,15 +77,29 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { }; } -export function NewTaskRouteScreen() { +export function NewTaskRouteScreen({ route }: StaticScreenProps) { const projects = useProjects(); const threads = useThreadShells(); const { state: catalogState } = useWorkspaceState(); const navigation = useNavigation(); + const isFocused = useIsFocused(); const { layout } = useAdaptiveWorkspaceLayout(); const insets = useSafeAreaInsets(); const chevronColor = useThemeColor("--color-chevron"); const accentColor = useThemeColor("--color-icon-muted"); + const { getShare, releaseShareReservation } = useIncomingShare(); + const routeShareId = Array.isArray(route.params?.incomingShareId) + ? route.params.incomingShareId[0] + : route.params?.incomingShareId; + const incomingShare = routeShareId ? getShare(routeShareId) : null; + const incomingShareSubtitle = incomingShare + ? incomingShare.attachments.length === 0 + ? "Choose a project for what you shared" + : incomingShare.attachments.length === 1 + ? "Choose a project for the image you shared" + : `Choose a project for the ${incomingShare.attachments.length} images you shared` + : null; + const screenTitle = incomingShare ? "Start a task" : "Choose project"; const repositoryGroups = useMemo( () => groupProjectsByRepository({ projects, threads }), [projects, threads], @@ -109,6 +128,70 @@ export function NewTaskRouteScreen() { return nextItems; }, [repositoryGroups]); const projectEmptyState = deriveProjectEmptyState(catalogState); + const resumedDestinationKeyRef = useRef(null); + const reservedDestinationProject = incomingShare?.destination + ? (projects.find( + (project) => + project.environmentId === incomingShare.destination?.environmentId && + project.id === incomingShare.destination?.projectId, + ) ?? null) + : null; + + async function selectProject(item: (typeof items)[number]): Promise { + if (incomingShare?.destination && !reservedDestinationProject) { + try { + await releaseShareReservation(incomingShare.id, incomingShare.destination); + } catch (error) { + Alert.alert( + "Could not change project", + error instanceof Error + ? error.message + : "The shared content reservation could not be updated.", + ); + return; + } + } + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: item.environmentId, + projectId: item.id, + title: item.title, + incomingShareId: incomingShare?.id, + }, + }); + } + + useEffect(() => { + const destination = incomingShare?.destination; + if (!destination) { + resumedDestinationKeyRef.current = null; + return; + } + if (!isFocused) { + // Returning from the reserved draft is a fresh resume attempt. Keeping + // this latch set would leave every project row disabled with no route. + resumedDestinationKeyRef.current = null; + return; + } + const destinationKey = `${incomingShare.id}:${destination.environmentId}:${destination.projectId}`; + if (resumedDestinationKeyRef.current === destinationKey) { + return; + } + if (!reservedDestinationProject) { + return; + } + resumedDestinationKeyRef.current = destinationKey; + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: reservedDestinationProject.environmentId, + projectId: reservedDestinationProject.id, + title: reservedDestinationProject.title, + incomingShareId: incomingShare.id, + }, + }); + }, [incomingShare, isFocused, navigation, reservedDestinationProject]); return ( @@ -117,7 +200,8 @@ export function NewTaskRouteScreen() { {/* Android renders its own in-screen header instead of the native bar. */} navigation.goBack() : undefined} actions={[ { @@ -129,21 +213,29 @@ export function NewTaskRouteScreen() { /> ) : ( - - {layout.usesSplitView ? ( + <> + + + {layout.usesSplitView ? ( + navigation.goBack()} + separateBackground + /> + ) : null} navigation.goBack()} + icon="plus" + onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} separateBackground /> - ) : null} - navigation.navigate("NewTaskSheet", { screen: "AddProject" })} - separateBackground - /> - + + )} - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { - environmentId: item.environmentId, - projectId: item.id, - title: item.title, - }, - }) - } + disabled={reservedDestinationProject !== null} + onPress={() => void selectProject(item)} className={cn( "bg-card px-4 py-3.5", !isFirst && "border-t border-border-subtle", diff --git a/apps/mobile/src/lib/base64.ts b/apps/mobile/src/lib/base64.ts new file mode 100644 index 00000000000..640e2260be4 --- /dev/null +++ b/apps/mobile/src/lib/base64.ts @@ -0,0 +1,4 @@ +export function estimateBase64ByteSize(base64: string): number { + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + return Math.floor((base64.length * 3) / 4) - padding; +} diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 40e00a271f7..21f2edaf52f 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -36,7 +36,37 @@ vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id", })); -import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; +import { + convertPastedImagesToAttachments, + isOwnedPastedImageUri, + toUploadChatImageAttachments, +} from "./composerImages"; + +describe("toUploadChatImageAttachments", () => { + it("strips client draft id and previewUri for the startTurn wire shape", () => { + expect( + toUploadChatImageAttachments([ + { + id: "client-draft-id", + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + previewUri: "file:///tmp/preview.png", + }, + ]), + ).toEqual([ + { + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + }, + ]); + }); +}); describe("native pasted image cleanup", () => { beforeEach(() => { diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 13b53af724e..5c79b5b5eb8 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -3,6 +3,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type UploadChatImageAttachment, } from "@t3tools/contracts"; +import { estimateBase64ByteSize } from "./base64"; import { uuidv4 } from "./uuid"; export interface DraftComposerImageAttachment extends UploadChatImageAttachment { @@ -10,13 +11,21 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment readonly previewUri: string; } -const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; - -function estimateBase64ByteSize(base64: string): number { - const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; - return Math.floor((base64.length * 3) / 4) - padding; +/** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ +export function toUploadChatImageAttachments( + attachments: ReadonlyArray, +): ReadonlyArray { + return attachments.map((attachment) => ({ + type: attachment.type, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: attachment.dataUrl, + })); } +const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; + async function loadImagePicker() { try { return await import("expo-image-picker"); diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index e3c2f744ada..85523175a2f 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -8,7 +8,7 @@ import { type RuntimeMode, } from "@t3tools/contracts"; -import type { DraftComposerImageAttachment } from "./composerImages"; +import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages"; export function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -55,7 +55,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe messageId: MessageId.make(spec.messageId), role: "user" as const, text: spec.text, - attachments: spec.attachments, + attachments: toUploadChatImageAttachments(spec.attachments), }, modelSelection: spec.modelSelection, titleSeed: title, diff --git a/apps/mobile/src/lib/serialized-async-queue.test.ts b/apps/mobile/src/lib/serialized-async-queue.test.ts new file mode 100644 index 00000000000..71dc9aa765a --- /dev/null +++ b/apps/mobile/src/lib/serialized-async-queue.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { SerializedAsyncQueue } from "./serialized-async-queue"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +describe("SerializedAsyncQueue", () => { + it("does not let a newer operation overtake an in-flight operation", async () => { + const queue = new SerializedAsyncQueue(); + const firstGate = deferred(); + const events: string[] = []; + + const first = queue.run(async () => { + events.push("first:start"); + await firstGate.promise; + events.push("first:end"); + }); + const second = queue.run(async () => { + events.push("second"); + }); + + await Promise.resolve(); + expect(events).toEqual(["first:start"]); + firstGate.resolve(); + await Promise.all([first, second]); + expect(events).toEqual(["first:start", "first:end", "second"]); + }); + + it("continues after a rejected operation", async () => { + const queue = new SerializedAsyncQueue(); + const first = queue.run(async () => { + throw new Error("failed"); + }); + const second = queue.run(async () => "recovered"); + + await expect(first).rejects.toThrow("failed"); + await expect(second).resolves.toBe("recovered"); + }); +}); diff --git a/apps/mobile/src/lib/serialized-async-queue.ts b/apps/mobile/src/lib/serialized-async-queue.ts new file mode 100644 index 00000000000..e989eb7da65 --- /dev/null +++ b/apps/mobile/src/lib/serialized-async-queue.ts @@ -0,0 +1,16 @@ +/** + * Runs asynchronous operations in call order while keeping the queue usable + * after an individual operation rejects. + */ +export class SerializedAsyncQueue { + private tail: Promise = Promise.resolve(); + + run(operation: () => Promise): Promise { + const result = this.tail.then(operation, operation); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index e2708757995..bbcbb8e4282 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -195,7 +195,7 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { if (typeName === "NativeHeaderToolbarButton") { return { type: "button", - label: "", + label: typeof child.props.label === "string" ? child.props.label : "", accessibilityLabel: typeof child.props.accessibilityLabel === "string" ? child.props.accessibilityLabel @@ -293,6 +293,7 @@ function NativeHeaderToolbarButton(_props: { readonly accessibilityLabel?: string; readonly disabled?: boolean; readonly icon?: string; + readonly label?: string; readonly onPress?: () => void; readonly separateBackground?: boolean; readonly tintColor?: ColorValue; diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index d02abb6a265..fdabe67bc71 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -8,7 +8,9 @@ import { decodePersistedComposerDrafts, type ComposerDraft, getComposerDraftSnapshot, + mergeComposerDraftContentState, removeComposerDraftsForEnvironment, + restoreComposerDraftSnapshotState, } from "./use-composer-drafts"; const DRAFT: ComposerDraft = { @@ -94,6 +96,7 @@ describe("mobile composer drafts", () => { const draft: ComposerDraft = { text: "send this", attachments: [], + importedShareIds: ["share-1"], modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", @@ -108,7 +111,8 @@ describe("mobile composer drafts", () => { expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({ [draftKey]: { - ...draft, + modelSelection: draft.modelSelection, + workspaceSelection: draft.workspaceSelection, text: "", attachments: [], }, @@ -131,6 +135,90 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); }); + it("merges shared content into a project draft without duplicating retries", () => { + const draftKey = "new-task:environment-1:project-1"; + const sharedAttachment = { + id: "share-1:image:0", + type: "image" as const, + name: "Screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "data:image/png;base64,YWJj", + }; + const existing: Record = { + [draftKey]: { text: "Existing context", attachments: [] }, + }; + const content = { + text: "Shared note", + attachments: [sharedAttachment], + sourceShareId: "share-1", + }; + + const merged = mergeComposerDraftContentState(existing, draftKey, content); + expect(merged[draftKey]).toMatchObject({ + text: "Existing context\n\nShared note", + attachments: [sharedAttachment], + importedShareIds: ["share-1"], + }); + expect(mergeComposerDraftContentState(merged, draftKey, content)).toBe(merged); + + const edited = { + ...merged, + [draftKey]: { ...merged[draftKey]!, text: "User edited the imported context" }, + }; + expect(mergeComposerDraftContentState(edited, draftKey, content)).toBe(edited); + }); + + it("preserves existing images when shared content exceeds the draft attachment limit", () => { + const draftKey = "new-task:environment-1:project-1"; + const image = (id: string) => ({ + id, + type: "image" as const, + name: `${id}.png`, + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "data:image/png;base64,YWJj", + }); + const existingImage = image("existing"); + const sharedImages = Array.from({ length: 8 }, (_, index) => image(`shared-${index}`)); + + const merged = mergeComposerDraftContentState( + { [draftKey]: { text: "", attachments: [existingImage] } }, + draftKey, + { text: "", attachments: sharedImages }, + ); + + expect(merged[draftKey]?.attachments).toHaveLength(8); + expect(merged[draftKey]?.attachments[0]).toEqual(existingImage); + expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-6"); + }); + + it("restores the exact draft captured before an interrupted share import", () => { + const draftKey = "new-task:environment-1:project-1"; + const beforeImport: ComposerDraft = { + text: "Existing context", + attachments: [], + runtimeMode: "approval-required", + }; + const imported: ComposerDraft = { + ...beforeImport, + text: "Existing context\n\nShared note", + importedShareIds: ["share-1"], + }; + + expect( + restoreComposerDraftSnapshotState({ [draftKey]: imported }, draftKey, beforeImport), + ).toEqual({ [draftKey]: beforeImport }); + expect( + restoreComposerDraftSnapshotState({ [draftKey]: imported }, draftKey, { + text: "", + attachments: [], + }), + ).toEqual({}); + }); + it("removes only drafts owned by the selected environment", () => { const environmentId = EnvironmentId.make("environment-cloud"); const retainedEnvironmentId = EnvironmentId.make("environment-local"); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index aa2e2dd5cca..cdef999e043 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { ModelSelection as ModelSelectionSchema, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode as ProviderInteractionModeSchema, RuntimeMode as RuntimeModeSchema, type EnvironmentId, @@ -14,6 +15,7 @@ import { Atom } from "effect/unstable/reactivity"; import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; @@ -38,12 +40,19 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass; + readonly importedShareIds?: ReadonlyArray; readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; } +export interface ComposerDraftContent { + readonly text: string; + readonly attachments: ReadonlyArray; + readonly sourceShareId?: string; +} + export interface ComposerDraftWorkspaceSelection { readonly mode: "local" | "worktree"; readonly branch: string | null; @@ -66,6 +75,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ const ComposerDraftSchema = Schema.Struct({ text: Schema.String, attachments: Schema.Array(DraftComposerImageAttachmentSchema), + importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), @@ -93,6 +103,7 @@ export const composerDraftsAtom = Atom.make>({}).p let loadPromise: Promise | null = null; let persistTimer: ReturnType | null = null; +const persistenceQueue = new SerializedAsyncQueue(); function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { if (!draft) { @@ -193,7 +204,7 @@ async function writePersistedComposerDrafts(drafts: Record): Promise { try { - await writePersistedComposerDrafts(drafts); + await persistenceQueue.run(() => writePersistedComposerDrafts(drafts)); } catch (error) { console.warn("[composer-drafts] failed to persist drafts", error); // Draft persistence is best-effort; in-memory drafts still keep working. @@ -366,8 +377,9 @@ export function clearComposerDraftContentState( if (!existing) { return current; } + const { importedShareIds: _importedShareIds, ...retained } = existing; const draft = { - ...existing, + ...retained, text: "", attachments: [], }; @@ -382,6 +394,138 @@ export function clearComposerDraftContentState( }; } +export function restoreComposerDraftSnapshotState( + current: Record, + draftKey: string, + snapshot: ComposerDraft, +): Record { + const next = { ...current }; + if (isEmptyDraft(snapshot)) { + delete next[draftKey]; + } else { + next[draftKey] = snapshot; + } + return next; +} + +function mergeComposerDraftText(existing: string, incoming: string): string { + if (incoming.length === 0) { + return existing; + } + if (existing.length === 0) { + return incoming; + } + // Import retries are possible after an interrupted native handoff. Keep the + // operation idempotent when the same shared text is already present. + if (existing === incoming || existing.endsWith(`\n\n${incoming}`)) { + return existing; + } + return `${existing}\n\n${incoming}`; +} + +export function mergeComposerDraftContentState( + current: Record, + draftKey: string, + content: ComposerDraftContent, +): Record { + const existing = normalizeDraft(current[draftKey]); + if (content.sourceShareId && existing.importedShareIds?.includes(content.sourceShareId)) { + return current; + } + const attachmentIds = new Set(existing.attachments.map((attachment) => attachment.id)); + const incomingAttachments = content.attachments.filter((attachment) => { + if (attachmentIds.has(attachment.id)) { + return false; + } + attachmentIds.add(attachment.id); + return true; + }); + const attachments = [...existing.attachments, ...incomingAttachments].slice( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + ); + const text = mergeComposerDraftText(existing.text, content.text); + const importedShareIds = content.sourceShareId + ? [...(existing.importedShareIds ?? []), content.sourceShareId] + : existing.importedShareIds; + if ( + text === existing.text && + attachments.length === existing.attachments.length && + importedShareIds === existing.importedShareIds + ) { + return current; + } + return { + ...current, + [draftKey]: { + ...existing, + text, + attachments, + ...(importedShareIds ? { importedShareIds } : {}), + }, + }; +} + +/** + * Atomically moves an incoming share into a project-scoped composer draft. + * The durable write happens before the share inbox item can be acknowledged. + */ +export async function mergeComposerDraftContent( + draftKey: string, + content: ComposerDraftContent, +): Promise<{ readonly skippedAttachmentCount: number }> { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } + if (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + } + const current = appAtomRegistry.get(composerDraftsAtom); + const next = mergeComposerDraftContentState(current, draftKey, content); + const currentAttachmentIds = new Set( + normalizeDraft(current[draftKey]).attachments.map((attachment) => attachment.id), + ); + const nextAttachmentIds = new Set( + normalizeDraft(next[draftKey]).attachments.map((attachment) => attachment.id), + ); + const skippedAttachmentCount = content.attachments.filter( + (attachment) => + !currentAttachmentIds.has(attachment.id) && !nextAttachmentIds.has(attachment.id), + ).length; + // Publish the content and its import receipt together before the filesystem + // await. Typing during persistence then builds on the receipt-bearing state, + // and its debounced write is serialized after this transaction. + if (next !== current) { + appAtomRegistry.set(composerDraftsAtom, next); + } + await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + return { skippedAttachmentCount }; +} + +/** Restores the exact content/settings captured before an interrupted import. */ +export async function restoreComposerDraftSnapshot( + draftKey: string, + snapshot: ComposerDraft, +): Promise { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } + if (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + } + const next = restoreComposerDraftSnapshotState( + appAtomRegistry.get(composerDraftsAtom), + draftKey, + snapshot, + ); + appAtomRegistry.set(composerDraftsAtom, next); + await persistenceQueue.run(() => writePersistedComposerDrafts(next)); +} + export function clearComposerDraftContent(draftKey: string): void { updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 1b4dfa47e77..3c2142fc452 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -17,6 +17,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; +import { toUploadChatImageAttachments } from "../lib/composerImages"; import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; import { useProjects, useThreadShells } from "./entities"; @@ -221,7 +222,7 @@ export function useThreadOutboxDrain(): void { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, - attachments: queuedMessage.attachments, + attachments: toUploadChatImageAttachments(queuedMessage.attachments), }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index f5ab794bce7..b59ded77f4f 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -6,6 +6,7 @@ import type { ProjectId, ThreadId, } from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as Effect from "effect/Effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; @@ -71,6 +72,30 @@ export function requireProjectAbsent(input: { ); } +export function requireActiveProjectWorkspaceRootAbsent(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly workspaceRoot: string; + readonly exceptProjectId?: ProjectId; +}): Effect.Effect { + const normalizedWorkspaceRoot = normalizeProjectPathForComparison(input.workspaceRoot); + const existingProject = input.readModel.projects.find( + (project) => + project.deletedAt === null && + normalizeProjectPathForComparison(project.workspaceRoot) === normalizedWorkspaceRoot && + project.id !== input.exceptProjectId, + ); + if (existingProject === undefined) { + return Effect.void; + } + return Effect.fail( + invariantError( + input.command.type, + `Active project '${existingProject.id}' already exists for workspace root '${normalizedWorkspaceRoot}'.`, + ), + ); +} + export function requireThread(input: { readonly readModel: OrchestrationReadModel; readonly command: OrchestrationCommand; diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 3f3a3ce9f12..3e59685d59b 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -171,6 +171,117 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { }), ); + it.effect("rejects project.create for an active workspace root that already exists", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const initial = createEmptyReadModel(now); + const readModel = yield* projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-existing"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-existing"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const failure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "project.create", + commandId: CommandId.make("cmd-project-create-duplicate-root"), + projectId: asProjectId("project-duplicate-root"), + title: "Duplicate Project", + workspaceRoot: "/tmp/project/", + createdAt: now, + }, + readModel, + }), + ); + + expect(failure.message).toContain( + "Active project 'project-existing' already exists for workspace root '/tmp/project'.", + ); + }), + ); + + it.effect("rejects project.meta.update when moving onto another active workspace root", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const initial = createEmptyReadModel(now); + const withFirstProject = yield* projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create-first"), + aggregateKind: "project", + aggregateId: asProjectId("project-first"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-first"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-first"), + metadata: {}, + payload: { + projectId: asProjectId("project-first"), + title: "First", + workspaceRoot: "/tmp/project-first", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + const readModel = yield* projectEvent(withFirstProject, { + sequence: 2, + eventId: asEventId("evt-project-create-second"), + aggregateKind: "project", + aggregateId: asProjectId("project-second"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-second"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-second"), + metadata: {}, + payload: { + projectId: asProjectId("project-second"), + title: "Second", + workspaceRoot: "/tmp/project-second", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const failure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-update-duplicate-root"), + projectId: asProjectId("project-second"), + workspaceRoot: "/tmp/project-first", + }, + readModel, + }), + ); + + expect(failure.message).toContain( + "Active project 'project-first' already exists for workspace root '/tmp/project-first'.", + ); + }), + ); + it.effect("emits user message and turn-start-requested events for thread.turn.start", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index d6e126e96af..b51f5ed990b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -13,6 +13,7 @@ import type * as PlatformError from "effect/PlatformError"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { listThreadsByProjectId, + requireActiveProjectWorkspaceRootAbsent, requireProject, requireProjectAbsent, requireThread, @@ -161,6 +162,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + yield* requireActiveProjectWorkspaceRootAbsent({ + readModel, + command, + workspaceRoot: command.workspaceRoot, + exceptProjectId: command.projectId, + }); return { ...(yield* withEventBase({ @@ -188,18 +195,25 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); - if ( - command.workspaceRoot !== undefined && - command.workspaceRoot !== project.workspaceRoot && - listThreadsByProjectId(readModel, command.projectId).some( - (thread) => - thread.worktreeRemovable === true && - (thread.worktreePath !== null || thread.worktreeRemovalPath !== null), - ) - ) { - return yield* new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `Project '${command.projectId}' cannot change workspace roots while it owns a removable thread worktree.`, + if (command.workspaceRoot !== undefined) { + if ( + command.workspaceRoot !== project.workspaceRoot && + listThreadsByProjectId(readModel, command.projectId).some( + (thread) => + thread.worktreeRemovable === true && + (thread.worktreePath !== null || thread.worktreeRemovalPath !== null), + ) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Project '${command.projectId}' cannot change workspace roots while it owns a removable thread worktree.`, + }); + } + yield* requireActiveProjectWorkspaceRootAbsent({ + readModel, + command, + workspaceRoot: command.workspaceRoot, + exceptProjectId: command.projectId, }); } const occurredAt = yield* nowIso; diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index f98759bad38..7aa5ab480e6 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1473,8 +1473,13 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { if (String(event.threadId) !== String(threadId)) { return; } - if (event.type === "request.opened" && !interrupted) { + if (event.type === "request.opened" && event.requestId && !interrupted) { interrupted = true; + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "cancel", + ); yield* adapter.interruptTurn(threadId); return; } @@ -1529,15 +1534,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { entry.result.outcome !== null && "outcome" in entry.result.outcome && entry.result.outcome.outcome === "cancelled"; - const cancelRequests = yield* waitForJsonLogMatch( - requestLogPath, - (entry) => entry.method === "session/cancel", - ); const approvalResponses = yield* waitForJsonLogMatch( requestLogPath, isCancelledApprovalResponse, ); - assert.isTrue(cancelRequests.some((entry) => entry.method === "session/cancel")); assert.isTrue(approvalResponses.some(isCancelledApprovalResponse)); yield* adapter.stopSession(threadId); diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index d3c7f6a8dab..6f89d86df88 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -25,6 +25,89 @@ describe("browser target resolver", () => { }); }); + it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:5173/dashboard?mode=test#results", + }), + ).toEqual({ + requestedUrl: "http://localhost:5173/dashboard?mode=test#results", + resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", + resolutionKind: "direct-private-network", + environmentId: "environment-1", + }); + }); + + it("preserves URL credentials when mapping localhost onto a remote host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://user:p%40ss@localhost:5173/dashboard", + }).resolvedUrl, + ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); + }); + + it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", + }).resolvedUrl, + ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); + }); + + it("maps schemeless localhost navigation onto a remote environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "localhost:3000/app", + }).resolvedUrl, + ).toBe("http://192.168.1.25:3000/app"); + }); + + it("keeps localhost navigation local for a local environment", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "localhost:3000/app", + }), + ).toEqual({ + requestedUrl: "localhost:3000/app", + resolvedUrl: "localhost:3000/app", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + + it("keeps localhost navigation local for the full IPv4 loopback range", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.2:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:3000/app", + }), + ).toEqual({ + requestedUrl: "http://localhost:3000/app", + resolvedUrl: "http://localhost:3000/app", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + it("refuses public relay hosts until the authenticated gateway exists", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -34,6 +117,12 @@ describe("browser target resolver", () => { port: 5173, }), ).toThrow(/authenticated preview gateway/); + expect(() => + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:5173", + }), + ).toThrow(/authenticated preview gateway/); }); it("normalizes schemeless localhost server-picker values", async () => { @@ -63,7 +152,9 @@ describe("browser target resolver", () => { }); it("supports private IPv6 environment hosts", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", + }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { @@ -71,7 +162,18 @@ describe("browser target resolver", () => { port: 5173, path: "/app?mode=test", }).resolvedUrl, - ).toBe("http://[::1]:5173/app?mode=test"); + ).toBe("http://[fd7a:115c:a1e0::53]:5173/app?mode=test"); + }); + + it("supports a local IPv6 environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + }).resolvedUrl, + ).toBe("http://[::1]:5173/"); }); it("leaves malformed input for the normal navigation error path", async () => { diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 9142cce1e72..9b201dbdbae 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -7,43 +7,60 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { readPreparedConnection } from "~/state/session"; +const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); + +const parseIpv4Address = (host: string): readonly number[] | null => { + const parts = normalizeHostname(host).split(".").map(Number); + return parts.length === 4 && + parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) + ? parts + : null; +}; + +const isLocalLoopbackHost = (host: string): boolean => { + const normalized = normalizeHostname(host); + if (normalized === "localhost" || normalized === "::1") return true; + return parseIpv4Address(normalized)?.[0] === 127; +}; + const isPrivateNetworkHost = (host: string): boolean => { - const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); - if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { + const normalized = normalizeHostname(host); + if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) { return true; } if (normalized.endsWith(".ts.net")) return true; - const parts = normalized.split(".").map(Number); - if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + const parts = parseIpv4Address(normalized); + if (parts) { + return ( + parts[0] === 10 || + (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) + ); + } + const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; + if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; + const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); return ( - parts[0] === 10 || - (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || - (parts[0] === 192 && parts[1] === 168) || - parts[0] === 127 || - (parts[0] === 169 && parts[1] === 254) + Number.isInteger(firstIpv6Hextet) && + ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) ); }; -const isLocalLoopbackHost = (host: string): boolean => { - const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); - return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + return new URL(connection.httpBaseUrl); }; -export function resolveBrowserNavigationTarget( +const resolveEnvironmentPortTarget = ( environmentId: EnvironmentId, - target: BrowserNavigationTarget, -): PreviewUrlResolution { - if (target.kind === "url") { - return { - requestedUrl: target.url, - resolvedUrl: target.url, - resolutionKind: "direct", - environmentId, - }; - } - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.httpBaseUrl); + target: Extract, + environmentUrl: URL, + requestedUrl?: string, + sourceUrl?: URL, +): PreviewUrlResolution => { if (!isPrivateNetworkHost(environmentUrl.hostname)) { throw new Error( "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", @@ -51,40 +68,72 @@ export function resolveBrowserNavigationTarget( } const protocol = target.protocol ?? "http"; const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; - const requestedUrl = `${protocol}://localhost:${target.port}${path}`; const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); const resolvedHost = normalizedEnvironmentHost.includes(":") ? `[${normalizedEnvironmentHost}]` : normalizedEnvironmentHost; - const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + const resolved = sourceUrl + ? new URL(sourceUrl) + : new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + if (sourceUrl) { + resolved.hostname = resolvedHost; + resolved.port = String(target.port); + } return { - requestedUrl, + requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`, resolvedUrl: resolved.toString(), - resolutionKind: - normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1" - ? "direct" - : "direct-private-network", + resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost) + ? "direct" + : "direct-private-network", environmentId, }; +}; + +export function resolveBrowserNavigationTarget( + environmentId: EnvironmentId, + target: BrowserNavigationTarget, +): PreviewUrlResolution { + if (target.kind === "url") { + let parsed: URL | null = null; + try { + parsed = new URL(normalizePreviewUrl(target.url)); + } catch { + // Preserve the existing direct-navigation behavior so the preview host + // reports malformed URL errors through its normal navigation path. + } + if (parsed && isLoopbackHost(parsed.hostname)) { + const environmentUrl = readEnvironmentUrl(environmentId); + if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { + return resolveEnvironmentPortTarget( + environmentId, + { + kind: "environment-port", + port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, + environmentUrl, + target.url, + parsed, + ); + } + } + return { + requestedUrl: target.url, + resolvedUrl: target.url, + resolutionKind: "direct", + environmentId, + }; + } + return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId)); } export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); - const parsed = new URL(normalizedUrl); - if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.httpBaseUrl); - if (parsed.hostname !== "0.0.0.0" && isLocalLoopbackHost(environmentUrl.hostname)) { - return normalizedUrl; - } - const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); return resolveBrowserNavigationTarget(environmentId, { - kind: "environment-port", - port, - protocol: parsed.protocol === "https:" ? "https" : "http", - path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + kind: "url", + url: normalizedUrl, }).resolvedUrl; } catch { return rawUrl; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 0f1a8f9d429..455cc9199f6 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; @@ -55,11 +55,35 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); + const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { + const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; + return isMacosDesktop && typeof getWindowFullscreenState === "function" + ? getWindowFullscreenState() + : false; + }); const macosWindowControlsStyle = - isElectron && isMacPlatform(navigator.platform) + isMacosDesktop && !isWindowFullscreen ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) : undefined; + useEffect(() => { + if (!isMacosDesktop) return; + const bridge = window.desktopBridge; + if (!bridge) return; + const { getWindowFullscreenState, onWindowFullscreenStateChange } = bridge; + if ( + typeof getWindowFullscreenState !== "function" || + typeof onWindowFullscreenStateChange !== "function" + ) { + return; + } + + const unsubscribe = onWindowFullscreenStateChange(setIsWindowFullscreen); + setIsWindowFullscreen(getWindowFullscreenState()); + return unsubscribe; + }, [isMacosDesktop]); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2421dc32a74..3d3ffdaf5ed 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -35,7 +35,7 @@ import React, { useState, type ReactNode, } from "react"; -import type { Components } from "react-markdown"; +import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import rehypeRaw from "rehype-raw"; @@ -64,6 +64,7 @@ import { serializeTableElementToCsv, serializeTableElementToMarkdown, } from "../markdown-clipboard"; +import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { normalizeMarkdownLinkDestination, resolveMarkdownFileLinkMeta, @@ -177,6 +178,24 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { }, } satisfies Parameters[0]; +const CHAT_MARKDOWN_REMARK_PLUGINS = [ + remarkGfm, + remarkNormalizeListItemIndentation, + remarkPreserveCodeMeta, +] satisfies NonNullable; + +const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ + remarkGfm, + remarkNormalizeListItemIndentation, + remarkBreaks, + remarkPreserveCodeMeta, +] satisfies NonNullable; + +const CHAT_MARKDOWN_REHYPE_PLUGINS = [ + rehypeRaw, + [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], +] satisfies NonNullable; + function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); const raw = match?.[1] ?? "text"; @@ -1579,11 +1598,9 @@ function ChatMarkdown({ > diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f60b92a0d65..40bc8417a96 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3458,8 +3458,9 @@ export default function Sidebar() { return buildPhysicalToLogicalProjectKeyMap({ projects: orderedProjects, settings: projectGroupingSettings, + primaryEnvironmentId, }); - }, [orderedProjects, projectGroupingSettings]); + }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); const projectPhysicalKeyByScopedRef = useMemo( () => new Map( diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 7f2d63e7ad2..460a253812a 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -1,5 +1,6 @@ import { findErrorTraceId } from "@t3tools/client-runtime/errors"; import { + type EnvironmentConnectionPresentation, RelayConnectionRegistration, RelayConnectionTarget, } from "@t3tools/client-runtime/connection"; @@ -10,7 +11,7 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as Option from "effect/Option"; -import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; import { environmentCatalog } from "~/connection/catalog"; import { cn } from "~/lib/utils"; @@ -22,6 +23,12 @@ import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRo import { Button } from "../ui/button"; import { Skeleton } from "../ui/skeleton"; import { toastManager } from "../ui/toast"; +import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation"; + +export interface SavedCloudEnvironmentConnection { + readonly environmentId: EnvironmentId; + readonly connection: EnvironmentConnectionPresentation; +} export function RemoteEnvironmentRowsSkeleton() { return ( @@ -40,19 +47,19 @@ export function RemoteEnvironmentRowsSkeleton() { /** * The user's T3 Connect environments from relay discovery, each with a * Connect button. The primary environment is always excluded; already-saved - * environments are hidden unless `showSavedAsConnected` renders them as - * connected instead (used by onboarding, where the full device mesh should be - * visible). + * environments are hidden unless `showSavedEnvironments` renders them with + * their live connection state (used by onboarding, where the full device mesh + * should be visible). */ export function CloudEnvironmentConnectRows({ primaryEnvironmentId, - savedEnvironmentIds, - showSavedAsConnected = false, + savedEnvironments, + showSavedEnvironments = false, empty = null, }: { readonly primaryEnvironmentId: EnvironmentId | null; - readonly savedEnvironmentIds: ReadonlyArray; - readonly showSavedAsConnected?: boolean; + readonly savedEnvironments: ReadonlyArray; + readonly showSavedEnvironments?: boolean; readonly empty?: ReactNode; }) { const environmentsState = useRelayEnvironmentDiscovery(); @@ -77,7 +84,9 @@ export function CloudEnvironmentConnectRows({ const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( null, ); - const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); + const savedById = new Map( + savedEnvironments.map((environment) => [environment.environmentId, environment]), + ); useEffect(() => { void refreshRelayEnvironments(); @@ -90,8 +99,8 @@ export function CloudEnvironmentConnectRows({ if (result._tag === "Success") { toastManager.add({ type: "success", - title: "Environment connected", - description: `${environment.label} is available through T3 Connect.`, + title: "Environment added", + description: `Connecting to ${environment.label} through T3 Connect.`, }); return; } @@ -121,10 +130,10 @@ export function CloudEnvironmentConnectRows({ const visibleEnvironments = [...environmentsState.environments.values()].filter( ({ environment }) => environment.environmentId !== primaryEnvironmentId && - (showSavedAsConnected || !savedIds.has(environment.environmentId)), + (showSavedEnvironments || !savedById.has(environment.environmentId)), ); - const standalone = showSavedAsConnected || savedEnvironmentIds.length === 0; + const standalone = showSavedEnvironments || savedEnvironments.length === 0; if ( standalone && @@ -163,31 +172,57 @@ export function CloudEnvironmentConnectRows({ } return visibleEnvironments.map(({ environment, availability, error }) => { - const alreadyConnected = savedIds.has(environment.environmentId); + const savedEnvironment = savedById.get(environment.environmentId); + const savedConnection = savedEnvironment + ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection) + : null; + const dotClassName = savedConnection + ? savedConnection.tone === "connected" + ? "bg-success" + : savedConnection.tone === "connecting" + ? "bg-warning" + : savedConnection.tone === "error" + ? "bg-destructive" + : "bg-muted-foreground/35" + : availability === "online" + ? "bg-success" + : availability === "error" + ? "bg-destructive" + : availability === "checking" + ? "bg-warning" + : "bg-muted-foreground/35"; + const statusText = savedConnection + ? savedConnection.statusText + : availability === "online" + ? "Available · Relay online" + : availability === "offline" + ? "Available · Relay offline" + : availability === "checking" + ? "Available · Checking relay status…" + : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable"); return (

{environment.label}

@@ -195,21 +230,19 @@ export function CloudEnvironmentConnectRows({

- {availability === "online" - ? "Available · Relay online" - : availability === "offline" - ? "Available · Relay offline" - : availability === "checking" - ? "Available · Checking relay status…" - : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")} + {statusText}

- {alreadyConnected ? ( + {savedConnection ? ( ) : (