diff --git a/.gitignore b/.gitignore index 6e5f8cc59c..3cca6666fc 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ apps/web/src/components/__screenshots__ .vitest-* __screenshots__/ .tanstack +.expo/ +/App.tsx +/app.json +ios/ diff --git a/REMOTE.md b/REMOTE.md index 9dc15ed1fe..37f81a9978 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -2,6 +2,29 @@ Use this when you want to open T3 Code from another device (phone, tablet, another laptop). +## Expo mobile remote + +This repo now includes an Expo app at `apps/mobile` that talks to the same T3 WebSocket orchestration API as the web client. + +Start it with: + +```bash +bun run dev:mobile +``` + +In the mobile app, enter: + +- `Server URL`: for example `http://192.168.1.42:3773` +- `Auth token`: the same token you passed to `--auth-token` when starting the server, if any + +The app derives the authenticated WebSocket URL automatically and lets you: + +- browse existing threads by project +- open a thread and watch assistant streaming output +- send the next user turn +- answer approval requests and pending user-input prompts +- stop a running turn + ## CLI ↔ Env option map The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables: @@ -50,6 +73,7 @@ Notes: - `--host 0.0.0.0` listens on all IPv4 interfaces. - `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions. - Ensure your OS firewall allows inbound TCP on the selected port. +- For the Expo app in `apps/mobile`, use the same HTTP origin in the connection form and paste the token into the token field. ## 2) Tailnet / Tailscale access @@ -66,3 +90,96 @@ Open from any device in your tailnet: `http://:3773` You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure. + +--- + +## Architecture Deep-Dive + +### Connection Establishment Sequence + +#### 1. CLI server (`apps/server/src/cli.ts`) + +- Binds the HTTP + WebSocket server directly on the selected host and port +- Uses `--auth-token` / `T3CODE_AUTH_TOKEN` for authenticated remote access +- Serves the same orchestration RPC surface the web app uses locally + +#### 2. Mobile — Connection Flow (`apps/mobile/src/app/useRemoteAppState.ts`) + +On app mount: + +1. **Load saved connection** from secure storage (`expo-secure-store` on native, `AsyncStorage` on web) +2. **Check for deep link** — if a QR or deep link is used, the URL scheme triggers the app with `serverUrl` + `authToken` +3. If neither exists, show the **connection editor sheet** +4. Once credentials are available: + - `resolveRemoteConnection()` normalizes the URL, infers ws/wss protocol, builds the WebSocket URL + - `preflightRemoteConnection()` does HTTP GET to `/api/remote/health` (5s timeout) + - Credentials saved to secure storage + - Creates `RemoteClient` and calls `connect()` + +### RPC Protocol (`apps/mobile/src/lib/remoteClient.ts`) + +Custom RPC message protocol over WebSocket: + +| Message Type | Purpose | +| ------------- | ------------------------------------ | +| **Request** | Unary RPC call (request -> response) | +| **Ack** | Response to a Request | +| **Chunk** | Streaming data (for subscriptions) | +| **Ping/Pong** | Keep-alive every 5s | +| **Exit** | Stream completion | +| **Defect** | Error response | + +**Two RPC patterns:** + +- **Unary**: `getSnapshot`, `dispatchCommand`, `getThreadMessagesPage` +- **Stream**: `subscribeOrchestrationDomainEvents` — server pushes Chunk messages as events occur + +### Real-Time Data Flow + +``` +Backend emits OrchestrationEvent + -> Server's subscribeOrchestrationDomainEvents stream + -> RPC Chunk message over WebSocket + -> Mobile RemoteClient.onChunk callback + -> useRemoteAppState.applyRealtimeEvent() + -> React state update -> UI re-renders +``` + +**Snapshot bootstrapping**: On connect, the client calls `getSnapshot` for the full read model (projects, threads, messages), then subscribes to domain events for incremental updates. Events are **sequence-ordered** — out-of-order events are buffered until the gap fills. + +### Sending Commands (Mobile -> Server) + +``` +User taps Send + -> enqueueThreadMessage() (optimistic UI update) + -> client.dispatchCommand("thread.turn.start", payload) + -> RPC Request sent directly to the CLI websocket server + -> Backend processes, emits events back through stream +``` + +### Reconnection & Resilience + +- **Exponential backoff**: 500ms -> 1s -> 2s -> 4s -> 8s (caps at 8s) +- **Ping/keep-alive**: Every 5s; closes socket if no pong within 5s +- **Request timeout**: 60s per request +- **Grace period**: 2.5s before showing "reconnecting" UI (avoids flash on brief drops) +- **Preflight errors**: 401 = bad token, 503 = backend not ready, network error = unreachable + +### Security Model + +- Token validated on every HTTP request and WebSocket upgrade +- Mobile stores credentials in `expo-secure-store` (encrypted on native) +- Auth via query param on WebSocket URL + +### Key Files + +| Layer | File | Role | +| ------ | ------------------------------------------ | ------------------------------------- | +| Shared | `packages/shared/src/remote.ts` | Deep link URL builder/parser | +| Mobile | `apps/mobile/src/lib/connection.ts` | URL resolution, preflight check | +| Mobile | `apps/mobile/src/lib/remoteClient.ts` | WebSocket RPC client | +| Mobile | `apps/mobile/src/lib/storage.ts` | Secure credential persistence | +| Mobile | `apps/mobile/src/app/useRemoteAppState.ts` | Central state management | +| Server | `apps/server/src/cli.ts` | Host/port/auth-token remote surface | +| Server | `apps/server/src/http.ts` | Remote health endpoint | +| Server | `apps/server/src/ws.ts` | RPC server endpoints, event streaming | diff --git a/apps/desktop/package.json b/apps/desktop/package.json index dfa3bde2f8..38a8e10ec7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -4,7 +4,7 @@ "private": true, "main": "dist-electron/main.js", "scripts": { - "dev": "bun run --parallel dev:bundle dev:electron", + "dev": "bun run scripts/dev.mjs", "dev:bundle": "tsdown --watch", "dev:electron": "bun run scripts/dev-electron.mjs", "build": "tsdown", diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 5244d51dbf..4f0aad6864 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -58,7 +58,7 @@ function startApp() { } const app = spawn( - resolveElectronPath(), + resolveElectronPath({ development: true }), [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], { cwd: desktopDir, diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs new file mode 100644 index 0000000000..30cef412d1 --- /dev/null +++ b/apps/desktop/scripts/dev.mjs @@ -0,0 +1,118 @@ +import { spawn } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const desktopDir = resolve(__dirname, ".."); +const bunExecutable = process.execPath; +const childScriptNames = ["dev:bundle", "dev:electron"]; +const forcedShutdownTimeoutMs = 1_500; + +const children = new Map(); +let shuttingDown = false; +let forcedShutdownTimer = null; +let exitCode = 0; +let exitSignal = null; + +function maybeExit() { + if (children.size > 0) { + return; + } + + if (forcedShutdownTimer !== null) { + clearTimeout(forcedShutdownTimer); + forcedShutdownTimer = null; + } + + if (exitSignal !== null) { + process.kill(process.pid, exitSignal); + return; + } + + process.exit(exitCode); +} + +function stopRemainingChildren() { + for (const child of children.values()) { + child.kill("SIGTERM"); + } + + if (forcedShutdownTimer !== null) { + return; + } + + forcedShutdownTimer = setTimeout(() => { + for (const child of children.values()) { + child.kill("SIGKILL"); + } + }, forcedShutdownTimeoutMs); + forcedShutdownTimer.unref(); +} + +function shutdown({ code = 0, signal = null } = {}) { + if (shuttingDown) { + if (code !== 0 && exitCode === 0) { + exitCode = code; + } + if (signal !== null && exitSignal === null) { + exitSignal = signal; + } + return; + } + + shuttingDown = true; + exitCode = code; + exitSignal = signal; + stopRemainingChildren(); + maybeExit(); +} + +function startChild(scriptName) { + const child = spawn(bunExecutable, ["run", scriptName], { + cwd: desktopDir, + env: process.env, + stdio: "inherit", + }); + + children.set(scriptName, child); + + child.once("error", (error) => { + console.error(`[desktop-dev] Failed to start ${scriptName}`, error); + children.delete(scriptName); + shutdown({ code: 1 }); + }); + + child.once("exit", (code, signal) => { + children.delete(scriptName); + + if (shuttingDown) { + if (code !== null && code !== 0 && exitCode === 0) { + exitCode = code; + } + if (signal !== null && exitSignal === null) { + exitSignal = signal; + } + maybeExit(); + return; + } + + if (signal !== null) { + shutdown({ signal }); + return; + } + + shutdown({ code: code ?? 1 }); + }); +} + +for (const scriptName of childScriptNames) { + startChild(scriptName); +} + +process.once("SIGINT", () => { + shutdown({ code: 130 }); +}); + +process.once("SIGTERM", () => { + shutdown({ code: 143 }); +}); diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 9d7c522781..10723e909f 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -132,11 +132,12 @@ function buildMacLauncher(electronBinaryPath) { return targetBinaryPath; } -export function resolveElectronPath() { +export function resolveElectronPath(options = {}) { + const development = options.development === true; const require = createRequire(import.meta.url); const electronBinaryPath = require("electron"); - if (process.platform !== "darwin") { + if (process.platform !== "darwin" || development) { return electronBinaryPath; } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3d61571df9..4d87f91626 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -73,6 +73,7 @@ const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code"; const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; +const shouldAutoOpenDevTools = process.env.T3_DESKTOP_OPEN_DEVTOOLS === "1"; const LOG_DIR = Path.join(STATE_DIR, "logs"); const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const LOG_FILE_MAX_FILES = 10; @@ -112,6 +113,7 @@ const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ }); const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo); +const hasSingleInstanceLock = app.requestSingleInstanceLock(); function logTimestamp(): string { return new Date().toISOString(); @@ -1407,7 +1409,9 @@ function createWindow(): BrowserWindow { if (isDevelopment) { void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); - window.webContents.openDevTools({ mode: "detach" }); + if (shouldAutoOpenDevTools) { + window.webContents.openDevTools({ mode: "detach" }); + } } else { void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); } @@ -1421,6 +1425,23 @@ function createWindow(): BrowserWindow { return window; } +function focusOrCreateMainWindow(): void { + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; + if (existingWindow && !existingWindow.isDestroyed()) { + if (existingWindow.isMinimized()) { + existingWindow.restore(); + } + if (!existingWindow.isVisible()) { + existingWindow.show(); + } + existingWindow.focus(); + mainWindow = existingWindow; + return; + } + + mainWindow = createWindow(); +} + // Override Electron's userData path before the `ready` event so that // Chromium session data uses a filesystem-friendly directory name. // Must be called synchronously at the top level — before `app.whenReady()`. @@ -1428,6 +1449,14 @@ app.setPath("userData", resolveUserDataPath()); configureAppIdentity(); +if (!hasSingleInstanceLock) { + app.quit(); +} else { + app.on("second-instance", () => { + focusOrCreateMainWindow(); + }); +} + async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap start"); backendPort = await Effect.service(NetService).pipe( @@ -1458,27 +1487,29 @@ app.on("before-quit", () => { restoreStdIoCapture?.(); }); -app - .whenReady() - .then(() => { - writeDesktopLogHeader("app ready"); - configureAppIdentity(); - configureApplicationMenu(); - registerDesktopProtocol(); - configureAutoUpdater(); - void bootstrap().catch((error) => { - handleFatalStartupError("bootstrap", error); - }); +if (hasSingleInstanceLock) { + app + .whenReady() + .then(() => { + writeDesktopLogHeader("app ready"); + configureAppIdentity(); + configureApplicationMenu(); + registerDesktopProtocol(); + configureAutoUpdater(); + void bootstrap().catch((error) => { + handleFatalStartupError("bootstrap", error); + }); - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - mainWindow = createWindow(); - } + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + focusOrCreateMainWindow(); + } + }); + }) + .catch((error) => { + handleFatalStartupError("whenReady", error); }); - }) - .catch((error) => { - handleFatalStartupError("whenReady", error); - }); +} app.on("window-all-closed", () => { if (process.platform !== "darwin" && !isQuitting) { diff --git a/apps/desktop/src/webSocketClose.test.ts b/apps/desktop/src/webSocketClose.test.ts new file mode 100644 index 0000000000..fe6ffb02ca --- /dev/null +++ b/apps/desktop/src/webSocketClose.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; + +import { closeWebSocket, isSendableWebSocketCloseCode } from "./webSocketClose"; + +type WebSocketReadyState = 0 | 1 | 2 | 3; + +function createSocket(readyState: WebSocketReadyState = WebSocket.OPEN) { + return { + readyState, + close: vi.fn(), + terminate: vi.fn(), + }; +} + +describe("isSendableWebSocketCloseCode", () => { + it("accepts standard and application close codes", () => { + expect(isSendableWebSocketCloseCode(1000)).toBe(true); + expect(isSendableWebSocketCloseCode(1011)).toBe(true); + expect(isSendableWebSocketCloseCode(4001)).toBe(true); + }); + + it("rejects reserved and out-of-range close codes", () => { + expect(isSendableWebSocketCloseCode(1005)).toBe(false); + expect(isSendableWebSocketCloseCode(1006)).toBe(false); + expect(isSendableWebSocketCloseCode(2000)).toBe(false); + }); +}); + +describe("closeWebSocket", () => { + it("forwards valid close frames", () => { + const socket = createSocket(); + + closeWebSocket(socket, 1011, "backend failed"); + + expect(socket.close).toHaveBeenCalledWith(1011, "backend failed"); + expect(socket.terminate).not.toHaveBeenCalled(); + }); + + it("omits empty reasons when forwarding a valid close code", () => { + const socket = createSocket(); + + closeWebSocket(socket, 1000, ""); + + expect(socket.close).toHaveBeenCalledWith(1000); + }); + + it("terminates on abnormal close codes that cannot be sent in a close frame", () => { + const socket = createSocket(); + + closeWebSocket(socket, 1006, Buffer.from("")); + + expect(socket.close).not.toHaveBeenCalled(); + expect(socket.terminate).toHaveBeenCalledTimes(1); + }); + + it("falls back to a plain close when no code is provided", () => { + const socket = createSocket(); + + closeWebSocket(socket); + + expect(socket.close).toHaveBeenCalledWith(); + expect(socket.terminate).not.toHaveBeenCalled(); + }); + + it("does nothing once the socket is already closing", () => { + const socket = createSocket(WebSocket.CLOSING); + + closeWebSocket(socket, 1000, "done"); + + expect(socket.close).not.toHaveBeenCalled(); + expect(socket.terminate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/webSocketClose.ts b/apps/desktop/src/webSocketClose.ts new file mode 100644 index 0000000000..b6a1016389 --- /dev/null +++ b/apps/desktop/src/webSocketClose.ts @@ -0,0 +1,40 @@ +import { WebSocket } from "ws"; + +type ClosableWebSocket = Pick; + +export type WebSocketCloseReason = Buffer | string; + +export function isSendableWebSocketCloseCode(code: number): boolean { + return ( + Number.isInteger(code) && + ((code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) || + (code >= 3000 && code <= 4999)) + ); +} + +export function closeWebSocket( + socket: ClosableWebSocket, + code?: number, + reason?: WebSocketCloseReason, +): void { + if (socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) { + return; + } + + if (code === undefined) { + socket.close(); + return; + } + + if (!isSendableWebSocketCloseCode(code)) { + socket.terminate(); + return; + } + + if (reason === undefined || reason.length === 0) { + socket.close(code); + return; + } + + socket.close(code, reason); +} diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000000..d914c328fe --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,41 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx new file mode 100644 index 0000000000..16f5500d5b --- /dev/null +++ b/apps/mobile/App.tsx @@ -0,0 +1 @@ +export { default } from "./src/app/App"; diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 0000000000..8d5dfca49d --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,68 @@ +{ + "expo": { + "name": "T3 Remote", + "slug": "t3-remote", + "scheme": "t3remote", + "version": "0.1.0", + "runtimeVersion": { + "policy": "appVersion" + }, + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "automatic", + "updates": { + "enabled": true, + "url": "https://u.expo.dev/525f875b-b769-4d0b-88d7-c55e97e42943", + "checkAutomatically": "ON_LOAD", + "fallbackToCacheTimeout": 0 + }, + "splash": { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "icon": "./assets/icon.png", + "supportsTablet": true, + "bundleIdentifier": "com.t3tools.t3remote", + "infoPlist": { + "NSAppTransportSecurity": { + "NSAllowsArbitraryLoads": true + }, + "ITSAppUsesNonExemptEncryption": false + } + }, + "android": { + "icon": "./assets/icon.png", + "package": "com.t3tools.t3remote", + "usesCleartextTraffic": true, + "adaptiveIcon": { + "backgroundColor": "#E6F4FE", + "foregroundImage": "./assets/android-icon-foreground.png", + "backgroundImage": "./assets/android-icon-background.png", + "monochromeImage": "./assets/android-icon-monochrome.png" + }, + "predictiveBackGestureEnabled": false + }, + "web": { + "favicon": "./assets/favicon.png" + }, + "plugins": [ + [ + "expo-splash-screen", + { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff", + "imageWidth": 220 + } + ], + "expo-secure-store" + ], + "extra": { + "eas": { + "projectId": "525f875b-b769-4d0b-88d7-c55e97e42943" + } + } + } +} diff --git a/apps/mobile/assets/android-icon-background.png b/apps/mobile/assets/android-icon-background.png new file mode 100644 index 0000000000..cb14e561ee Binary files /dev/null and b/apps/mobile/assets/android-icon-background.png differ diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png new file mode 100644 index 0000000000..0b521bbdac Binary files /dev/null and b/apps/mobile/assets/android-icon-foreground.png differ diff --git a/apps/mobile/assets/android-icon-monochrome.png b/apps/mobile/assets/android-icon-monochrome.png new file mode 100644 index 0000000000..f995ae17b9 Binary files /dev/null and b/apps/mobile/assets/android-icon-monochrome.png differ diff --git a/apps/mobile/assets/favicon.png b/apps/mobile/assets/favicon.png new file mode 100644 index 0000000000..f8b453334b Binary files /dev/null and b/apps/mobile/assets/favicon.png differ diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png new file mode 100644 index 0000000000..56b5934117 Binary files /dev/null and b/apps/mobile/assets/icon.png differ diff --git a/apps/mobile/assets/splash-icon.png b/apps/mobile/assets/splash-icon.png new file mode 100644 index 0000000000..56b5934117 Binary files /dev/null and b/apps/mobile/assets/splash-icon.png differ diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js new file mode 100644 index 0000000000..90f7d460dd --- /dev/null +++ b/apps/mobile/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: [["babel-preset-expo", { unstable_transformImportMeta: true }], "nativewind/babel"], + }; +}; diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 0000000000..6cbe93d497 --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,28 @@ +{ + "cli": { + "version": ">= 18.4.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "channel": "development", + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "channel": "preview", + "distribution": "internal" + }, + "production": { + "channel": "production", + "autoIncrement": true + } + }, + "submit": { + "production": { + "ios": { + "ascAppId": "6761315631" + } + } + } +} diff --git a/apps/mobile/global.css b/apps/mobile/global.css new file mode 100644 index 0000000000..0bbd6c14e4 --- /dev/null +++ b/apps/mobile/global.css @@ -0,0 +1,17 @@ +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/preflight.css" layer(base); +@import "tailwindcss/utilities.css" layer(utilities); + +@import "nativewind/theme"; + +@utility font-t3-medium { + font-weight: 500; +} + +@utility font-t3-bold { + font-weight: 700; +} + +@utility font-t3-extrabold { + font-weight: 800; +} diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts new file mode 100644 index 0000000000..828b356984 --- /dev/null +++ b/apps/mobile/index.ts @@ -0,0 +1,8 @@ +import { registerRootComponent } from "expo"; + +import App from "./App"; + +// registerRootComponent calls AppRegistry.registerComponent('main', () => App); +// It also ensures that whether you load the app in Expo Go or in a native build, +// the environment is set up appropriately +registerRootComponent(App); diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js new file mode 100644 index 0000000000..6ebd265808 --- /dev/null +++ b/apps/mobile/metro.config.js @@ -0,0 +1,10 @@ +const { getDefaultConfig } = require("expo/metro-config"); +const { withNativewind } = require("nativewind/metro"); + +/** @type {import("expo/metro-config").MetroConfig} */ +const config = getDefaultConfig(__dirname); + +module.exports = withNativewind(config, { + input: "./global.css", + globalClassNamePolyfill: true, +}); diff --git a/apps/mobile/nativewind-env.d.ts b/apps/mobile/nativewind-env.d.ts new file mode 100644 index 0000000000..c3e758c04b --- /dev/null +++ b/apps/mobile/nativewind-env.d.ts @@ -0,0 +1,3 @@ +/// + +// NOTE: This file is generated by react-native-css / NativeWind integration. diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000000..d644c3050a --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,56 @@ +{ + "name": "@t3tools/mobile", + "version": "0.0.0", + "private": true, + "main": "index.ts", + "scripts": { + "dev": "expo start --clear", + "start": "expo start", + "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", + "ios": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", + "web": "expo start --web", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@expo-google-fonts/dm-sans": "^0.4.2", + "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-masked-view/masked-view": "0.3.2", + "@sethwebster/react-ethereal-input": "^0.4.0", + "@shopify/flash-list": "2.0.2", + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:", + "expo": "~55.0.8", + "expo-clipboard": "~55.0.9", + "expo-constants": "^55.0.9", + "expo-font": "^55.0.4", + "expo-glass-effect": "~55.0.8", + "expo-haptics": "^55.0.9", + "expo-image-picker": "~55.0.14", + "expo-secure-store": "~55.0.9", + "expo-splash-screen": "~55.0.13", + "expo-symbols": "~55.0.5", + "expo-updates": "~55.0.16", + "nativewind": "^5.0.0-preview.3", + "punycode": "^2.3.1", + "react": "19.2.0", + "react-native": "0.83.2", + "react-native-css": "^3.0.6", + "react-native-markdown-display": "^7.0.2", + "react-native-reanimated": "4.2.1", + "react-native-safe-area-context": "~5.6.2", + "react-native-svg": "15.15.3", + "react-native-worklets": "0.7.2" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.2.2", + "@types/react": "~19.2.10", + "babel-preset-expo": "~55.0.8", + "postcss": "^8.5.8", + "tailwindcss": "^4.2.2", + "typescript": "~5.9.2" + }, + "overrides": { + "lightningcss": "1.30.1" + } +} diff --git a/apps/mobile/postcss.config.mjs b/apps/mobile/postcss.config.mjs new file mode 100644 index 0000000000..c2ddf74822 --- /dev/null +++ b/apps/mobile/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/apps/mobile/src/app/App.tsx b/apps/mobile/src/app/App.tsx new file mode 100644 index 0000000000..6897fa6650 --- /dev/null +++ b/apps/mobile/src/app/App.tsx @@ -0,0 +1,25 @@ +import { useColorScheme } from "react-native"; +import "./../../global.css"; + +import { SafeAreaProvider } from "react-native-safe-area-context"; + +import { LoadingScreen } from "../components/LoadingScreen"; +import { MobileAppShell } from "./MobileAppShell"; +import { useRemoteAppState } from "./useRemoteAppState"; + +export default function App() { + const colorScheme = useColorScheme(); + const isDarkMode = colorScheme !== "light"; + const app = useRemoteAppState(); + let content; + + if (app.isLoadingSavedConnection) { + content = ; + } else if (app.reconnectingScreenVisible) { + content = ; + } else { + content = ; + } + + return {content}; +} diff --git a/apps/mobile/src/app/MobileAppShell.tsx b/apps/mobile/src/app/MobileAppShell.tsx new file mode 100644 index 0000000000..89b4b4a7a8 --- /dev/null +++ b/apps/mobile/src/app/MobileAppShell.tsx @@ -0,0 +1,297 @@ +import type { OrchestrationThread } from "@t3tools/contracts"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { StatusBar, useWindowDimensions, View } from "react-native"; +import Animated, { + Easing, + interpolate, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +import { ConnectionSheet } from "../features/connection/ConnectionSheet"; +import { ThreadDetailScreen } from "../features/threads/ThreadDetailScreen"; +import { ThreadListScreen, type TransitionSourceFrame } from "../features/threads/ThreadListScreen"; +import type { RemoteAppModel } from "./useRemoteAppState"; + +function revealCenter( + sourceFrame: TransitionSourceFrame | null, + width: number, + height: number, +): { readonly x: number; readonly y: number } { + "worklet"; + if (!sourceFrame) { + return { + x: width / 2, + y: height / 2, + }; + } + + return { + x: sourceFrame.x + sourceFrame.width / 2, + y: sourceFrame.y + sourceFrame.height / 2, + }; +} + +function revealRadius(centerX: number, centerY: number, width: number, height: number): number { + "worklet"; + const distances = [ + Math.hypot(centerX, centerY), + Math.hypot(width - centerX, centerY), + Math.hypot(centerX, height - centerY), + Math.hypot(width - centerX, height - centerY), + ]; + + return Math.max(...distances); +} + +function useRevealTransition( + selectedThread: OrchestrationThread | null, + onSelectThread: (threadId: OrchestrationThread["id"]) => void, + onBackFromThread: () => void, + width: number, + height: number, +) { + const [transitionSource, setTransitionSource] = useState(null); + const [transitionPhase, setTransitionPhase] = useState<"idle" | "opening" | "closing">("idle"); + const revealProgress = useSharedValue(1); + const openingAnimationFrameRef = useRef(null); + + const revealMaskStyle = useAnimatedStyle(() => { + const center = revealCenter(transitionSource, width, height); + const startRadius = transitionSource + ? Math.max(transitionSource.width, transitionSource.height) * 0.3 + : 20; + const endRadius = revealRadius(center.x, center.y, width, height); + const radius = startRadius + (endRadius - startRadius) * revealProgress.value; + + return { + position: "absolute", + left: center.x - radius, + top: center.y - radius, + width: radius * 2, + height: radius * 2, + borderRadius: radius, + overflow: "hidden", + opacity: interpolate(revealProgress.value, [0, 0.12, 1], [0, 1, 1]), + }; + }); + + const revealContentStyle = useAnimatedStyle(() => { + const center = revealCenter(transitionSource, width, height); + const startRadius = transitionSource + ? Math.max(transitionSource.width, transitionSource.height) * 0.3 + : 20; + const endRadius = revealRadius(center.x, center.y, width, height); + const radius = startRadius + (endRadius - startRadius) * revealProgress.value; + + return { + width, + height, + transform: [{ translateX: -(center.x - radius) }, { translateY: -(center.y - radius) }], + }; + }); + + const handleSelectThread = useCallback( + (threadId: OrchestrationThread["id"], sourceFrame: TransitionSourceFrame | null): void => { + setTransitionSource(sourceFrame); + setTransitionPhase("opening"); + revealProgress.value = 0; + onSelectThread(threadId); + }, + [onSelectThread, revealProgress], + ); + + const handleBackFromThread = useCallback((): void => { + if (!selectedThread) { + return; + } + + setTransitionPhase("closing"); + revealProgress.value = withTiming( + 0, + { + duration: 260, + easing: Easing.inOut(Easing.cubic), + }, + (finished) => { + if (!finished) { + return; + } + runOnJS(onBackFromThread)(); + runOnJS(setTransitionPhase)("idle"); + runOnJS(setTransitionSource)(null); + revealProgress.value = 1; + }, + ); + }, [onBackFromThread, revealProgress, selectedThread]); + + useEffect(() => { + if (transitionPhase !== "opening" || !selectedThread) { + return; + } + + if (openingAnimationFrameRef.current !== null) { + cancelAnimationFrame(openingAnimationFrameRef.current); + } + + openingAnimationFrameRef.current = requestAnimationFrame(() => { + openingAnimationFrameRef.current = null; + revealProgress.value = withTiming( + 1, + { + duration: 420, + easing: Easing.out(Easing.cubic), + }, + (finished) => { + if (!finished) { + return; + } + runOnJS(setTransitionPhase)("idle"); + }, + ); + }); + + return () => { + if (openingAnimationFrameRef.current !== null) { + cancelAnimationFrame(openingAnimationFrameRef.current); + openingAnimationFrameRef.current = null; + } + }; + }, [selectedThread, revealProgress, transitionPhase]); + + return { + transitionPhase, + revealMaskStyle, + revealContentStyle, + handleSelectThread, + handleBackFromThread, + }; +} + +export function MobileAppShell(props: { + readonly app: RemoteAppModel; + readonly isDarkMode: boolean; +}) { + const { app } = props; + const { width, height } = useWindowDimensions(); + const backgroundColor = props.isDarkMode ? "#020617" : "#f8fafc"; + + const { + transitionPhase, + revealMaskStyle, + revealContentStyle, + handleSelectThread, + handleBackFromThread, + } = useRevealTransition( + app.selectedThread, + app.onSelectThread, + app.onBackFromThread, + width, + height, + ); + + const sharedDetailProps = app.selectedThread + ? { + selectedThread: app.selectedThread, + screenTone: app.screenTone, + connectionError: app.connectionError, + httpOrigin: app.httpOrigin, + resolvedAuthToken: app.resolvedAuthToken, + selectedThreadFeed: app.selectedThreadFeed, + selectedThreadFeedLoadingInitial: app.selectedThreadFeedLoadingInitial, + selectedThreadFeedLoadingMore: app.selectedThreadFeedLoadingMore, + selectedThreadFeedHasMore: app.selectedThreadFeedHasMore, + activeWorkDurationLabel: app.activeWorkDurationLabel, + activePendingApproval: app.activePendingApproval, + respondingApprovalId: app.respondingApprovalId, + activePendingUserInput: app.activePendingUserInput, + activePendingUserInputDrafts: app.activePendingUserInputDrafts, + activePendingUserInputAnswers: app.activePendingUserInputAnswers, + respondingUserInputId: app.respondingUserInputId, + draftMessage: app.draftMessage, + draftAttachments: app.draftAttachments, + connectionStateLabel: app.connectionState, + activeThreadBusy: app.activeThreadBusy, + selectedThreadQueueCount: app.selectedThreadQueueCount, + onBack: handleBackFromThread, + onOpenConnectionEditor: app.onOpenConnectionEditor, + onChangeDraftMessage: app.onChangeDraftMessage, + onPickDraftImages: app.onPickDraftImages, + onPasteIntoDraft: app.onPasteIntoDraft, + onRemoveDraftImage: app.onRemoveDraftImage, + onRefresh: app.onRefresh, + onLoadMoreFeed: app.onLoadMoreSelectedThreadFeed, + onRenameThread: app.onRenameThread, + onStopThread: app.onStopThread, + onSendMessage: app.onSendMessage, + onRespondToApproval: app.onRespondToApproval, + onSelectUserInputOption: app.onSelectUserInputOption, + onChangeUserInputCustomAnswer: app.onChangeUserInputCustomAnswer, + onSubmitUserInput: app.onSubmitUserInput, + } + : null; + + return ( + + + + + + + + {app.selectedThread && sharedDetailProps ? ( + + + {transitionPhase !== "idle" ? ( + + + + + + ) : null} + + ) : null} + + + + ); +} diff --git a/apps/mobile/src/app/useRemoteAppState.ts b/apps/mobile/src/app/useRemoteAppState.ts new file mode 100644 index 0000000000..c8b45723e7 --- /dev/null +++ b/apps/mobile/src/app/useRemoteAppState.ts @@ -0,0 +1,1522 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert, Linking } from "react-native"; + +import { + ApprovalRequestId, + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + MessageId, + type OrchestrationReadModel, + type OrchestrationThread, + ProjectId, + type ProviderApprovalDecision, + type ServerConfig as T3ServerConfig, + ThreadId, +} from "@t3tools/contracts"; +import { deriveActiveWorkStartedAt, formatElapsed } from "@t3tools/shared/orchestrationTiming"; +import { parseRemoteAppConnectionUrl } from "@t3tools/shared/remote"; + +import { connectionTone } from "../features/connection/connectionTone"; +import { screenTitle, threadSortValue } from "../features/threads/threadPresentation"; +import { newClientId } from "../lib/clientId"; +import { + preflightRemoteConnection, + resolveRemoteConnection, + type RemoteConnectionInput, +} from "../lib/connection"; +import { + applyOptimisticUserMessage, + applyRealtimeEvent, + requiresSnapshotRefresh, +} from "../lib/orchestration"; +import { type RemoteClientConnectionState, RemoteClient } from "../lib/remoteClient"; +import { + type DraftComposerImageAttachment, + pasteComposerClipboard, + pickComposerImages, +} from "../lib/composerImages"; +import { + clearSavedConnectionInput, + loadSavedConnectionInput, + saveConnectionInput, +} from "../lib/storage"; +import { + buildPendingUserInputAnswers, + buildThreadFeed, + derivePendingApprovals, + derivePendingUserInputs, + setPendingUserInputCustomAnswer, + type PendingUserInputDraftAnswer, + type QueuedThreadMessage, +} from "../lib/threadActivity"; +import { sortCopy } from "../lib/arrayCompat"; + +export interface RemoteAppModel { + readonly isLoadingSavedConnection: boolean; + readonly reconnectingScreenVisible: boolean; + readonly connectionSheetRequired: boolean; + readonly connectionInput: RemoteConnectionInput; + readonly connectionState: RemoteClientConnectionState; + readonly connectionError: string | null; + readonly serverConfig: T3ServerConfig | null; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly selectedThread: OrchestrationThread | null; + readonly projectNameById: Map; + readonly selectedThreadFeed: ReturnType; + readonly selectedThreadFeedLoadingInitial: boolean; + readonly selectedThreadFeedLoadingMore: boolean; + readonly selectedThreadFeedHasMore: boolean; + readonly selectedThreadQueueCount: number; + readonly activeWorkDurationLabel: string | null; + readonly activePendingApproval: ReturnType[number] | null; + readonly respondingApprovalId: ApprovalRequestId | null; + readonly activePendingUserInput: ReturnType[number] | null; + readonly activePendingUserInputDrafts: Record; + readonly activePendingUserInputAnswers: Record | null; + readonly respondingUserInputId: ApprovalRequestId | null; + readonly draftMessage: string; + readonly draftAttachments: ReadonlyArray; + readonly screenTone: ReturnType; + readonly activeThreadBusy: boolean; + readonly hasRemoteActivity: boolean; + readonly resolvedServerUrl: string | null; + readonly httpOrigin: string | null; + readonly resolvedAuthToken: string | null; + readonly hasClient: boolean; + readonly heroTitle: string; + readonly showBrandWordmark: boolean; + readonly onOpenConnectionEditor: () => void; + readonly onCloseConnectionEditor: () => void; + readonly onRequestCloseConnectionEditor: () => void; + readonly onChangeConnectionServerUrl: (serverUrl: string) => void; + readonly onChangeConnectionAuthToken: (authToken: string) => void; + readonly onConnectPress: () => void; + readonly onDisconnectPress: () => void; + readonly onForgetConnectionPress: () => void; + readonly onRefresh: () => Promise; + readonly onCreateThread: (projectId: ProjectId) => Promise; + readonly onSelectThread: (threadId: OrchestrationThread["id"]) => void; + readonly onLoadMoreSelectedThreadFeed: () => Promise; + readonly onBackFromThread: () => void; + readonly onChangeDraftMessage: (value: string) => void; + readonly onPickDraftImages: () => Promise; + readonly onPasteIntoDraft: () => Promise; + readonly onRemoveDraftImage: (imageId: string) => void; + readonly onSendMessage: () => void; + readonly onRenameThread: (title: string) => Promise; + readonly onStopThread: () => Promise; + readonly onRespondToApproval: ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => Promise; + readonly onSelectUserInputOption: (requestId: string, questionId: string, label: string) => void; + readonly onChangeUserInputCustomAnswer: ( + requestId: string, + questionId: string, + customAnswer: string, + ) => void; + readonly onSubmitUserInput: () => Promise; +} + +const THREAD_MESSAGES_PAGE_SIZE = 5; +const CONNECTION_SHEET_GRACE_MS = 2500; + +type ThreadMessagePageState = { + readonly messagesNewestFirst: ReadonlyArray; + readonly hasMore: boolean; + readonly loaded: boolean; + readonly loadingInitial: boolean; + readonly loadingMore: boolean; +}; + +function emptyThreadMessagePageState(): ThreadMessagePageState { + return { + messagesNewestFirst: [], + hasMore: false, + loaded: false, + loadingInitial: false, + loadingMore: false, + }; +} + +function initialThreadMessagePageState( + thread: OrchestrationThread, + pageSize: number, +): ThreadMessagePageState { + return { + messagesNewestFirst: sortCopy(thread.messages, compareThreadMessagesNewestFirst), + hasMore: thread.messages.length >= pageSize, + loaded: true, + loadingInitial: false, + loadingMore: false, + }; +} + +function compareThreadMessagesNewestFirst( + left: OrchestrationThread["messages"][number], + right: OrchestrationThread["messages"][number], +): number { + const byCreatedAt = right.createdAt.localeCompare(left.createdAt); + if (byCreatedAt !== 0) { + return byCreatedAt; + } + return right.id.localeCompare(left.id); +} + +function mergeThreadMessagesNewestFirst( + current: ReadonlyArray, + incoming: ReadonlyArray, +): ReadonlyArray { + const messageById = new Map(); + + for (const message of current) { + messageById.set(message.id, message); + } + for (const message of incoming) { + messageById.set(message.id, message); + } + + return sortCopy(Array.from(messageById.values()), compareThreadMessagesNewestFirst); +} + +function resolveNewThreadModelSelection(input: { + readonly projectId: ProjectId; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +}) { + const project = input.projects.find((candidate) => candidate.id === input.projectId); + if (project?.defaultModelSelection) { + return project.defaultModelSelection; + } + + const latestProjectThread = input.threads.find((thread) => thread.projectId === input.projectId); + if (latestProjectThread) { + return latestProjectThread.modelSelection; + } + + return null; +} + +function useStartupConnection({ + connectFromDeepLink, + connectToRemote, + setConnectionInput, + setConnectionEditorVisible, + setIsLoadingSavedConnection, + clearConnectionSheetGraceTimer, + disconnectClient, +}: { + readonly connectFromDeepLink: ( + url: string, + options?: { readonly persist?: boolean; readonly startSheetGrace?: boolean }, + ) => Promise; + readonly connectToRemote: ( + input: RemoteConnectionInput, + options?: { readonly persist?: boolean; readonly startSheetGrace?: boolean }, + ) => Promise; + readonly setConnectionInput: (input: RemoteConnectionInput) => void; + readonly setConnectionEditorVisible: (visible: boolean) => void; + readonly setIsLoadingSavedConnection: (loading: boolean) => void; + readonly clearConnectionSheetGraceTimer: () => void; + readonly disconnectClient: () => void; +}) { + useEffect(() => { + let cancelled = false; + + void Promise.all([Linking.getInitialURL(), loadSavedConnectionInput()]) + .then(async ([initialUrl, saved]) => { + if (cancelled) { + return; + } + if (initialUrl && (await connectFromDeepLink(initialUrl, { startSheetGrace: true }))) { + return; + } + if (saved) { + setConnectionInput(saved); + void connectToRemote(saved, { persist: false, startSheetGrace: true }); + return; + } + setConnectionEditorVisible(true); + }) + .catch(() => { + if (!cancelled) { + setConnectionEditorVisible(true); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoadingSavedConnection(false); + } + }); + + return () => { + cancelled = true; + clearConnectionSheetGraceTimer(); + disconnectClient(); + }; + }, [ + connectFromDeepLink, + connectToRemote, + setConnectionInput, + setConnectionEditorVisible, + setIsLoadingSavedConnection, + clearConnectionSheetGraceTimer, + disconnectClient, + ]); +} + +function useDeepLinkListener( + connectFromDeepLink: ( + url: string, + options?: { readonly persist?: boolean; readonly startSheetGrace?: boolean }, + ) => Promise, +) { + useEffect(() => { + const subscription = Linking.addEventListener("url", (event) => { + void connectFromDeepLink(event.url, { startSheetGrace: true }); + }); + + return () => { + subscription.remove(); + }; + }, [connectFromDeepLink]); +} + +function useThreadMessageSync( + selectedThread: OrchestrationThread | null, + selectedThreadMessagePage: ThreadMessagePageState | null, + setThreadMessagePagesByThreadId: React.Dispatch< + React.SetStateAction> + >, +) { + useEffect(() => { + if (!selectedThread || !selectedThreadMessagePage?.loaded) { + return; + } + + setThreadMessagePagesByThreadId((current) => { + const existing = current[selectedThread.id]; + if (!existing?.loaded) { + return current; + } + + const newestLoadedCreatedAt = existing.messagesNewestFirst[0]?.createdAt ?? null; + const nextMessages = mergeThreadMessagesNewestFirst( + existing.messagesNewestFirst, + selectedThread.messages.filter((message) => + newestLoadedCreatedAt === null + ? true + : message.createdAt >= newestLoadedCreatedAt || + existing.messagesNewestFirst.some((m) => m.id === message.id), + ), + ); + + if ( + nextMessages.length === existing.messagesNewestFirst.length && + nextMessages.every((message, index) => message === existing.messagesNewestFirst[index]) + ) { + return current; + } + + return { + ...current, + [selectedThread.id]: { + ...existing, + messagesNewestFirst: nextMessages, + }, + }; + }); + }, [selectedThread, selectedThreadMessagePage, setThreadMessagePagesByThreadId]); +} + +function useOrphanThreadCleanup( + selectedThreadId: OrchestrationThread["id"] | null, + selectedThread: OrchestrationThread | null, + setSelectedThreadId: (id: OrchestrationThread["id"] | null) => void, +) { + useEffect(() => { + if (selectedThreadId && !selectedThread) { + setSelectedThreadId(null); + } + }, [selectedThread, selectedThreadId, setSelectedThreadId]); +} + +function useWorkDurationTicker( + activeWorkStartedAt: string | null, + setNowTick: (tick: number) => void, +) { + useEffect(() => { + if (!activeWorkStartedAt) { + return; + } + + setNowTick(Date.now()); + const timer = setInterval(() => { + setNowTick(Date.now()); + }, 1_000); + + return () => clearInterval(timer); + }, [activeWorkStartedAt, setNowTick]); +} + +function useQueueDrain({ + connectionState, + dispatchingQueuedMessageId, + sendingThreadId, + queuedMessagesByThreadId, + threads, + sendQueuedMessage, +}: { + readonly connectionState: RemoteClientConnectionState; + readonly dispatchingQueuedMessageId: string | null; + readonly sendingThreadId: OrchestrationThread["id"] | null; + readonly queuedMessagesByThreadId: Record>; + readonly threads: ReadonlyArray; + readonly sendQueuedMessage: (message: QueuedThreadMessage) => Promise; +}) { + useEffect(() => { + if ( + connectionState !== "ready" || + dispatchingQueuedMessageId !== null || + sendingThreadId !== null + ) { + return; + } + + for (const [threadId, queuedMessages] of Object.entries(queuedMessagesByThreadId)) { + const nextQueuedMessage = queuedMessages[0]; + if (!nextQueuedMessage) { + continue; + } + + const thread = threads.find((candidate) => candidate.id === threadId); + const threadStatus = thread?.session?.status; + if (threadStatus === "running" || threadStatus === "starting") { + continue; + } + + void sendQueuedMessage(nextQueuedMessage); + return; + } + }, [ + connectionState, + dispatchingQueuedMessageId, + sendingThreadId, + queuedMessagesByThreadId, + threads, + sendQueuedMessage, + ]); +} + +export function useRemoteAppState(): RemoteAppModel { + const clientRef = useRef(null); + const unsubscribeRef = useRef<(() => void) | null>(null); + const refreshTimerRef = useRef | null>(null); + const connectionSheetGraceTimerRef = useRef | null>(null); + const connectionAttemptRef = useRef(0); + const [isLoadingSavedConnection, setIsLoadingSavedConnection] = useState(true); + const [connectionEditorVisible, setConnectionEditorVisible] = useState(false); + const [connectionSheetGraceActive, setConnectionSheetGraceActive] = useState(false); + const [suppressAutoConnectionSheet, setSuppressAutoConnectionSheet] = useState(false); + const [connectionInput, setConnectionInput] = useState({ + serverUrl: "", + authToken: "", + }); + const [resolvedServerUrl, setResolvedServerUrl] = useState(null); + const [httpOrigin, setHttpOrigin] = useState(null); + const [resolvedAuthToken, setResolvedAuthToken] = useState(null); + const [connectionState, setConnectionState] = useState("idle"); + const [connectionError, setConnectionError] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const [serverConfig, setServerConfig] = useState(null); + const [selectedThreadId, setSelectedThreadId] = useState(null); + const [nowTick, setNowTick] = useState(() => Date.now()); + const [draftMessageByThreadId, setDraftMessageByThreadId] = useState>({}); + const [draftAttachmentsByThreadId, setDraftAttachmentsByThreadId] = useState< + Record> + >({}); + const [sendingThreadId, setSendingThreadId] = useState(null); + const [respondingApprovalId, setRespondingApprovalId] = useState(null); + const [respondingUserInputId, setRespondingUserInputId] = useState( + null, + ); + const [dispatchingQueuedMessageId, setDispatchingQueuedMessageId] = useState(null); + const [queuedMessagesByThreadId, setQueuedMessagesByThreadId] = useState< + Record> + >({}); + const [threadMessagePagesByThreadId, setThreadMessagePagesByThreadId] = useState< + Record + >({}); + const [userInputDraftsByRequestId, setUserInputDraftsByRequestId] = useState< + Record> + >({}); + + const clearRefreshTimer = useCallback(() => { + if (refreshTimerRef.current !== null) { + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }, []); + + const clearConnectionSheetGraceTimer = useCallback(() => { + if (connectionSheetGraceTimerRef.current !== null) { + clearTimeout(connectionSheetGraceTimerRef.current); + connectionSheetGraceTimerRef.current = null; + } + }, []); + + const startConnectionSheetGrace = useCallback(() => { + clearConnectionSheetGraceTimer(); + setConnectionSheetGraceActive(true); + connectionSheetGraceTimerRef.current = setTimeout(() => { + connectionSheetGraceTimerRef.current = null; + setConnectionSheetGraceActive(false); + }, CONNECTION_SHEET_GRACE_MS); + }, [clearConnectionSheetGraceTimer]); + + const disconnectClient = useCallback( + (options?: { readonly invalidatePendingConnection?: boolean }) => { + if (options?.invalidatePendingConnection !== false) { + connectionAttemptRef.current += 1; + } + + clearRefreshTimer(); + unsubscribeRef.current?.(); + unsubscribeRef.current = null; + clientRef.current?.disconnect(); + clientRef.current = null; + }, + [clearRefreshTimer], + ); + + const refreshSnapshot = useCallback(async () => { + const client = clientRef.current; + if (!client) { + return; + } + + try { + const nextSnapshot = await client.refreshSnapshot(); + setSnapshot(nextSnapshot); + } catch (error) { + setConnectionError(error instanceof Error ? error.message : "Failed to refresh snapshot."); + } + }, []); + + const scheduleSnapshotRefresh = useCallback(() => { + if (refreshTimerRef.current !== null) { + return; + } + + refreshTimerRef.current = setTimeout(() => { + refreshTimerRef.current = null; + void refreshSnapshot(); + }, 180); + }, [refreshSnapshot]); + + const connectToRemote = useCallback( + async ( + input: RemoteConnectionInput, + options?: { readonly persist?: boolean; readonly startSheetGrace?: boolean }, + ) => { + const attemptId = connectionAttemptRef.current + 1; + connectionAttemptRef.current = attemptId; + + if (options?.startSheetGrace) { + startConnectionSheetGrace(); + } else { + clearConnectionSheetGraceTimer(); + setConnectionSheetGraceActive(false); + } + + let resolved; + try { + resolved = resolveRemoteConnection(input); + } catch (error) { + setConnectionError( + error instanceof Error ? error.message : "Enter a valid server URL to continue.", + ); + return; + } + + try { + await preflightRemoteConnection(resolved); + } catch (error) { + if (connectionAttemptRef.current !== attemptId) { + return; + } + setConnectionError( + error instanceof Error ? error.message : "Failed to reach the T3 server.", + ); + return; + } + + if (connectionAttemptRef.current !== attemptId) { + return; + } + + disconnectClient({ invalidatePendingConnection: false }); + setSuppressAutoConnectionSheet(false); + setConnectionError(null); + setSnapshot(null); + setServerConfig(null); + setSelectedThreadId(null); + setDraftMessageByThreadId({}); + setDraftAttachmentsByThreadId({}); + setQueuedMessagesByThreadId({}); + setThreadMessagePagesByThreadId({}); + setDispatchingQueuedMessageId(null); + setResolvedServerUrl(resolved.displayUrl); + setHttpOrigin(resolved.httpOrigin); + setResolvedAuthToken(resolved.authToken); + setConnectionState("connecting"); + + if (options?.persist !== false) { + await saveConnectionInput({ + serverUrl: input.serverUrl.trim(), + authToken: input.authToken.trim(), + }); + } + + if (connectionAttemptRef.current !== attemptId) { + return; + } + + const client = new RemoteClient(resolved); + clientRef.current = client; + + unsubscribeRef.current = client.addListener((event) => { + switch (event.type) { + case "status": + setConnectionState(event.state); + setConnectionError(event.error ?? null); + if (event.state === "ready") { + clearConnectionSheetGraceTimer(); + setConnectionSheetGraceActive(false); + } + return; + case "server-config": + setServerConfig(event.config); + return; + case "snapshot": + setSnapshot(event.snapshot); + setSelectedThreadId((current) => { + if ( + current && + event.snapshot.threads.some( + (thread) => thread.id === current && thread.deletedAt === null, + ) + ) { + return current; + } + return null; + }); + return; + case "domain-event": + setSnapshot((current) => { + if (!current) { + return current; + } + return applyRealtimeEvent(current, event.event); + }); + if (requiresSnapshotRefresh(event.event)) { + scheduleSnapshotRefresh(); + } + return; + } + }); + + client.connect(); + setConnectionEditorVisible(false); + }, + [ + clearConnectionSheetGraceTimer, + disconnectClient, + scheduleSnapshotRefresh, + startConnectionSheetGrace, + ], + ); + + const connectFromDeepLink = useCallback( + async ( + url: string, + options?: { readonly persist?: boolean; readonly startSheetGrace?: boolean }, + ) => { + const parsed = parseRemoteAppConnectionUrl(url); + if (!parsed) { + return false; + } + + setSuppressAutoConnectionSheet(false); + setConnectionEditorVisible(false); + setConnectionInput({ + serverUrl: parsed.serverUrl, + authToken: parsed.authToken ?? "", + }); + await connectToRemote( + { + serverUrl: parsed.serverUrl, + authToken: parsed.authToken ?? "", + }, + options, + ); + return true; + }, + [connectToRemote], + ); + + useStartupConnection({ + connectFromDeepLink, + connectToRemote, + setConnectionInput, + setConnectionEditorVisible, + setIsLoadingSavedConnection, + clearConnectionSheetGraceTimer, + disconnectClient, + }); + + useDeepLinkListener(connectFromDeepLink); + + const projects = useMemo(() => { + if (!snapshot) { + return []; + } + + return sortCopy( + snapshot.projects.filter((project) => project.deletedAt === null), + (left, right) => left.title.localeCompare(right.title), + ); + }, [snapshot]); + + const projectNameById = useMemo(() => { + const map = new Map(); + for (const project of projects) { + map.set(project.id, project.title); + } + return map; + }, [projects]); + + const threads = useMemo(() => { + if (!snapshot) { + return []; + } + + return sortCopy( + snapshot.threads.filter((thread) => thread.deletedAt === null), + (left, right) => threadSortValue(right) - threadSortValue(left), + ); + }, [snapshot]); + + const selectedThread = useMemo( + () => threads.find((thread) => thread.id === selectedThreadId) ?? null, + [selectedThreadId, threads], + ); + + const selectedThreadMessagePage = useMemo( + () => + selectedThread + ? (threadMessagePagesByThreadId[selectedThread.id] ?? emptyThreadMessagePageState()) + : null, + [selectedThread, threadMessagePagesByThreadId], + ); + + const loadThreadMessagesPage = useCallback( + async (threadId: OrchestrationThread["id"], mode: "initial" | "more") => { + const client = clientRef.current; + if (!client) { + return; + } + + let offset = 0; + let shouldSkip = false; + + setThreadMessagePagesByThreadId((current) => { + const currentPage = current[threadId] ?? emptyThreadMessagePageState(); + if (mode === "initial" && currentPage.loadingInitial) { + shouldSkip = true; + return current; + } + if ( + mode === "more" && + (currentPage.loadingInitial || currentPage.loadingMore || !currentPage.hasMore) + ) { + shouldSkip = true; + return current; + } + + offset = mode === "initial" ? 0 : currentPage.messagesNewestFirst.length; + return { + ...current, + [threadId]: { + ...(mode === "initial" ? emptyThreadMessagePageState() : currentPage), + messagesNewestFirst: mode === "initial" ? [] : currentPage.messagesNewestFirst, + hasMore: mode === "initial" ? false : currentPage.hasMore, + loaded: mode === "initial" ? false : currentPage.loaded, + loadingInitial: mode === "initial", + loadingMore: mode === "more", + }, + }; + }); + + if (shouldSkip) { + return; + } + + try { + const result = await client.getThreadMessagesPage({ + threadId, + offset, + limit: THREAD_MESSAGES_PAGE_SIZE, + }); + setThreadMessagePagesByThreadId((current) => { + const previous = + mode === "initial" + ? emptyThreadMessagePageState() + : (current[threadId] ?? emptyThreadMessagePageState()); + + return { + ...current, + [threadId]: { + messagesNewestFirst: + mode === "initial" + ? result.messages + : mergeThreadMessagesNewestFirst(previous.messagesNewestFirst, result.messages), + hasMore: result.hasMore, + loaded: true, + loadingInitial: false, + loadingMore: false, + }, + }; + }); + } catch (error) { + setConnectionError( + error instanceof Error ? error.message : "Failed to load thread messages.", + ); + setThreadMessagePagesByThreadId((current) => ({ + ...current, + [threadId]: { + ...(current[threadId] ?? emptyThreadMessagePageState()), + loadingInitial: false, + loadingMore: false, + }, + })); + } + }, + [], + ); + + const selectedThreadQueuedMessages = useMemo( + () => (selectedThread ? (queuedMessagesByThreadId[selectedThread.id] ?? []) : []), + [queuedMessagesByThreadId, selectedThread], + ); + const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null; + + const selectedThreadLoadedMessages = useMemo( + () => + selectedThreadMessagePage?.loaded + ? sortCopy( + selectedThreadMessagePage.messagesNewestFirst, + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ) + : [], + [selectedThreadMessagePage], + ); + + const selectedThreadFeed = useMemo( + () => + selectedThread + ? buildThreadFeed( + selectedThread, + selectedThreadQueuedMessages, + dispatchingQueuedMessageId, + selectedThreadMessagePage?.loaded + ? { loadedMessages: selectedThreadLoadedMessages } + : undefined, + ) + : [], + [ + dispatchingQueuedMessageId, + selectedThread, + selectedThreadMessagePage?.loaded, + selectedThreadLoadedMessages, + selectedThreadQueuedMessages, + ], + ); + const draftMessage = selectedThread ? (draftMessageByThreadId[selectedThread.id] ?? "") : ""; + const draftAttachments = selectedThread + ? (draftAttachmentsByThreadId[selectedThread.id] ?? []) + : []; + + useThreadMessageSync(selectedThread, selectedThreadMessagePage, setThreadMessagePagesByThreadId); + + const selectedThreadQueueCount = selectedThreadQueuedMessages.length; + + const selectedThreadSessionActivity = useMemo(() => { + if (!selectedThread?.session) { + return null; + } + return { + orchestrationStatus: selectedThread.session.status, + activeTurnId: selectedThread.session.activeTurnId ?? undefined, + }; + }, [selectedThread]); + + const activeWorkStartedAt = useMemo(() => { + if (!selectedThread) { + return null; + } + return deriveActiveWorkStartedAt( + selectedThread.latestTurn, + selectedThreadSessionActivity, + queuedSendStartedAt, + ); + }, [queuedSendStartedAt, selectedThread, selectedThreadSessionActivity]); + + const activeWorkDurationLabel = useMemo( + () => + activeWorkStartedAt + ? formatElapsed(activeWorkStartedAt, new Date(nowTick).toISOString()) + : null, + [activeWorkStartedAt, nowTick], + ); + + useOrphanThreadCleanup(selectedThreadId, selectedThread, setSelectedThreadId); + useWorkDurationTicker(activeWorkStartedAt, setNowTick); + + const activePendingApprovals = useMemo( + () => (selectedThread ? derivePendingApprovals(selectedThread.activities) : []), + [selectedThread], + ); + const activePendingApproval = activePendingApprovals[0] ?? null; + + const activePendingUserInputs = useMemo( + () => (selectedThread ? derivePendingUserInputs(selectedThread.activities) : []), + [selectedThread], + ); + const activePendingUserInput = activePendingUserInputs[0] ?? null; + const activePendingUserInputDrafts = activePendingUserInput + ? (userInputDraftsByRequestId[activePendingUserInput.requestId] ?? {}) + : {}; + const activePendingUserInputAnswers = activePendingUserInput + ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts) + : null; + + const screenTone = connectionTone(connectionState); + const activeThreadBusy = + !!selectedThread && + (selectedThread.session?.status === "running" || + selectedThread.session?.status === "starting") && + sendingThreadId !== selectedThread.id; + const hasRemoteActivity = useMemo( + () => + threads.some( + (thread) => thread.session?.status === "running" || thread.session?.status === "starting", + ), + [threads], + ); + + const enqueueThreadMessage = useCallback((queuedMessage: QueuedThreadMessage) => { + setQueuedMessagesByThreadId((current) => ({ + ...current, + [queuedMessage.threadId]: [...(current[queuedMessage.threadId] ?? []), queuedMessage], + })); + }, []); + + const removeQueuedMessage = useCallback((threadId: string, queuedMessageId: string) => { + setQueuedMessagesByThreadId((current) => { + const existing = current[threadId]; + if (!existing) { + return current; + } + + const nextQueue = existing.filter((entry) => entry.id !== queuedMessageId); + if (nextQueue.length === existing.length) { + return current; + } + if (nextQueue.length === 0) { + const next = { ...current }; + delete next[threadId]; + return next; + } + return { + ...current, + [threadId]: nextQueue, + }; + }); + }, []); + + const clearQueuedMessagesForThread = useCallback((threadId: string) => { + setQueuedMessagesByThreadId((current) => { + if (!(threadId in current)) { + return current; + } + + const next = { ...current }; + delete next[threadId]; + return next; + }); + }, []); + + const onRefresh = useCallback(async () => { + const client = clientRef.current; + if (!client) { + return; + } + + try { + const [nextConfig, nextSnapshot] = await Promise.all([ + client.refreshServerConfig(), + client.refreshSnapshot(), + ]); + setServerConfig(nextConfig); + setSnapshot(nextSnapshot); + setConnectionError(null); + } catch (error) { + setConnectionError(error instanceof Error ? error.message : "Failed to refresh remote data."); + } + }, []); + + const onCreateThread = useCallback( + async (projectId: ProjectId) => { + const client = clientRef.current; + if (!client || connectionState !== "ready") { + return; + } + + const modelSelection = resolveNewThreadModelSelection({ + projectId, + projects, + threads, + }); + if (!modelSelection) { + setConnectionError("This project does not have a default model configured yet."); + return; + } + + const threadId = ThreadId.makeUnsafe(newClientId("thread")); + const createdAt = new Date().toISOString(); + + try { + await client.dispatchCommand({ + type: "thread.create", + commandId: CommandId.makeUnsafe(newClientId("command")), + threadId, + projectId, + title: "New thread", + modelSelection, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt, + }); + const nextSnapshot = await client.refreshSnapshot(); + setSnapshot(nextSnapshot); + setSelectedThreadId(threadId); + setConnectionError(null); + } catch (error) { + setConnectionError(error instanceof Error ? error.message : "Failed to create thread."); + } + }, + [connectionState, projects, threads], + ); + + const onLoadMoreSelectedThreadFeed = useCallback(async () => { + if (!selectedThread) { + return; + } + await loadThreadMessagesPage(selectedThread.id, "more"); + }, [loadThreadMessagesPage, selectedThread]); + + const onConnectPress = useCallback(() => { + setSuppressAutoConnectionSheet(false); + void connectToRemote(connectionInput); + }, [connectToRemote, connectionInput]); + + const onDisconnectPress = useCallback(() => { + clearConnectionSheetGraceTimer(); + setConnectionSheetGraceActive(false); + setConnectionEditorVisible(false); + setSuppressAutoConnectionSheet(true); + disconnectClient(); + setConnectionState("idle"); + setConnectionError(null); + setSnapshot(null); + setServerConfig(null); + setDraftMessageByThreadId({}); + setDraftAttachmentsByThreadId({}); + setQueuedMessagesByThreadId({}); + setThreadMessagePagesByThreadId({}); + setDispatchingQueuedMessageId(null); + setResolvedServerUrl(null); + setHttpOrigin(null); + setResolvedAuthToken(null); + setSelectedThreadId(null); + }, [clearConnectionSheetGraceTimer, disconnectClient]); + + const onForgetConnectionPress = useCallback(() => { + Alert.alert("Forget saved connection?", "The saved URL and auth token will be removed.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Forget", + style: "destructive", + onPress: () => { + void clearSavedConnectionInput(); + onDisconnectPress(); + setConnectionInput({ serverUrl: "", authToken: "" }); + setConnectionEditorVisible(true); + }, + }, + ]); + }, [onDisconnectPress]); + + const sendQueuedMessage = useCallback( + async (queuedMessage: QueuedThreadMessage) => { + const client = clientRef.current; + if (!client) { + return; + } + + setDispatchingQueuedMessageId(queuedMessage.id); + setSendingThreadId(ThreadId.makeUnsafe(queuedMessage.threadId)); + + try { + await client.dispatchCommand({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe(queuedMessage.commandId), + threadId: ThreadId.makeUnsafe(queuedMessage.threadId), + message: { + messageId: MessageId.makeUnsafe(queuedMessage.messageId), + role: "user", + text: queuedMessage.text, + attachments: queuedMessage.attachments, + }, + runtimeMode: + threads.find((thread) => thread.id === queuedMessage.threadId)?.runtimeMode ?? + "full-access", + interactionMode: + threads.find((thread) => thread.id === queuedMessage.threadId)?.interactionMode ?? + "default", + createdAt: queuedMessage.createdAt, + }); + removeQueuedMessage(queuedMessage.threadId, queuedMessage.id); + setSnapshot((current) => + current + ? applyOptimisticUserMessage(current, { + threadId: ThreadId.makeUnsafe(queuedMessage.threadId), + messageId: MessageId.makeUnsafe(queuedMessage.messageId), + text: queuedMessage.text, + attachments: queuedMessage.attachments, + createdAt: queuedMessage.createdAt, + }) + : current, + ); + } catch (error) { + removeQueuedMessage(queuedMessage.threadId, queuedMessage.id); + setConnectionError(error instanceof Error ? error.message : "Failed to send message."); + void refreshSnapshot(); + } finally { + setDispatchingQueuedMessageId((current) => (current === queuedMessage.id ? null : current)); + setSendingThreadId((current) => + current === ThreadId.makeUnsafe(queuedMessage.threadId) ? null : current, + ); + } + }, + [refreshSnapshot, removeQueuedMessage, threads], + ); + + useQueueDrain({ + connectionState, + dispatchingQueuedMessageId, + sendingThreadId, + queuedMessagesByThreadId, + threads, + sendQueuedMessage, + }); + + const onSendMessage = useCallback(() => { + if (!selectedThread || connectionState !== "ready") { + return; + } + + const draft = draftMessageByThreadId[selectedThread.id] ?? ""; + const text = draft.trim(); + const attachments = draftAttachmentsByThreadId[selectedThread.id] ?? []; + if (text.length === 0 && attachments.length === 0) { + return; + } + + const createdAt = new Date().toISOString(); + enqueueThreadMessage({ + id: newClientId("queued-message"), + threadId: selectedThread.id, + messageId: newClientId("message"), + commandId: newClientId("command"), + text, + attachments, + createdAt, + }); + setDraftMessageByThreadId((current) => ({ + ...current, + [selectedThread.id]: "", + })); + setDraftAttachmentsByThreadId((current) => ({ + ...current, + [selectedThread.id]: [], + })); + }, [ + connectionState, + draftAttachmentsByThreadId, + draftMessageByThreadId, + enqueueThreadMessage, + selectedThread, + ]); + + const onStopThread = useCallback(async () => { + if (!selectedThread) { + return; + } + + clearQueuedMessagesForThread(selectedThread.id); + + const client = clientRef.current; + if (!client) { + return; + } + if ( + selectedThread.session?.status !== "running" && + selectedThread.session?.status !== "starting" + ) { + return; + } + + try { + await client.dispatchCommand({ + type: "thread.turn.interrupt", + commandId: CommandId.makeUnsafe(newClientId("command")), + threadId: selectedThread.id, + ...(selectedThread.session?.activeTurnId + ? { turnId: selectedThread.session.activeTurnId } + : {}), + createdAt: new Date().toISOString(), + }); + } catch (error) { + setConnectionError(error instanceof Error ? error.message : "Failed to interrupt turn."); + } + }, [clearQueuedMessagesForThread, selectedThread]); + + const onRenameThread = useCallback( + async (title: string) => { + const client = clientRef.current; + if (!client || !selectedThread) { + return; + } + + const trimmed = title.trim(); + if (trimmed.length === 0 || trimmed === selectedThread.title) { + return; + } + + try { + await client.dispatchCommand({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe(newClientId("command")), + threadId: selectedThread.id, + title: trimmed, + }); + setConnectionError(null); + } catch (error) { + setConnectionError(error instanceof Error ? error.message : "Failed to rename thread."); + } + }, + [selectedThread], + ); + + const onRespondToApproval = useCallback( + async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { + const client = clientRef.current; + if (!client || !selectedThread) { + return; + } + + setRespondingApprovalId(requestId); + try { + await client.dispatchCommand({ + type: "thread.approval.respond", + commandId: CommandId.makeUnsafe(newClientId("command")), + threadId: selectedThread.id, + requestId, + decision, + createdAt: new Date().toISOString(), + }); + } catch (error) { + setConnectionError( + error instanceof Error ? error.message : "Failed to submit approval response.", + ); + } finally { + setRespondingApprovalId((current) => (current === requestId ? null : current)); + } + }, + [selectedThread], + ); + + const onSelectUserInputOption = useCallback( + (requestId: string, questionId: string, label: string) => { + setUserInputDraftsByRequestId((current) => ({ + ...current, + [requestId]: { + ...current[requestId], + [questionId]: { + selectedOptionLabel: label, + }, + }, + })); + }, + [], + ); + + const onChangeUserInputCustomAnswer = useCallback( + (requestId: string, questionId: string, customAnswer: string) => { + setUserInputDraftsByRequestId((current) => ({ + ...current, + [requestId]: { + ...current[requestId], + [questionId]: setPendingUserInputCustomAnswer( + current[requestId]?.[questionId], + customAnswer, + ), + }, + })); + }, + [], + ); + + const onSubmitUserInput = useCallback(async () => { + const client = clientRef.current; + if (!client || !selectedThread || !activePendingUserInput || !activePendingUserInputAnswers) { + return; + } + + setRespondingUserInputId(activePendingUserInput.requestId); + try { + await client.dispatchCommand({ + type: "thread.user-input.respond", + commandId: CommandId.makeUnsafe(newClientId("command")), + threadId: selectedThread.id, + requestId: activePendingUserInput.requestId, + answers: activePendingUserInputAnswers, + createdAt: new Date().toISOString(), + }); + setUserInputDraftsByRequestId((current) => { + if (!(activePendingUserInput.requestId in current)) { + return current; + } + + const next = { ...current }; + delete next[activePendingUserInput.requestId]; + return next; + }); + } catch (error) { + setConnectionError( + error instanceof Error ? error.message : "Failed to submit user input answers.", + ); + } finally { + setRespondingUserInputId((current) => + current === activePendingUserInput.requestId ? null : current, + ); + } + }, [activePendingUserInput, activePendingUserInputAnswers, selectedThread]); + + const onOpenConnectionEditor = useCallback(() => { + setSuppressAutoConnectionSheet(false); + setConnectionEditorVisible(true); + }, []); + + const onCloseConnectionEditor = useCallback(() => setConnectionEditorVisible(false), []); + + const onRequestCloseConnectionEditor = useCallback(() => { + if (clientRef.current) { + setConnectionEditorVisible(false); + } + }, []); + + const onChangeConnectionServerUrl = useCallback( + (serverUrl: string) => setConnectionInput((current) => ({ ...current, serverUrl })), + [], + ); + + const onChangeConnectionAuthToken = useCallback( + (authToken: string) => setConnectionInput((current) => ({ ...current, authToken })), + [], + ); + + const onSelectThread = useCallback( + (threadId: OrchestrationThread["id"]) => { + const thread = threads.find((candidate) => candidate.id === threadId) ?? null; + setSelectedThreadId(threadId); + setThreadMessagePagesByThreadId((current) => { + if (current[threadId]?.loaded || !thread) { + return current; + } + + return { + ...current, + [threadId]: initialThreadMessagePageState(thread, THREAD_MESSAGES_PAGE_SIZE), + }; + }); + }, + [threads], + ); + + const onBackFromThread = useCallback(() => setSelectedThreadId(null), []); + + const onChangeDraftMessage = useCallback( + (value: string) => { + if (!selectedThread) { + return; + } + setDraftMessageByThreadId((current) => ({ + ...current, + [selectedThread.id]: value, + })); + }, + [selectedThread], + ); + + const onPickDraftImages = useCallback(async () => { + if (!selectedThread) { + return; + } + + const result = await pickComposerImages({ + existingCount: draftAttachmentsByThreadId[selectedThread.id]?.length ?? 0, + }); + if (result.images.length > 0) { + setDraftAttachmentsByThreadId((current) => ({ + ...current, + [selectedThread.id]: [...(current[selectedThread.id] ?? []), ...result.images], + })); + } + if (result.error) { + setConnectionError(result.error); + } + }, [draftAttachmentsByThreadId, selectedThread]); + + const onPasteIntoDraft = useCallback(async () => { + if (!selectedThread) { + return; + } + + const result = await pasteComposerClipboard({ + existingCount: draftAttachmentsByThreadId[selectedThread.id]?.length ?? 0, + }); + if (result.images.length > 0) { + setDraftAttachmentsByThreadId((current) => ({ + ...current, + [selectedThread.id]: [...(current[selectedThread.id] ?? []), ...result.images], + })); + } + if (result.text) { + setDraftMessageByThreadId((current) => ({ + ...current, + [selectedThread.id]: `${current[selectedThread.id] ?? ""}${result.text}`, + })); + } + if (result.error) { + setConnectionError(result.error); + } + }, [draftAttachmentsByThreadId, selectedThread]); + + const onRemoveDraftImage = useCallback( + (imageId: string) => { + if (!selectedThread) { + return; + } + setDraftAttachmentsByThreadId((current) => { + const existing = current[selectedThread.id] ?? []; + const next = existing.filter((image) => image.id !== imageId); + if (next.length === existing.length) { + return current; + } + return { + ...current, + [selectedThread.id]: next, + }; + }); + }, + [selectedThread], + ); + + const hasClient = clientRef.current !== null; + const reconnectingScreenVisible = + connectionSheetGraceActive && !connectionEditorVisible && connectionState !== "ready"; + const connectionSheetRequired = + connectionEditorVisible || + (!hasClient && !connectionSheetGraceActive && !suppressAutoConnectionSheet); + const heroTitle = screenTitle(serverConfig, resolvedServerUrl); + const showBrandWordmark = /^t3[-_\s]?code$/i.test(heroTitle); + + return { + isLoadingSavedConnection, + reconnectingScreenVisible, + connectionSheetRequired, + connectionInput, + connectionState, + connectionError, + serverConfig, + projects, + threads, + selectedThread, + projectNameById, + selectedThreadFeed, + selectedThreadFeedLoadingInitial: selectedThreadMessagePage?.loadingInitial ?? false, + selectedThreadFeedLoadingMore: selectedThreadMessagePage?.loadingMore ?? false, + selectedThreadFeedHasMore: selectedThreadMessagePage?.hasMore ?? false, + selectedThreadQueueCount, + activeWorkDurationLabel, + activePendingApproval, + respondingApprovalId, + activePendingUserInput, + activePendingUserInputDrafts, + activePendingUserInputAnswers, + respondingUserInputId, + draftMessage, + draftAttachments, + screenTone, + activeThreadBusy, + hasRemoteActivity, + resolvedServerUrl, + httpOrigin, + resolvedAuthToken, + hasClient, + heroTitle, + showBrandWordmark, + onOpenConnectionEditor: onOpenConnectionEditor, + onCloseConnectionEditor: onCloseConnectionEditor, + onRequestCloseConnectionEditor: onRequestCloseConnectionEditor, + onChangeConnectionServerUrl: onChangeConnectionServerUrl, + onChangeConnectionAuthToken: onChangeConnectionAuthToken, + onConnectPress, + onDisconnectPress, + onForgetConnectionPress, + onRefresh, + onCreateThread, + onSelectThread: onSelectThread, + onLoadMoreSelectedThreadFeed, + onBackFromThread: onBackFromThread, + onChangeDraftMessage: onChangeDraftMessage, + onPickDraftImages: onPickDraftImages, + onPasteIntoDraft: onPasteIntoDraft, + onRemoveDraftImage: onRemoveDraftImage, + onSendMessage, + onRenameThread, + onStopThread, + onRespondToApproval, + onSelectUserInputOption, + onChangeUserInputCustomAnswer, + onSubmitUserInput, + }; +} diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx new file mode 100644 index 0000000000..08c7574c68 --- /dev/null +++ b/apps/mobile/src/components/AppText.tsx @@ -0,0 +1,316 @@ +import { + Text as RNText, + type StyleProp, + TextInput as RNTextInput, + type TextInputProps as RNTextInputProps, + type TextProps as RNTextProps, + type TextStyle, + useColorScheme, + type ViewStyle, +} from "react-native"; + +type ClassNameProp = { + readonly className?: string; +}; + +const colorMap = { + "text-white": "#ffffff", + "text-slate-50": "#f8fafc", + "text-slate-300": "#cbd5e1", + "text-slate-400": "#94a3b8", + "text-slate-500": "#64748b", + "text-slate-600": "#475569", + "text-slate-700": "#334155", + "text-slate-950": "#020617", + "text-orange-300": "#fdba74", + "text-orange-600": "#ea580c", + "text-orange-700": "#c2410c", + "text-emerald-300": "#6ee7b7", + "text-emerald-700": "#047857", + "text-rose-300": "#fda4af", + "text-rose-700": "#be123c", + "text-sky-300": "#7dd3fc", + "text-sky-700": "#0369a1", +} satisfies Record; + +const backgroundColorMap = { + "bg-white": "#ffffff", + "bg-slate-900": "#0f172a", + "bg-slate-950/70": "rgba(2, 6, 23, 0.7)", +} satisfies Record; + +const borderColorMap = { + "border-slate-200": "#e2e8f0", + "border-white/8": "rgba(255, 255, 255, 0.08)", +} satisfies Record; + +function activeToken(token: string, isDarkMode: boolean) { + if (!token.startsWith("dark:")) { + return token; + } + + return isDarkMode ? token.slice(5) : null; +} + +function resolveTextStyle(className: string | undefined, isDarkMode: boolean): TextStyle { + const style: TextStyle = { + color: isDarkMode ? "#f8fafc" : "#020617", + }; + + let hasLeadingNone = false; + for (const rawToken of className?.split(/\s+/) ?? []) { + const token = activeToken(rawToken, isDarkMode); + if (!token) { + continue; + } + + if (token in colorMap) { + style.color = colorMap[token as keyof typeof colorMap]; + continue; + } + + if (token === "font-sans") { + style.fontWeight = "400"; + continue; + } + + if (token === "font-medium") { + style.fontWeight = "500"; + continue; + } + + if (token === "font-bold") { + style.fontWeight = "700"; + continue; + } + + if (token === "font-extrabold") { + style.fontWeight = "800"; + continue; + } + + if (token === "font-t3-medium") { + style.fontWeight = "500"; + continue; + } + + if (token === "font-t3-bold") { + style.fontWeight = "700"; + continue; + } + + if (token === "font-t3-extrabold") { + style.fontWeight = "800"; + continue; + } + + if (token === "text-xs") { + style.fontSize = 12; + continue; + } + + if (token === "text-sm") { + style.fontSize = 14; + continue; + } + + if (token === "text-lg") { + style.fontSize = 18; + continue; + } + + if (token === "leading-none") { + hasLeadingNone = true; + continue; + } + + if (token === "leading-5") { + style.lineHeight = 20; + continue; + } + + if (token === "uppercase") { + style.textTransform = "uppercase"; + continue; + } + + if (token === "capitalize") { + style.textTransform = "capitalize"; + continue; + } + + if (token === "mt-2") { + style.marginTop = 8; + continue; + } + + if (token === "pt-1.5") { + style.paddingTop = 6; + continue; + } + + if (token === "pb-1") { + style.paddingBottom = 4; + continue; + } + + if (token === "max-w-[640px]") { + style.maxWidth = 640; + continue; + } + + const pxValue = token.match(/^text-\[(\d+)px\]$/); + if (pxValue) { + style.fontSize = Number(pxValue[1]); + continue; + } + + const lineHeightValue = token.match(/^leading-\[(\d+)px\]$/); + if (lineHeightValue) { + style.lineHeight = Number(lineHeightValue[1]); + continue; + } + + const trackingValue = token.match(/^tracking-\[([0-9.]+)px\]$/); + if (trackingValue) { + style.letterSpacing = Number(trackingValue[1]); + } + } + + if (hasLeadingNone) { + style.lineHeight = style.fontSize ?? 16; + } + + return style; +} + +function resolveTextInputStyle( + className: string | undefined, + isDarkMode: boolean, +): ViewStyle & TextStyle { + const style: ViewStyle & TextStyle = { + color: isDarkMode ? "#f8fafc" : "#020617", + backgroundColor: isDarkMode ? "#0f172a" : "#ffffff", + borderColor: isDarkMode ? "rgba(255, 255, 255, 0.08)" : "#e2e8f0", + borderWidth: 1, + borderRadius: 16, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + minHeight: 54, + }; + + for (const rawToken of className?.split(/\s+/) ?? []) { + const token = activeToken(rawToken, isDarkMode); + if (!token) { + continue; + } + + if (token in colorMap) { + style.color = colorMap[token as keyof typeof colorMap]; + continue; + } + + if (token in backgroundColorMap) { + style.backgroundColor = backgroundColorMap[token as keyof typeof backgroundColorMap]; + continue; + } + + if (token in borderColorMap) { + style.borderColor = borderColorMap[token as keyof typeof borderColorMap]; + continue; + } + + if (token === "border") { + style.borderWidth = 1; + continue; + } + + if (token === "rounded-2xl") { + style.borderRadius = 16; + continue; + } + + if (token === "rounded-[18px]") { + style.borderRadius = 18; + continue; + } + + if (token === "px-3.5") { + style.paddingHorizontal = 14; + continue; + } + + if (token === "py-3") { + style.paddingVertical = 12; + continue; + } + + if (token === "py-3.5") { + style.paddingVertical = 14; + continue; + } + + if (token === "font-sans") { + style.fontWeight = "400"; + continue; + } + + if (token === "text-sm") { + style.fontSize = 14; + continue; + } + + const minHeightValue = token.match(/^min-h-\[(\d+)px\]$/); + if (minHeightValue) { + style.minHeight = Number(minHeightValue[1]); + continue; + } + + const maxHeightValue = token.match(/^max-h-\[(\d+)px\]$/); + if (maxHeightValue) { + style.maxHeight = Number(maxHeightValue[1]); + continue; + } + + const textPxValue = token.match(/^text-\[(\d+)px\]$/); + if (textPxValue) { + style.fontSize = Number(textPxValue[1]); + continue; + } + + const lineHeightValue = token.match(/^leading-\[(\d+)px\]$/); + if (lineHeightValue) { + style.lineHeight = Number(lineHeightValue[1]); + } + } + + return style; +} + +export type AppTextProps = RNTextProps & ClassNameProp; + +export function AppText({ className, style, ...props }: AppTextProps) { + const isDarkMode = useColorScheme() === "dark"; + return ; +} + +export type AppTextInputProps = RNTextInputProps & ClassNameProp; + +export function AppTextInput({ + className, + placeholderTextColor, + style, + ...props +}: AppTextInputProps) { + const isDarkMode = useColorScheme() === "dark"; + const resolvedStyle = resolveTextInputStyle(className, isDarkMode); + + return ( + , style]} + /> + ); +} diff --git a/apps/mobile/src/components/EmptyState.tsx b/apps/mobile/src/components/EmptyState.tsx new file mode 100644 index 0000000000..a22f7d47f1 --- /dev/null +++ b/apps/mobile/src/components/EmptyState.tsx @@ -0,0 +1,13 @@ +import { View } from "react-native"; + +import { AppText as Text } from "./AppText"; +export function EmptyState(props: { readonly title: string; readonly detail: string }) { + return ( + + {props.title} + + {props.detail} + + + ); +} diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx new file mode 100644 index 0000000000..3fb8ba5d91 --- /dev/null +++ b/apps/mobile/src/components/ErrorBanner.tsx @@ -0,0 +1,12 @@ +import { View } from "react-native"; + +import { AppText as Text } from "./AppText"; +export function ErrorBanner(props: { readonly message: string }) { + return ( + + + {props.message} + + + ); +} diff --git a/apps/mobile/src/components/GlassSafeAreaView.tsx b/apps/mobile/src/components/GlassSafeAreaView.tsx new file mode 100644 index 0000000000..11bfa17e2c --- /dev/null +++ b/apps/mobile/src/components/GlassSafeAreaView.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from "react"; +import { useColorScheme, View, type StyleProp, type ViewStyle } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { GlassSurface } from "./GlassSurface"; + +export interface GlassSafeAreaViewProps { + readonly leftSlot?: ReactNode; + readonly centerSlot?: ReactNode; + readonly rightSlot?: ReactNode; + readonly style?: StyleProp; +} + +export function GlassSafeAreaView({ + leftSlot, + centerSlot, + rightSlot, + style, +}: GlassSafeAreaViewProps) { + const isDarkMode = useColorScheme() === "dark"; + const insets = useSafeAreaInsets(); + const headerPaddingTop = insets.top + 16; + const surfaceStyle = { + borderRadius: 0, + backgroundColor: isDarkMode ? "rgba(2,6,23,0.18)" : "rgba(248,250,252,0.2)", + borderBottomWidth: 1, + borderBottomColor: isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(148,163,184,0.16)", + } as const; + + return ( + + + + {leftSlot} + {centerSlot} + {rightSlot} + + + + ); +} diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx new file mode 100644 index 0000000000..fba6d8e801 --- /dev/null +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -0,0 +1,73 @@ +import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; +import type { ReactNode } from "react"; +import { Platform, useColorScheme, View, type ViewProps, type ViewStyle } from "react-native"; + +export interface GlassSurfaceProps extends ViewProps { + readonly children: ReactNode; + readonly glassEffectStyle?: "clear" | "regular" | "none"; + readonly tintColor?: string; + readonly chrome?: "default" | "none"; +} + +export function GlassSurface({ + children, + glassEffectStyle = "regular", + chrome = "default", + tintColor, + style, + ...props +}: GlassSurfaceProps) { + const isDarkMode = useColorScheme() === "dark"; + const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); + const surfaceStyle: ViewStyle = { + borderRadius: 32, + overflow: "hidden", + borderWidth: chrome === "none" ? 0 : 1, + borderColor: + chrome === "none" + ? "transparent" + : isDarkMode + ? "rgba(255,255,255,0.08)" + : "rgba(226,232,240,0.9)", + backgroundColor: + chrome === "none" + ? "transparent" + : isDarkMode + ? "rgba(15,23,42,0.78)" + : "rgba(255,255,255,0.72)", + shadowColor: chrome === "none" ? "transparent" : "#020617", + shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08, + shadowRadius: chrome === "none" ? 0 : 28, + shadowOffset: + chrome === "none" + ? { + width: 0, + height: 0, + } + : { + width: 0, + height: 14, + }, + elevation: chrome === "none" ? 0 : 12, + }; + + if (supportsGlass) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} diff --git a/apps/mobile/src/components/LoadingScreen.tsx b/apps/mobile/src/components/LoadingScreen.tsx new file mode 100644 index 0000000000..33357620ca --- /dev/null +++ b/apps/mobile/src/components/LoadingScreen.tsx @@ -0,0 +1,25 @@ +import { ActivityIndicator, StatusBar, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "./AppText"; + +export function LoadingScreen(props: { readonly isDarkMode: boolean; readonly message: string }) { + const insets = useSafeAreaInsets(); + const backgroundColor = props.isDarkMode ? "#020617" : "#f8fafc"; + + return ( + + + + + + {props.message} + + + + ); +} diff --git a/apps/mobile/src/components/StatusPill.tsx b/apps/mobile/src/components/StatusPill.tsx new file mode 100644 index 0000000000..ce8db639db --- /dev/null +++ b/apps/mobile/src/components/StatusPill.tsx @@ -0,0 +1,18 @@ +import { View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import { cx } from "../lib/classNames"; + +export interface StatusTone { + readonly label: string; + readonly pillClassName: string; + readonly textClassName: string; +} + +export function StatusPill(props: StatusTone) { + return ( + + {props.label} + + ); +} diff --git a/apps/mobile/src/features/connection/ConnectionSheet.tsx b/apps/mobile/src/features/connection/ConnectionSheet.tsx new file mode 100644 index 0000000000..ff7ef4b696 --- /dev/null +++ b/apps/mobile/src/features/connection/ConnectionSheet.tsx @@ -0,0 +1,298 @@ +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { useColorScheme } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { ErrorBanner } from "../../components/ErrorBanner"; +import type { RemoteConnectionInput } from "../../lib/connection"; +import type { RemoteClientConnectionState } from "../../lib/remoteClient"; + +export interface ConnectionSheetProps { + readonly visible: boolean; + readonly hasClient: boolean; + readonly connectionInput: RemoteConnectionInput; + readonly connectionState: RemoteClientConnectionState; + readonly connectionError: string | null; + readonly onRequestClose: () => void; + readonly onChangeServerUrl: (serverUrl: string) => void; + readonly onChangeAuthToken: (authToken: string) => void; + readonly onConnect: () => void; + readonly onClose: () => void; + readonly onDisconnect: () => void; + readonly onForgetSavedLink: () => void; +} + +function makePalette(isDarkMode: boolean) { + if (isDarkMode) { + return { + sheet: "#151618", + panel: "#1d1e21", + panelAlt: "#232529", + rail: "#17181b", + border: "rgba(255,255,255,0.08)", + text: "#f4f3ef", + muted: "#8f918f", + tabActive: "#f1eee6", + tabActiveText: "#18191b", + tabInactive: "#24262a", + tabInactiveText: "#73767c", + input: "#1f2230", + inputText: "#f8fafc", + placeholder: "#6b7280", + action: "#f97316", + actionText: "#fff7ed", + secondary: "#303440", + secondaryText: "#f8fafc", + danger: "#381624", + dangerText: "#fda4af", + utility: "#2a2d33", + utilityText: "#f4f3ef", + accent: "#d8b27a", + }; + } + + return { + sheet: "#f2ece4", + panel: "#fbf7f1", + panelAlt: "#f2ebe1", + rail: "#ece4d8", + border: "#d7cdbf", + text: "#1f1b17", + muted: "#847b71", + tabActive: "#2c2a2d", + tabActiveText: "#f8f4ee", + tabInactive: "#ffffff", + tabInactiveText: "#a89f94", + input: "#ffffff", + inputText: "#1f2937", + placeholder: "#94a3b8", + action: "#2c2a2d", + actionText: "#f8f4ee", + secondary: "#ffffff", + secondaryText: "#1f1b17", + danger: "#fde7e7", + dangerText: "#a11d33", + utility: "#e8dfd2", + utilityText: "#1f1b17", + accent: "#9a6b30", + }; +} + +function FieldBlock(props: { + readonly label: string; + readonly placeholder: string; + readonly value: string; + readonly onChangeText: (value: string) => void; + readonly palette: ReturnType; + readonly keyboardType?: "default" | "url"; +}) { + return ( + + + {props.label} + + + + ); +} + +function ActionButton(props: { + readonly label: string; + readonly onPress: () => void; + readonly palette: ReturnType; + readonly tone?: "primary" | "secondary" | "danger" | "utility"; +}) { + const tone = props.tone ?? "secondary"; + const styles = + tone === "primary" + ? { + backgroundColor: props.palette.action, + color: props.palette.actionText, + } + : tone === "danger" + ? { + backgroundColor: props.palette.danger, + color: props.palette.dangerText, + } + : tone === "utility" + ? { + backgroundColor: props.palette.utility, + color: props.palette.utilityText, + } + : { + backgroundColor: props.palette.secondary, + color: props.palette.secondaryText, + }; + + return ( + + + {props.label} + + + ); +} + +export function ConnectionSheet(props: ConnectionSheetProps) { + const isDarkMode = useColorScheme() === "dark"; + const palette = makePalette(isDarkMode); + + return ( + + + + + + + + + Remote link + + + Connect to a T3 server + + + Use the same LAN or Tailnet URL you use for remote web access. The auth token is + optional unless the server was started with --auth-token. + + + + + + {props.hasClient ? "Saved link" : "Manual setup"} + + + + + + + + + + + + {props.connectionError ? : null} + + + + + + + {props.hasClient ? ( + + + + ) : null} + + + {props.hasClient ? ( + + + + + + + + + ) : ( + + )} + + + + + + ); +} diff --git a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx new file mode 100644 index 0000000000..2f9cbdbf26 --- /dev/null +++ b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx @@ -0,0 +1,112 @@ +import { useEffect } from "react"; +import { View } from "react-native"; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +import type { RemoteClientConnectionState } from "../../lib/remoteClient"; + +function statusDotTone(state: RemoteClientConnectionState): { + readonly dotColor: string; + readonly haloColor: string; +} { + switch (state) { + case "ready": + return { + dotColor: "#34d399", + haloColor: "rgba(52,211,153,0.48)", + }; + case "connecting": + case "reconnecting": + return { + dotColor: "#f59e0b", + haloColor: "rgba(245,158,11,0.5)", + }; + case "idle": + case "disconnected": + return { + dotColor: "#ef4444", + haloColor: "rgba(239,68,68,0.48)", + }; + } +} + +function usePulseAnimation(pulse: boolean) { + const pulseProgress = useSharedValue(0); + + useEffect(() => { + if (pulse) { + pulseProgress.value = withRepeat( + withTiming(1, { + duration: 1100, + easing: Easing.out(Easing.cubic), + }), + -1, + false, + ); + return; + } + + cancelAnimation(pulseProgress); + pulseProgress.value = withTiming(0, { + duration: 180, + easing: Easing.out(Easing.quad), + }); + }, [pulse, pulseProgress]); + + return pulseProgress; +} + +export function ConnectionStatusDot(props: { + readonly state: RemoteClientConnectionState; + readonly pulse: boolean; + readonly size?: number; +}) { + const pulseProgress = usePulseAnimation(props.pulse); + const tone = statusDotTone(props.state); + const dotSize = props.size ?? 10; + const haloSize = dotSize + 4; + const containerSize = haloSize + 4; + + const haloStyle = useAnimatedStyle(() => ({ + opacity: props.pulse ? 0.14 + (1 - pulseProgress.value) * 0.3 : 0, + transform: [{ scale: 0.78 + pulseProgress.value * 1.16 }], + })); + + return ( + + + + + ); +} diff --git a/apps/mobile/src/features/connection/connectionTone.ts b/apps/mobile/src/features/connection/connectionTone.ts new file mode 100644 index 0000000000..120260428e --- /dev/null +++ b/apps/mobile/src/features/connection/connectionTone.ts @@ -0,0 +1,37 @@ +import type { StatusTone } from "../../components/StatusPill"; +import type { RemoteClientConnectionState } from "../../lib/remoteClient"; + +export function connectionTone(state: RemoteClientConnectionState): StatusTone { + switch (state) { + case "ready": + return { + label: "Connected", + pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", + textClassName: "text-emerald-700 dark:text-emerald-300", + }; + case "reconnecting": + return { + label: "Reconnecting", + pillClassName: "bg-amber-500/12 dark:bg-amber-500/16", + textClassName: "text-amber-700 dark:text-amber-300", + }; + case "connecting": + return { + label: "Connecting", + pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", + textClassName: "text-sky-700 dark:text-sky-300", + }; + case "disconnected": + return { + label: "Disconnected", + pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", + textClassName: "text-rose-700 dark:text-rose-300", + }; + case "idle": + return { + label: "Idle", + pillClassName: "bg-slate-500/10 dark:bg-slate-500/16", + textClassName: "text-slate-600 dark:text-slate-300", + }; + } +} diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx new file mode 100644 index 0000000000..ab7fcd97d5 --- /dev/null +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -0,0 +1,57 @@ +import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts"; +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import type { PendingApproval } from "../../lib/threadActivity"; + +export interface PendingApprovalCardProps { + readonly approval: PendingApproval; + readonly respondingApprovalId: ApprovalRequestId | null; + readonly onRespond: ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => Promise; +} + +export function PendingApprovalCard(props: PendingApprovalCardProps) { + return ( + + + Approval needed + + + {props.approval.requestKind} + + {props.approval.detail ? ( + + {props.approval.detail} + + ) : null} + + void props.onRespond(props.approval.requestId, "accept")} + > + Allow once + + void props.onRespond(props.approval.requestId, "acceptForSession")} + > + + Allow session + + + void props.onRespond(props.approval.requestId, "decline")} + > + Decline + + + + ); +} diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx new file mode 100644 index 0000000000..f87cb8e16b --- /dev/null +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -0,0 +1,101 @@ +import type { ApprovalRequestId } from "@t3tools/contracts"; +import { Pressable, View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { cx } from "../../lib/classNames"; +import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity"; + +export interface PendingUserInputCardProps { + readonly pendingUserInput: PendingUserInput; + readonly drafts: Record; + readonly answers: Record | null; + readonly respondingUserInputId: ApprovalRequestId | null; + readonly onSelectOption: (requestId: string, questionId: string, label: string) => void; + readonly onChangeCustomAnswer: ( + requestId: string, + questionId: string, + customAnswer: string, + ) => void; + readonly onSubmit: () => Promise; +} + +export function PendingUserInputCard(props: PendingUserInputCardProps) { + return ( + + + User input needed + + + Fill in the pending answers + + {props.pendingUserInput.questions.map((question) => { + const draft = props.drafts[question.id]; + return ( + + + {question.header} + + + {question.question} + + + {question.options.map((option) => { + const selected = + draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length; + return ( + + props.onSelectOption( + props.pendingUserInput.requestId, + question.id, + option.label, + ) + } + > + + {option.label} + + + ); + })} + + + props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) + } + placeholder="Or type a custom answer" + className="min-h-[54px] rounded-2xl border border-slate-200 bg-white px-3.5 py-3 font-sans text-[15px] text-slate-950 dark:border-white/8 dark:bg-slate-950/70 dark:text-slate-50" + /> + + ); + })} + void props.onSubmit()} + > + Submit answers + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx new file mode 100644 index 0000000000..facdb9a64b --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -0,0 +1,160 @@ +import type { OrchestrationThread } from "@t3tools/contracts"; +import { SymbolView } from "expo-symbols"; +import { memo } from "react"; +import { Image, Pressable, ScrollView, View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { cx } from "../../lib/classNames"; +import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import type { RemoteClientConnectionState } from "../../lib/remoteClient"; + +function ComposerAction(props: { + readonly icon: + | "photo.on.rectangle" + | "doc.on.clipboard" + | "arrow.clockwise" + | "stop.fill" + | "tray.and.arrow.down.fill" + | "paperplane.fill"; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly iconTintColor: string; + readonly backgroundClassName: string; +}) { + return ( + + + + ); +} + +export interface ThreadComposerProps { + readonly draftMessage: string; + readonly draftAttachments: ReadonlyArray; + readonly placeholder: string; + readonly bottomInset?: number; + readonly connectionState: RemoteClientConnectionState; + readonly selectedThread: OrchestrationThread; + readonly queueCount: number; + readonly activeThreadBusy: boolean; + readonly onChangeDraftMessage: (value: string) => void; + readonly onPickDraftImages: () => Promise; + readonly onPasteIntoDraft: () => Promise; + readonly onRemoveDraftImage: (imageId: string) => void; + readonly onRefresh: () => Promise; + readonly onStopThread: () => Promise; + readonly onSendMessage: () => void; +} + +export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { + const canSend = + props.connectionState === "ready" && + (props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0); + const showStopAction = + props.selectedThread.session?.status === "running" || + props.selectedThread.session?.status === "starting" || + props.queueCount > 0; + + return ( + + + + + + void props.onPickDraftImages()} + /> + void props.onPasteIntoDraft()} + /> + void props.onRefresh()} + /> + + + {showStopAction ? ( + void props.onStopThread()} + /> + ) : null} + 0 + ? "tray.and.arrow.down.fill" + : "paperplane.fill" + } + backgroundClassName={cx( + canSend ? "bg-orange-500" : "bg-slate-200 dark:bg-slate-700/60", + )} + iconTintColor="#ffffff" + disabled={!canSend} + onPress={props.onSendMessage} + /> + + + + {props.draftAttachments.length > 0 ? ( + + + {props.draftAttachments.map((image) => ( + + + props.onRemoveDraftImage(image.id)} + > + + Remove + + + + ))} + + + ) : null} + {props.queueCount > 0 ? ( + + {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send + automatically. + + ) : null} + + ); +}); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx new file mode 100644 index 0000000000..7dfd3e9b39 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -0,0 +1,414 @@ +import type { + ApprovalRequestId, + OrchestrationThread, + ProviderApprovalDecision, +} from "@t3tools/contracts"; +import * as Haptics from "expo-haptics"; +import { useEffect, useRef, useState } from "react"; +import { + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + useColorScheme, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import Animated from "react-native-reanimated"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { ErrorBanner } from "../../components/ErrorBanner"; +import { GlassSafeAreaView } from "../../components/GlassSafeAreaView"; +import type { StatusTone } from "../../components/StatusPill"; +import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; +import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import type { + PendingApproval, + PendingUserInput, + PendingUserInputDraftAnswer, + ThreadFeedEntry, +} from "../../lib/threadActivity"; +import { PendingApprovalCard } from "./PendingApprovalCard"; +import { PendingUserInputCard } from "./PendingUserInputCard"; +import { ThreadComposer } from "./ThreadComposer"; +import { ThreadFeed } from "./ThreadFeed"; + +export interface ThreadDetailScreenProps { + readonly selectedThread: OrchestrationThread; + readonly screenTone: StatusTone; + readonly connectionError: string | null; + readonly httpOrigin: string | null; + readonly resolvedAuthToken: string | null; + readonly selectedThreadFeed: ReadonlyArray; + readonly selectedThreadFeedLoadingInitial: boolean; + readonly selectedThreadFeedLoadingMore: boolean; + readonly selectedThreadFeedHasMore: boolean; + readonly activeWorkDurationLabel: string | null; + readonly activePendingApproval: PendingApproval | null; + readonly respondingApprovalId: ApprovalRequestId | null; + readonly activePendingUserInput: PendingUserInput | null; + readonly activePendingUserInputDrafts: Record; + readonly activePendingUserInputAnswers: Record | null; + readonly respondingUserInputId: ApprovalRequestId | null; + readonly draftMessage: string; + readonly draftAttachments: ReadonlyArray; + readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; + readonly activeThreadBusy: boolean; + readonly selectedThreadQueueCount: number; + readonly onBack: () => void; + readonly onOpenConnectionEditor: () => void; + readonly onChangeDraftMessage: (value: string) => void; + readonly onPickDraftImages: () => Promise; + readonly onPasteIntoDraft: () => Promise; + readonly onRemoveDraftImage: (imageId: string) => void; + readonly onRefresh: () => Promise; + readonly onLoadMoreFeed: () => Promise; + readonly onRenameThread: (title: string) => Promise; + readonly onStopThread: () => Promise; + readonly onSendMessage: () => void; + readonly onRespondToApproval: ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => Promise; + readonly onSelectUserInputOption: (requestId: string, questionId: string, label: string) => void; + readonly onChangeUserInputCustomAnswer: ( + requestId: string, + questionId: string, + customAnswer: string, + ) => void; + readonly onSubmitUserInput: () => Promise; + readonly showHeader?: boolean; + readonly showContent?: boolean; +} + +function latestStreamingAssistantMessage( + feed: ReadonlyArray, +): { readonly id: string; readonly textLength: number } | null { + for (let index = feed.length - 1; index >= 0; index -= 1) { + const entry = feed[index]; + if (entry?.type !== "message") { + continue; + } + if (entry.message.role !== "assistant" || !entry.message.streaming) { + continue; + } + return { + id: entry.message.id, + textLength: entry.message.text.length, + }; + } + + return null; +} + +function useRenameDraftSync(threadId: string, threadTitle: string) { + const [renameDraft, setRenameDraft] = useState(threadTitle); + + useEffect(() => { + setRenameDraft(threadTitle); + }, [threadId, threadTitle]); + + return [renameDraft, setRenameDraft] as const; +} + +function useStreamingHaptics(threadId: string, feed: ReadonlyArray) { + const lastStreamingAssistantRef = useRef<{ + readonly id: string; + readonly textLength: number; + } | null>(null); + const lastStreamHapticAtRef = useRef(0); + const hydratedRef = useRef(false); + const previousThreadIdRef = useRef(threadId); + + useEffect(() => { + if (previousThreadIdRef.current !== threadId) { + previousThreadIdRef.current = threadId; + hydratedRef.current = false; + } + + const latestStreamingMessage = latestStreamingAssistantMessage(feed); + + if (!hydratedRef.current) { + hydratedRef.current = true; + lastStreamingAssistantRef.current = latestStreamingMessage; + return; + } + + if (!latestStreamingMessage) { + lastStreamingAssistantRef.current = null; + return; + } + + const previousStreamingMessage = lastStreamingAssistantRef.current; + lastStreamingAssistantRef.current = latestStreamingMessage; + + const isNewStream = previousStreamingMessage?.id !== latestStreamingMessage.id; + const textGrew = + previousStreamingMessage?.id === latestStreamingMessage.id && + latestStreamingMessage.textLength > previousStreamingMessage.textLength; + + if (!isNewStream && !textGrew) { + return; + } + + const now = Date.now(); + if (!isNewStream && now - lastStreamHapticAtRef.current < 320) { + return; + } + + lastStreamHapticAtRef.current = now; + void Haptics.selectionAsync(); + }, [threadId, feed]); +} + +export function ThreadDetailScreen(props: ThreadDetailScreenProps) { + const isDarkMode = useColorScheme() === "dark"; + const insets = useSafeAreaInsets(); + const agentLabel = `${props.selectedThread.modelSelection.provider} agent`; + const headerOverlayHeight = insets.top + 70; + const composerBottomInset = Math.max(insets.bottom, 12); + const screenBackgroundColor = isDarkMode ? "#020617" : "#f8fafc"; + const modalBackdropColor = isDarkMode ? "rgba(2,6,23,0.68)" : "rgba(15,23,42,0.22)"; + const modalPanelColor = isDarkMode ? "#111827" : "#ffffff"; + const modalBorderColor = isDarkMode ? "rgba(255,255,255,0.08)" : "#e2e8f0"; + const modalMutedColor = isDarkMode ? "#94a3b8" : "#64748b"; + const modalPrimaryColor = isDarkMode ? "#f8fafc" : "#020617"; + const connectionPulse = props.activeThreadBusy; + const [renameVisible, setRenameVisible] = useState(false); + const [renameDraft, setRenameDraft] = useRenameDraftSync( + props.selectedThread.id, + props.selectedThread.title, + ); + const showHeader = props.showHeader ?? true; + const showContent = props.showContent ?? true; + + useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); + + async function handleSubmitRename(): Promise { + const trimmed = renameDraft.trim(); + if (trimmed.length === 0) { + return; + } + + await props.onRenameThread(trimmed); + setRenameVisible(false); + } + + return ( + + {showHeader ? ( + + + + Back + + + } + centerSlot={ + + setRenameVisible(true)}> + + {props.selectedThread.title} + + + + {props.activeWorkDurationLabel ? props.activeWorkDurationLabel : ""} + + + } + rightSlot={ + + + + } + /> + + ) : null} + + {showContent && props.connectionError ? ( + + + + ) : null} + + {showContent ? ( + <> + + + + + {props.activePendingApproval || props.activePendingUserInput ? ( + + {props.activePendingApproval ? ( + + ) : null} + {props.activePendingUserInput ? ( + + ) : null} + + ) : null} + + + + ) : ( + + )} + + setRenameVisible(false)} + > + + + + + Thread name + + + Rename thread + + + + { + void handleSubmitRename(); + }} + /> + + + { + setRenameDraft(props.selectedThread.title); + setRenameVisible(false); + }} + > + + Cancel + + + { + void handleSubmitRename(); + }} + > + + Save + + + + + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx new file mode 100644 index 0000000000..4daf35844a --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -0,0 +1,389 @@ +import { FlashList, type FlashListRef, type ListRenderItemInfo } from "@shopify/flash-list"; +import { memo, useCallback, useEffect, useRef } from "react"; +import Markdown from "react-native-markdown-display"; +import { + ActivityIndicator, + Image, + type NativeScrollEvent, + type NativeSyntheticEvent, + View, +} from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { EmptyState } from "../../components/EmptyState"; +import { cx } from "../../lib/classNames"; +import type { ThreadFeedEntry } from "../../lib/threadActivity"; +import { relativeTime } from "../../lib/time"; +import { messageImageUrl } from "./threadPresentation"; + +export interface ThreadFeedProps { + readonly threadId: string; + readonly feed: ReadonlyArray; + readonly loadingInitial: boolean; + readonly loadingMore: boolean; + readonly hasMore: boolean; + readonly httpOrigin: string | null; + readonly authToken: string | null; + readonly agentLabel: string; + readonly contentTopInset?: number; + readonly contentBottomInset?: number; + readonly onLoadMore: () => Promise; +} + +function FeedLoadingState(props: { readonly label: string }) { + return ( + + + {props.label} + + ); +} + +function stripShellWrapper(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); + return (match?.[1] ?? trimmed).trim(); +} + +function compactActivityDetail(detail: string | null): string | null { + if (!detail) { + return null; + } + + const cleaned = stripShellWrapper(detail); + return cleaned.length > 0 ? cleaned : null; +} + +function buildActivityRows( + activities: ReadonlyArray<{ + readonly id: string; + readonly summary: string; + readonly detail: string | null; + readonly status: string | null; + }>, +) { + const rows: Array<{ + id: string; + label: string; + detail: string | null; + status: string | null; + }> = []; + + for (const activity of activities) { + const detail = compactActivityDetail(activity.detail); + const label = detail ?? activity.summary; + const previous = rows.at(-1); + + if (previous && previous.label === label) { + rows[rows.length - 1] = { + ...previous, + detail, + status: activity.status ?? previous.status, + }; + continue; + } + + rows.push({ + id: activity.id, + label, + detail, + status: activity.status, + }); + } + + return rows; +} + +const MARKDOWN_BASE = { + body: { + color: "#020617", + fontSize: 15, + lineHeight: 22, + }, + paragraph: { marginTop: 0, marginBottom: 0 }, + bullet_list: { marginTop: 0, marginBottom: 0 }, + ordered_list: { marginTop: 0, marginBottom: 0 }, + list_item: { marginTop: 0, marginBottom: 2 }, + strong: { fontWeight: "800" as const, color: "#020617" }, + em: { fontStyle: "italic" as const }, + link: { color: "#0369a1" }, + blockquote: { + borderLeftWidth: 3, + borderLeftColor: "rgba(100,116,139,0.35)", + paddingLeft: 12, + marginLeft: 0, + }, +}; + +const USER_MARKDOWN_STYLES = { + ...MARKDOWN_BASE, + code_inline: { + backgroundColor: "rgba(255,255,255,0.55)", + color: "#0f172a", + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 2, + }, + code_block: { + backgroundColor: "rgba(255,255,255,0.6)", + color: "#0f172a", + borderRadius: 14, + padding: 12, + }, + fence: { + backgroundColor: "rgba(255,255,255,0.6)", + color: "#0f172a", + borderRadius: 14, + padding: 12, + }, +}; + +const ASSISTANT_MARKDOWN_STYLES = { + ...MARKDOWN_BASE, + code_inline: { + backgroundColor: "rgba(15,23,42,0.08)", + color: "#0f172a", + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 2, + }, + code_block: { + backgroundColor: "rgba(15,23,42,0.08)", + color: "#0f172a", + borderRadius: 14, + padding: 12, + }, + fence: { + backgroundColor: "rgba(15,23,42,0.08)", + color: "#0f172a", + borderRadius: 14, + padding: 12, + }, +}; + +function renderFeedEntry( + info: ListRenderItemInfo, + props: Pick, +) { + const entry = info.item; + + if (entry.type === "message") { + const { message } = entry; + const isUser = message.role === "user"; + const markdownStyles = isUser ? USER_MARKDOWN_STYLES : ASSISTANT_MARKDOWN_STYLES; + + return ( + + + + {isUser ? "You" : props.agentLabel} + + + {relativeTime(message.createdAt)} + {message.streaming ? " • live" : ""} + + + {message.text.trim().length > 0 ? ( + {message.text} + ) : null} + {message.attachments?.map((attachment) => { + const uri = messageImageUrl(props.httpOrigin, attachment.id, props.authToken); + if (!uri) { + return null; + } + + return ( + + ); + })} + + ); + } + + if (entry.type === "queued-message") { + return ( + + + + {entry.sending ? "Sending next" : "Queued"} + + + {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`} + + + + {entry.queuedMessage.text} + + {entry.queuedMessage.attachments.length > 0 ? ( + + {entry.queuedMessage.attachments.length} image + {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached + + ) : null} + + ); + } + + const rows = buildActivityRows(entry.activities); + + return ( + + + + Command center + + + {relativeTime(entry.createdAt)} + + + {rows.map((row, index) => ( + 0 && "border-t border-slate-200/80 dark:border-white/4", + )} + > + + + {row.label} + + + {row.status ? row.status.replaceAll("_", " ") : "done"} + + + {row.detail && row.detail !== row.label ? ( + + {row.detail} + + ) : null} + + ))} + + ); +} + +function useAutoScrollToLatest( + listRef: React.RefObject | null>, + threadId: string, + feed: ReadonlyArray, +) { + const shouldFollowLatestRef = useRef(true); + const previousThreadIdRef = useRef(threadId); + const previousFeedLengthRef = useRef(feed.length); + + const updateFollowLatest = useCallback((event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height); + shouldFollowLatestRef.current = distanceFromBottom <= 96; + }, []); + + useEffect(() => { + const isNewThread = previousThreadIdRef.current !== threadId; + if (isNewThread) { + previousThreadIdRef.current = threadId; + previousFeedLengthRef.current = feed.length; + shouldFollowLatestRef.current = true; + } + + const feedGrew = feed.length >= previousFeedLengthRef.current; + previousFeedLengthRef.current = feed.length; + + if (!shouldFollowLatestRef.current || (!feedGrew && !isNewThread)) { + return; + } + + requestAnimationFrame(() => { + listRef.current?.scrollToEnd({ + animated: !isNewThread, + }); + }); + }, [feed, feed.length, listRef, threadId]); + + return updateFollowLatest; +} + +export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { + const listRef = useRef>(null); + const updateFollowLatest = useAutoScrollToLatest(listRef, props.threadId, props.feed); + + if (props.loadingInitial) { + return ( + + + + ); + } + + if (props.feed.length === 0) { + return ( + + + + ); + } + + return ( + + renderFeedEntry(info, props)} + keyExtractor={(entry) => `${entry.type}:${entry.id}`} + keyboardShouldPersistTaps="handled" + onScroll={updateFollowLatest} + scrollEventThrottle={16} + onStartReached={ + props.hasMore && !props.loadingInitial && !props.loadingMore + ? () => void props.onLoadMore() + : undefined + } + onStartReachedThreshold={0.12} + maintainVisibleContentPosition={{ + autoscrollToBottomThreshold: 0.2, + animateAutoScrollToBottom: true, + startRenderingFromBottom: true, + }} + contentContainerStyle={{ + paddingHorizontal: 16, + paddingTop: props.contentTopInset ?? 18, + paddingBottom: props.contentBottomInset ?? 18, + }} + ListHeaderComponent={ + props.loadingMore ? : null + } + /> + + ); +}); diff --git a/apps/mobile/src/features/threads/ThreadListScreen.tsx b/apps/mobile/src/features/threads/ThreadListScreen.tsx new file mode 100644 index 0000000000..cf1d6fde3c --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadListScreen.tsx @@ -0,0 +1,683 @@ +import type { OrchestrationProject, OrchestrationThread, ProjectId } from "@t3tools/contracts"; +import { SymbolView } from "expo-symbols"; +import { useRef, useState } from "react"; +import { Pressable, ScrollView, View, type View as RNView } from "react-native"; +import { useColorScheme } from "react-native"; +import Animated, { FadeInDown, FadeOutUp, LinearTransition } from "react-native-reanimated"; +import Svg, { Path } from "react-native-svg"; + +import { AppText as Text } from "../../components/AppText"; +import { EmptyState } from "../../components/EmptyState"; +import { ErrorBanner } from "../../components/ErrorBanner"; +import { GlassSafeAreaView } from "../../components/GlassSafeAreaView"; +import { StatusPill } from "../../components/StatusPill"; +import { cx } from "../../lib/classNames"; +import { sortCopy } from "../../lib/arrayCompat"; +import type { RemoteClientConnectionState } from "../../lib/remoteClient"; +import { relativeTime } from "../../lib/time"; +import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; +import { lastConversationLine, threadStatusTone } from "./threadPresentation"; + +export interface ThreadListScreenProps { + readonly heroTitle: string; + readonly showBrandWordmark: boolean; + readonly screenTone: { + readonly label: string; + readonly pillClassName: string; + readonly textClassName: string; + }; + readonly connectionState: RemoteClientConnectionState; + readonly connectionPulse: boolean; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly hasClient: boolean; + readonly hasServerConfig: boolean; + readonly hiddenThreadId?: OrchestrationThread["id"] | null; + readonly connectionError: string | null; + readonly onOpenConnectionEditor: () => void; + readonly onRefresh: () => Promise; + readonly onCreateThread: (projectId: ProjectId) => Promise; + readonly onSelectThread: ( + threadId: OrchestrationThread["id"], + sourceFrame: TransitionSourceFrame | null, + ) => void; +} + +export interface TransitionSourceFrame { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +function T3Wordmark(props: { readonly color: string }) { + return ( + + + + ); +} + +type GlyphName = + | "link" + | "stack" + | "folder" + | "refresh" + | "arrow-up-right" + | "chevron-down" + | "chevron-up" + | "device" + | "spark"; + +const GLYPH_SYMBOL_NAMES = { + link: "link", + stack: "square.stack.3d.up", + folder: "folder", + refresh: "arrow.clockwise", + "arrow-up-right": "arrow.up.right", + "chevron-down": "chevron.down", + "chevron-up": "chevron.up", + device: "iphone", + spark: "sparkles", +} as const; + +const GLYPH_PATHS: Record = { + link: "M6.1 9.9 9.9 6.1M5.2 11.8H4a2.8 2.8 0 1 1 0-5.6h1.2M10.8 4.2H12a2.8 2.8 0 1 1 0 5.6h-1.2", + stack: "M2.5 5.2 8 2.5l5.5 2.7L8 7.9 2.5 5.2ZM2.5 8 8 10.7 13.5 8M2.5 10.8 8 13.5l5.5-2.7", + folder: "M1.8 4.5h4l1.4 1.6h7v5.7a1 1 0 0 1-1 1H2.8a1 1 0 0 1-1-1V4.5Z", + refresh: "M13.2 5.2V2.8M13.2 2.8h-2.4M13.2 2.8 10.8 5.2M12.3 8A4.3 4.3 0 1 1 8 3.7", + "arrow-up-right": "M5 11 11 5M6 5h5v5", + "chevron-down": "m3.5 5.8 4.5 4.4 4.5-4.4", + "chevron-up": "m3.5 10.2 4.5-4.4 4.5 4.4", + device: + "M4.2 2.5h7.6a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4.2a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1ZM6.4 11.7h3.2", + spark: "M8 2.4 9.1 6.9 13.6 8l-4.5 1.1L8 13.6 6.9 9.1 2.4 8l4.5-1.1L8 2.4Z", +}; + +const GLYPH_STROKE_JOIN: Partial> = { + folder: "round", +}; + +function Glyph(props: { + readonly name: GlyphName; + readonly color: string; + readonly size?: number; +}) { + const size = props.size ?? 16; + const strokeWidth = 1.8; + const symbolName = GLYPH_SYMBOL_NAMES[props.name]; + const path = GLYPH_PATHS[props.name]; + const strokeLinejoin = GLYPH_STROKE_JOIN[props.name] ?? "round"; + + const fallback = ( + + + + ); + + return ( + + ); +} + +function ExpandThreadsToggle(props: { + readonly expanded: boolean; + readonly hiddenCount: number; + readonly palette: ReturnType; + readonly onPress: () => void; +}) { + const label = props.expanded ? "Show less" : `Show ${props.hiddenCount} more`; + + return ( + + + + + + {label} + + + + + + + ); +} + +function toneStyles(label: string) { + const value = label.toLowerCase(); + + if (value.includes("error") || value.includes("disconnect")) { + return { + dot: "bg-rose-500", + line: "bg-rose-300/80", + chip: "bg-rose-100/90", + text: "text-rose-700", + }; + } + + if (value.includes("run")) { + return { + dot: "bg-orange-500", + line: "bg-orange-300/80", + chip: "bg-orange-100/90", + text: "text-orange-700", + }; + } + + if (value.includes("connect") || value.includes("ready") || value.includes("start")) { + return { + dot: "bg-sky-500", + line: "bg-sky-300/80", + chip: "bg-sky-100/90", + text: "text-sky-700", + }; + } + + return { + dot: "bg-slate-500", + line: "bg-slate-300/80", + chip: "bg-slate-200/90", + text: "text-slate-700", + }; +} + +function StatCell(props: { + readonly icon: React.ComponentProps["name"]; + readonly label: string; + readonly value: string; + readonly palette: ReturnType; +}) { + return ( + + + + + {props.label} + + + + {props.value} + + + ); +} + +function ProjectActionButton(props: { + readonly palette: ReturnType; + readonly label: string; + readonly icon: React.ComponentProps["name"]; + readonly onPress: () => void; +}) { + return ( + + + + {props.label} + + + ); +} + +function ThreadRow(props: { + readonly thread: OrchestrationThread; + readonly isLast: boolean; + readonly hidden?: boolean; + readonly onPress: (sourceFrame: TransitionSourceFrame | null) => void; + readonly palette: ReturnType; +}) { + const containerRef = useRef(null); + const threadTone = threadStatusTone(props.thread); + const styles = toneStyles(threadTone.label); + + return ( + { + containerRef.current?.measureInWindow((x, y, width, height) => { + if (width > 0 && height > 0) { + props.onPress({ x, y, width, height }); + return; + } + props.onPress(null); + }); + }} + > + + + + + + + {props.thread.title} + + + + + {lastConversationLine(props.thread)} + + + + + + + + + + {props.thread.modelSelection.provider} · {props.thread.modelSelection.model} ·{" "} + {relativeTime(props.thread.updatedAt ?? props.thread.createdAt)} + + + + + + + + ); +} + +function SectionPanel(props: { + readonly children: React.ReactNode; + readonly palette: ReturnType; +}) { + return ( + + {props.children} + + ); +} + +function orderThreadsByRecency(threads: ReadonlyArray) { + return sortCopy(threads, (left, right) => { + const leftTime = new Date(left.updatedAt ?? left.createdAt).getTime(); + const rightTime = new Date(right.updatedAt ?? right.createdAt).getTime(); + return rightTime - leftTime; + }); +} + +function makePalette(isDarkMode: boolean) { + if (isDarkMode) { + return { + canvas: "#11100e", + panel: "#1a1815", + panelAlt: "#221f1b", + border: "rgba(255,255,255,0.08)", + text: "#f5f1ea", + muted: "#a69e92", + rule: "rgba(255,255,255,0.1)", + tabActive: "#f5f1ea", + tabActiveText: "#171512", + tabInactive: "#2a2723", + tabInactiveText: "#b2ab9f", + rail: "#201d19", + action: "#f5f1ea", + actionText: "#171512", + actionSecondary: "#2b2823", + actionSecondaryText: "#f5f1ea", + }; + } + + return { + canvas: "#efe7dc", + panel: "#fbf7f1", + panelAlt: "#f3ede3", + border: "#d8cec1", + text: "#1f1b17", + muted: "#8a8175", + rule: "#ddd2c5", + tabActive: "#2c2a2d", + tabActiveText: "#f8f4ee", + tabInactive: "#ffffff", + tabInactiveText: "#b0a79b", + rail: "#f3ede3", + action: "#2c2a2d", + actionText: "#f8f4ee", + actionSecondary: "#ffffff", + actionSecondaryText: "#1f1b17", + }; +} + +export function ThreadListScreen(props: ThreadListScreenProps) { + const isDarkMode = useColorScheme() === "dark"; + const palette = makePalette(isDarkMode); + const [expandedProjectIds, setExpandedProjectIds] = useState>(new Set()); + const groupedProjects = props.projects.map((project) => ({ + project, + threads: props.threads.filter((thread) => thread.projectId === project.id), + })); + + return ( + + + + + + Code + + + } + /> + + + + + + {props.connectionError ? : null} + + + + + + + Control board + + + + + + + + + + + + + + + + + + {props.hasClient ? "Connected" : "Awaiting connection"} + + + + + + + + {props.hasClient ? "Link" : "Connect"} + + + + {props.hasClient ? ( + void props.onRefresh()} + > + + + Refresh + + + ) : null} + + + + + + {props.threads.length === 0 ? ( + + ) : null} + + {groupedProjects.map(({ project, threads }) => { + const sortedThreads = orderThreadsByRecency(threads); + const latestThread = sortedThreads[0]; + const remainingThreads = sortedThreads.slice(1); + const isExpanded = expandedProjectIds.has(project.id); + + return ( + + + + + + + + {project.title} + + + + + + + {threads.length} + + + + + + {project.workspaceRoot} + + + void props.onCreateThread(project.id)} + /> + + + + {latestThread ? ( + + + + ); + })} + + + + + ); +} diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts new file mode 100644 index 0000000000..38f17df2ac --- /dev/null +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -0,0 +1,86 @@ +import type { OrchestrationThread, ServerConfig as T3ServerConfig } from "@t3tools/contracts"; + +import type { StatusTone } from "../../components/StatusPill"; +import { reverseCopy } from "../../lib/arrayCompat"; + +export function threadSortValue(thread: OrchestrationThread): number { + const candidate = Date.parse(thread.updatedAt ?? thread.createdAt); + return Number.isNaN(candidate) ? 0 : candidate; +} + +export function threadStatusTone(thread: OrchestrationThread): StatusTone { + const status = thread.session?.status; + if (status === "running") { + return { + label: "Running", + pillClassName: "bg-orange-500/12 dark:bg-orange-500/16", + textClassName: "text-orange-700 dark:text-orange-300", + }; + } + if (status === "ready") { + return { + label: "Ready", + pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", + textClassName: "text-emerald-700 dark:text-emerald-300", + }; + } + if (status === "starting") { + return { + label: "Starting", + pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", + textClassName: "text-sky-700 dark:text-sky-300", + }; + } + if (status === "error") { + return { + label: "Error", + pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", + textClassName: "text-rose-700 dark:text-rose-300", + }; + } + return { + label: "Idle", + pillClassName: "bg-slate-500/10 dark:bg-slate-500/16", + textClassName: "text-slate-600 dark:text-slate-300", + }; +} + +export function messageImageUrl( + httpOrigin: string | null, + attachmentId: string, + authToken: string | null, +): string | null { + if (!httpOrigin) { + return null; + } + + const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpOrigin); + if (authToken) { + url.searchParams.set("token", authToken); + } + return url.toString(); +} + +export function lastConversationLine(thread: OrchestrationThread): string { + const candidate = reverseCopy(thread.messages).find( + (message) => message.role === "user" || message.role === "assistant", + ); + if (!candidate) { + return "No messages yet."; + } + + const trimmed = candidate.text.trim(); + if (trimmed.length === 0) { + return candidate.role === "assistant" ? "(empty assistant response)" : "(empty message)"; + } + return trimmed; +} + +export function screenTitle(config: T3ServerConfig | null, serverUrl: string | null): string { + if (config) { + const segments = config.cwd.split(/[/\\]/).filter(Boolean); + const candidate = segments.at(-1) ?? config.cwd; + return /^t3[-_\s]?code$/i.test(candidate) ? "T3 Code" : candidate; + } + return serverUrl ?? "T3 Remote"; +} diff --git a/apps/mobile/src/lib/arrayCompat.ts b/apps/mobile/src/lib/arrayCompat.ts new file mode 100644 index 0000000000..3343d5e00f --- /dev/null +++ b/apps/mobile/src/lib/arrayCompat.ts @@ -0,0 +1,42 @@ +export function sortCopy( + values: ReadonlyArray, + compareFn: (left: T, right: T) => number, +): T[] { + const next = [...values]; + + for (let index = 1; index < next.length; index += 1) { + const current = next[index]; + if (current === undefined) { + continue; + } + + let insertionIndex = index - 1; + while (insertionIndex >= 0) { + const candidate = next[insertionIndex]; + if (candidate !== undefined && compareFn(candidate, current) <= 0) { + break; + } + + next[insertionIndex + 1] = candidate; + insertionIndex -= 1; + } + + next[insertionIndex + 1] = current; + } + + return next; +} + +export function reverseCopy(values: ReadonlyArray): T[] { + const reversed = Array.from({ length: values.length }) as T[]; + + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (value === undefined) { + continue; + } + reversed[values.length - 1 - index] = value; + } + + return reversed; +} diff --git a/apps/mobile/src/lib/classNames.ts b/apps/mobile/src/lib/classNames.ts new file mode 100644 index 0000000000..ef515666fc --- /dev/null +++ b/apps/mobile/src/lib/classNames.ts @@ -0,0 +1,3 @@ +export function cx(...parts: Array): string { + return parts.filter(Boolean).join(" "); +} diff --git a/apps/mobile/src/lib/clientId.ts b/apps/mobile/src/lib/clientId.ts new file mode 100644 index 0000000000..88c7e8219b --- /dev/null +++ b/apps/mobile/src/lib/clientId.ts @@ -0,0 +1,3 @@ +export function newClientId(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts new file mode 100644 index 0000000000..eb2ff074ae --- /dev/null +++ b/apps/mobile/src/lib/composerImages.ts @@ -0,0 +1,196 @@ +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + type UploadChatImageAttachment, +} from "@t3tools/contracts"; + +import { newClientId } from "./clientId"; + +export interface DraftComposerImageAttachment extends UploadChatImageAttachment { + readonly id: string; + readonly previewUri: string; +} + +function estimateBase64ByteSize(base64: string): number { + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + return Math.floor((base64.length * 3) / 4) - padding; +} + +async function loadImagePicker() { + try { + return await import("expo-image-picker"); + } catch (error) { + throw new Error("Image attachments are unavailable right now.", { cause: error }); + } +} + +async function loadClipboard() { + try { + return await import("expo-clipboard"); + } catch (error) { + throw new Error("Clipboard paste is unavailable right now.", { cause: error }); + } +} + +export async function pickComposerImages(input: { readonly existingCount: number }): Promise<{ + readonly images: ReadonlyArray; + readonly error: string | null; +}> { + const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; + if (remainingSlots <= 0) { + return { + images: [], + error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, + }; + } + + let imagePicker: Awaited>; + try { + imagePicker = await loadImagePicker(); + } catch (error) { + return { + images: [], + error: + error instanceof Error ? error.message : "Image attachments are unavailable right now.", + }; + } + + const permission = await imagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + return { + images: [], + error: "Allow photo library access to attach images.", + }; + } + + const result = await imagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsMultipleSelection: true, + selectionLimit: remainingSlots, + base64: true, + quality: 1, + }); + + if (result.canceled) { + return { + images: [], + error: null, + }; + } + + const nextImages: DraftComposerImageAttachment[] = []; + let error: string | null = null; + + for (const asset of result.assets) { + const mimeType = asset.mimeType?.toLowerCase(); + if (!mimeType?.startsWith("image/")) { + error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; + continue; + } + + const base64 = asset.base64; + if (!base64) { + error = `Failed to read '${asset.fileName ?? "image"}'.`; + continue; + } + + const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64); + if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; + continue; + } + + nextImages.push({ + id: newClientId("attachment"), + type: "image", + name: asset.fileName ?? "image", + mimeType, + sizeBytes, + dataUrl: `data:${mimeType};base64,${base64}`, + previewUri: asset.uri, + }); + } + + return { + images: nextImages, + error, + }; +} + +export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<{ + readonly images: ReadonlyArray; + readonly text: string | null; + readonly error: string | null; +}> { + let clipboard: Awaited>; + try { + clipboard = await loadClipboard(); + } catch (error) { + return { + images: [], + text: null, + error: error instanceof Error ? error.message : "Clipboard paste is unavailable right now.", + }; + } + + const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; + + if (await clipboard.hasImageAsync()) { + if (remainingSlots <= 0) { + return { + images: [], + text: null, + error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, + }; + } + const image = await clipboard.getImageAsync({ format: "png" }); + if (!image) { + return { + images: [], + text: null, + error: "Clipboard image is unavailable.", + }; + } + + const base64 = image.data.split(",")[1] ?? ""; + const sizeBytes = estimateBase64ByteSize(base64); + if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + return { + images: [], + text: null, + error: "Clipboard image exceeds the 10 MB attachment limit.", + }; + } + + return { + images: [ + { + id: newClientId("attachment"), + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes, + dataUrl: image.data, + previewUri: image.data, + }, + ], + text: null, + error: null, + }; + } + + if (await clipboard.hasStringAsync()) { + const text = await clipboard.getStringAsync(); + return { + images: [], + text: text.length > 0 ? text : null, + error: text.length > 0 ? null : "Clipboard is empty.", + }; + } + + return { + images: [], + text: null, + error: "Clipboard does not contain pasteable text or image content.", + }; +} diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts new file mode 100644 index 0000000000..f1f5afe038 --- /dev/null +++ b/apps/mobile/src/lib/connection.ts @@ -0,0 +1,96 @@ +export interface RemoteConnectionInput { + readonly serverUrl: string; + readonly authToken: string; +} + +export interface RemoteConnectionConfig { + readonly serverUrl: string; + readonly authToken: string | null; + readonly displayUrl: string; + readonly httpOrigin: string; + readonly wsUrl: string; +} + +const SUPPORTED_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); +const REMOTE_HEALTH_PATH = "/api/remote/health"; +const PREFLIGHT_TIMEOUT_MS = 5_000; + +export function resolveRemoteConnection(input: RemoteConnectionInput): RemoteConnectionConfig { + const rawServerUrl = input.serverUrl.trim(); + if (rawServerUrl.length === 0) { + throw new Error("Server URL is required."); + } + + let parsed: URL; + try { + parsed = new URL(rawServerUrl.includes("://") ? rawServerUrl : `http://${rawServerUrl}`); + } catch { + throw new Error("Enter a valid server URL like http://192.168.1.42:3773."); + } + + if (!SUPPORTED_PROTOCOLS.has(parsed.protocol)) { + throw new Error("Server URL must use http, https, ws, or wss."); + } + + const httpProtocol = + parsed.protocol === "https:" || parsed.protocol === "wss:" ? "https:" : "http:"; + const wsProtocol = httpProtocol === "https:" ? "wss:" : "ws:"; + const httpOrigin = `${httpProtocol}//${parsed.host}`; + const wsUrl = new URL("/ws", `${wsProtocol}//${parsed.host}`); + + const authToken = input.authToken.trim(); + if (authToken.length > 0) { + wsUrl.searchParams.set("token", authToken); + } + + return { + serverUrl: rawServerUrl, + authToken: authToken.length > 0 ? authToken : null, + displayUrl: httpOrigin, + httpOrigin, + wsUrl: wsUrl.toString(), + }; +} + +export async function preflightRemoteConnection(connection: RemoteConnectionConfig): Promise { + const url = new URL(REMOTE_HEALTH_PATH, connection.httpOrigin); + if (connection.authToken) { + url.searchParams.set("token", connection.authToken); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PREFLIGHT_TIMEOUT_MS); + + let response: Response; + try { + response = await fetch(url.toString(), { + method: "GET", + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Timed out reaching the T3 server. Check the URL and make sure it is up.", { + cause: error, + }); + } + throw new Error("Could not reach the T3 server. Check the URL and try again.", { + cause: error, + }); + } finally { + clearTimeout(timeout); + } + + if (response.status === 401) { + throw new Error( + "Remote access rejected the auth token. Verify the server token and try again.", + ); + } + + if (response.status === 503) { + throw new Error("The T3 server is up, but it is not ready yet. Wait a moment and try again."); + } + + if (!response.ok) { + throw new Error(`Remote server responded with ${response.status}.`); + } +} diff --git a/apps/mobile/src/lib/orchestration.ts b/apps/mobile/src/lib/orchestration.ts new file mode 100644 index 0000000000..1320402f96 --- /dev/null +++ b/apps/mobile/src/lib/orchestration.ts @@ -0,0 +1,451 @@ +import type { + ChatAttachment, + MessageId, + OrchestrationEvent, + OrchestrationProject, + OrchestrationReadModel, + OrchestrationThread, + ThreadId, +} from "@t3tools/contracts"; +import { sortCopy } from "./arrayCompat"; + +const MAX_THREAD_PROPOSED_PLANS = 200; + +function updateThread( + snapshot: OrchestrationReadModel, + threadId: ThreadId, + updater: ( + thread: OrchestrationReadModel["threads"][number], + ) => OrchestrationReadModel["threads"][number], +): OrchestrationReadModel["threads"] { + return snapshot.threads.map((thread) => (thread.id === threadId ? updater(thread) : thread)); +} + +function updateProject( + snapshot: OrchestrationReadModel, + projectId: OrchestrationProject["id"], + updater: (project: OrchestrationProject) => OrchestrationProject, +): OrchestrationReadModel["projects"] { + return snapshot.projects.map((project) => + project.id === projectId ? updater(project) : project, + ); +} + +function upsertProject( + snapshot: OrchestrationReadModel, + nextProject: OrchestrationProject, +): OrchestrationReadModel["projects"] { + const existing = snapshot.projects.find((project) => project.id === nextProject.id); + if (existing) { + return updateProject(snapshot, nextProject.id, () => nextProject); + } + return [...snapshot.projects, nextProject]; +} + +function upsertThread( + snapshot: OrchestrationReadModel, + nextThread: OrchestrationThread, +): OrchestrationReadModel["threads"] { + const existing = snapshot.threads.find((thread) => thread.id === nextThread.id); + if (existing) { + return updateThread(snapshot, nextThread.id, () => nextThread); + } + return [...snapshot.threads, nextThread]; +} + +export function applyRealtimeEvent( + snapshot: OrchestrationReadModel, + event: OrchestrationEvent, +): OrchestrationReadModel { + const nextBase: OrchestrationReadModel = { + ...snapshot, + snapshotSequence: event.sequence, + updatedAt: event.occurredAt, + }; + + switch (event.type) { + case "project.created": + return { + ...nextBase, + projects: upsertProject(nextBase, { + id: event.payload.projectId, + title: event.payload.title, + workspaceRoot: event.payload.workspaceRoot, + defaultModelSelection: event.payload.defaultModelSelection, + scripts: event.payload.scripts, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + deletedAt: null, + }), + }; + + case "project.meta-updated": + return { + ...nextBase, + projects: updateProject(nextBase, event.payload.projectId, (project) => ({ + ...project, + ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.workspaceRoot !== undefined + ? { workspaceRoot: event.payload.workspaceRoot } + : {}), + ...(event.payload.defaultModelSelection !== undefined + ? { defaultModelSelection: event.payload.defaultModelSelection } + : {}), + ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), + updatedAt: event.payload.updatedAt, + })), + }; + + case "project.deleted": + return { + ...nextBase, + projects: updateProject(nextBase, event.payload.projectId, (project) => ({ + ...project, + deletedAt: event.payload.deletedAt, + updatedAt: event.payload.deletedAt, + })), + }; + + case "thread.created": + return { + ...nextBase, + threads: upsertThread(nextBase, { + id: event.payload.threadId, + projectId: event.payload.projectId, + title: event.payload.title, + modelSelection: event.payload.modelSelection, + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + branch: event.payload.branch, + worktreePath: event.payload.worktreePath, + latestTurn: null, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }), + }; + + case "thread.deleted": + return { + ...nextBase, + threads: updateThread(nextBase, event.payload.threadId, (thread) => ({ + ...thread, + deletedAt: event.payload.deletedAt, + updatedAt: event.payload.deletedAt, + })), + }; + + case "thread.archived": + return { + ...nextBase, + threads: updateThread(nextBase, event.payload.threadId, (thread) => ({ + ...thread, + archivedAt: event.payload.archivedAt, + updatedAt: event.payload.updatedAt, + })), + }; + + case "thread.unarchived": + return { + ...nextBase, + threads: updateThread(nextBase, event.payload.threadId, (thread) => ({ + ...thread, + archivedAt: null, + updatedAt: event.payload.updatedAt, + })), + }; + + case "thread.meta-updated": + return { + ...nextBase, + threads: updateThread(nextBase, event.payload.threadId, (thread) => ({ + ...thread, + ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.modelSelection !== undefined + ? { modelSelection: event.payload.modelSelection } + : {}), + ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), + ...(event.payload.worktreePath !== undefined + ? { worktreePath: event.payload.worktreePath } + : {}), + updatedAt: event.payload.updatedAt, + })), + }; + + case "thread.runtime-mode-set": + return { + ...nextBase, + threads: updateThread(nextBase, event.payload.threadId, (thread) => ({ + ...thread, + runtimeMode: event.payload.runtimeMode, + updatedAt: event.payload.updatedAt, + })), + }; + + case "thread.interaction-mode-set": + return { + ...nextBase, + threads: updateThread(nextBase, event.payload.threadId, (thread) => ({ + ...thread, + interactionMode: event.payload.interactionMode, + updatedAt: event.payload.updatedAt, + })), + }; + + case "thread.message-sent": + return { + ...nextBase, + threads: updateThread(nextBase, event.payload.threadId, (thread) => { + const existing = thread.messages.find( + (message) => message.id === event.payload.messageId, + ); + const nextMessages = existing + ? thread.messages.map((message) => + message.id === event.payload.messageId + ? { + ...message, + text: event.payload.streaming + ? `${message.text}${event.payload.text}` + : event.payload.text.length > 0 + ? event.payload.text + : message.text, + turnId: event.payload.turnId, + streaming: event.payload.streaming, + updatedAt: event.payload.updatedAt, + ...(event.payload.attachments + ? { attachments: event.payload.attachments } + : {}), + } + : message, + ) + : [ + ...thread.messages, + { + id: event.payload.messageId, + role: event.payload.role, + text: event.payload.text, + turnId: event.payload.turnId, + streaming: event.payload.streaming, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + ...(event.payload.attachments ? { attachments: event.payload.attachments } : {}), + }, + ]; + + return { + ...thread, + messages: nextMessages, + updatedAt: event.occurredAt, + }; + }), + }; + + case "thread.turn-start-requested": + return { + ...nextBase, + threads: updateThread(nextBase, event.payload.threadId, (thread) => ({ + ...thread, + updatedAt: event.occurredAt, + })), + }; + + case "thread.session-set": + return { + ...nextBase, + threads: updateThread(nextBase, event.payload.threadId, (thread) => ({ + ...thread, + session: event.payload.session, + latestTurn: + event.payload.session.status === "running" && + event.payload.session.activeTurnId !== null + ? { + turnId: event.payload.session.activeTurnId, + state: "running", + requestedAt: + thread.latestTurn?.turnId === event.payload.session.activeTurnId + ? thread.latestTurn.requestedAt + : event.payload.session.updatedAt, + startedAt: + thread.latestTurn?.turnId === event.payload.session.activeTurnId + ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) + : event.payload.session.updatedAt, + completedAt: null, + assistantMessageId: + thread.latestTurn?.turnId === event.payload.session.activeTurnId + ? thread.latestTurn.assistantMessageId + : null, + } + : thread.latestTurn, + updatedAt: event.occurredAt, + })), + }; + + case "thread.proposed-plan-upserted": + return { + ...nextBase, + threads: updateThread(nextBase, event.payload.threadId, (thread) => ({ + ...thread, + proposedPlans: sortCopy( + [ + ...thread.proposedPlans.filter((proposedPlan) => { + return proposedPlan.id !== event.payload.proposedPlan.id; + }), + event.payload.proposedPlan, + ], + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ).slice(-MAX_THREAD_PROPOSED_PLANS), + updatedAt: event.occurredAt, + })), + }; + + case "thread.turn-diff-completed": + return { + ...nextBase, + threads: updateThread(nextBase, event.payload.threadId, (thread) => { + const checkpoints = sortCopy( + [ + ...thread.checkpoints.filter( + (checkpoint) => checkpoint.turnId !== event.payload.turnId, + ), + { + turnId: event.payload.turnId, + checkpointTurnCount: event.payload.checkpointTurnCount, + checkpointRef: event.payload.checkpointRef, + status: event.payload.status, + files: event.payload.files, + assistantMessageId: event.payload.assistantMessageId, + completedAt: event.payload.completedAt, + }, + ], + (left, right) => left.checkpointTurnCount - right.checkpointTurnCount, + ); + + const latestTurnState = + event.payload.status === "error" + ? "error" + : event.payload.status === "missing" + ? "interrupted" + : "completed"; + + return { + ...thread, + checkpoints, + latestTurn: { + turnId: event.payload.turnId, + state: latestTurnState, + requestedAt: + thread.latestTurn?.turnId === event.payload.turnId + ? thread.latestTurn.requestedAt + : event.payload.completedAt, + startedAt: + thread.latestTurn?.turnId === event.payload.turnId + ? (thread.latestTurn.startedAt ?? event.payload.completedAt) + : event.payload.completedAt, + completedAt: event.payload.completedAt, + assistantMessageId: event.payload.assistantMessageId, + }, + updatedAt: event.occurredAt, + }; + }), + }; + + case "thread.activity-appended": + return { + ...nextBase, + threads: updateThread(nextBase, event.payload.threadId, (thread) => ({ + ...thread, + activities: sortCopy( + [ + ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), + event.payload.activity, + ], + (left, right) => { + const leftSequence = left.sequence ?? Number.MIN_SAFE_INTEGER; + const rightSequence = right.sequence ?? Number.MIN_SAFE_INTEGER; + if (leftSequence !== rightSequence) { + return leftSequence - rightSequence; + } + return ( + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id) + ); + }, + ), + updatedAt: event.occurredAt, + })), + }; + + default: + return nextBase; + } +} + +export function requiresSnapshotRefresh(event: OrchestrationEvent): boolean { + switch (event.type) { + case "project.created": + case "project.meta-updated": + case "project.deleted": + case "thread.created": + case "thread.deleted": + case "thread.archived": + case "thread.unarchived": + case "thread.meta-updated": + case "thread.runtime-mode-set": + case "thread.interaction-mode-set": + case "thread.message-sent": + case "thread.turn-start-requested": + case "thread.session-set": + case "thread.proposed-plan-upserted": + case "thread.turn-diff-completed": + case "thread.activity-appended": + return false; + default: + return true; + } +} + +export function applyOptimisticUserMessage( + snapshot: OrchestrationReadModel, + input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + readonly text: string; + readonly attachments?: ReadonlyArray; + readonly createdAt: string; + }, +): OrchestrationReadModel { + return { + ...snapshot, + updatedAt: input.createdAt, + threads: updateThread(snapshot, input.threadId, (thread) => { + if (thread.messages.some((message) => message.id === input.messageId)) { + return thread; + } + return { + ...thread, + updatedAt: input.createdAt, + messages: [ + ...thread.messages, + { + id: input.messageId, + role: "user", + text: input.text, + turnId: null, + streaming: false, + createdAt: input.createdAt, + updatedAt: input.createdAt, + ...(input.attachments && input.attachments.length > 0 + ? { attachments: input.attachments } + : {}), + }, + ], + }; + }), + }; +} diff --git a/apps/mobile/src/lib/remoteClient.ts b/apps/mobile/src/lib/remoteClient.ts new file mode 100644 index 0000000000..9998e5fe53 --- /dev/null +++ b/apps/mobile/src/lib/remoteClient.ts @@ -0,0 +1,676 @@ +import { + type ClientOrchestrationCommand, + ORCHESTRATION_WS_METHODS, + type OrchestrationEvent, + type OrchestrationGetThreadMessagesPageResult, + type OrchestrationReadModel, + type ServerConfig, + type ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; + +import type { RemoteConnectionConfig } from "./connection"; + +const REQUEST_TIMEOUT_MS = 60_000; +const PING_INTERVAL_MS = 5_000; +const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000] as const; +const EMPTY_HEADERS: ReadonlyArray = []; +const textDecoder = new TextDecoder(); + +type RpcRequestId = string; + +type RpcRequestMessage = { + readonly _tag: "Request"; + readonly id: RpcRequestId; + readonly tag: string; + readonly payload: unknown; + readonly headers: ReadonlyArray; +}; + +type RpcAckMessage = { + readonly _tag: "Ack"; + readonly requestId: RpcRequestId; +}; + +type RpcPingMessage = { + readonly _tag: "Ping"; +}; + +type RpcPongMessage = { + readonly _tag: "Pong"; +}; + +type RpcChunkMessage = { + readonly _tag: "Chunk"; + readonly requestId: RpcRequestId; + readonly values: ReadonlyArray; +}; + +type RpcExitMessage = { + readonly _tag: "Exit"; + readonly requestId: RpcRequestId; + readonly exit: + | { + readonly _tag: "Success"; + readonly value: unknown; + } + | { + readonly _tag: "Failure"; + readonly cause: unknown; + }; +}; + +type RpcDefectMessage = { + readonly _tag: "Defect"; + readonly defect: unknown; +}; + +type RpcInboundMessage = + | RpcAckMessage + | RpcChunkMessage + | RpcDefectMessage + | RpcExitMessage + | RpcPingMessage + | RpcPongMessage; + +type PendingUnaryRequest = { + readonly kind: "unary"; + readonly label: string; + readonly reject: (error: Error) => void; + readonly resolve: (value: unknown) => void; + readonly timeout: ReturnType; +}; + +type PendingStreamRequest = { + readonly kind: "stream"; + readonly label: string; + readonly onChunk: (values: ReadonlyArray) => void; + readonly onExit: (error?: Error) => void; +}; + +type PendingRequest = PendingStreamRequest | PendingUnaryRequest; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function toMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + return fallback; +} + +function extractRpcFailureMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + if (Array.isArray(error)) { + for (const entry of error) { + const message = extractRpcFailureMessage(entry, ""); + if (message.length > 0) { + return message; + } + } + return fallback; + } + if (!isRecord(error)) { + return fallback; + } + for (const key of ["message", "defect", "error", "reason"] as const) { + const value = error[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + if ("cause" in error) { + const message = extractRpcFailureMessage(error.cause, ""); + if (message.length > 0) { + return message; + } + } + try { + return JSON.stringify(error); + } catch { + return fallback; + } +} + +function toRpcError(error: unknown, fallback: string): Error { + return new Error(extractRpcFailureMessage(error, fallback)); +} + +async function readWebSocketMessageData(data: unknown): Promise { + if (typeof data === "string") { + return data; + } + if (data instanceof ArrayBuffer) { + return textDecoder.decode(data); + } + if (ArrayBuffer.isView(data)) { + return textDecoder.decode(data); + } + if (typeof Blob !== "undefined" && data instanceof Blob) { + return await data.text(); + } + return String(data); +} + +function parseRpcMessages(raw: string): ReadonlyArray { + const decoded = JSON.parse(raw); + if (Array.isArray(decoded)) { + return decoded as ReadonlyArray; + } + return [decoded as RpcInboundMessage]; +} + +export type RemoteClientConnectionState = + | "idle" + | "connecting" + | "ready" + | "reconnecting" + | "disconnected"; + +export type RemoteClientEvent = + | { + readonly type: "status"; + readonly state: RemoteClientConnectionState; + readonly error?: string; + } + | { readonly type: "snapshot"; readonly snapshot: OrchestrationReadModel } + | { readonly type: "server-config"; readonly config: ServerConfig } + | { readonly type: "domain-event"; readonly event: OrchestrationEvent }; + +export class RemoteClient { + private socket: WebSocket | null = null; + private reconnectTimer: ReturnType | null = null; + private pingTimer: ReturnType | null = null; + private reconnectAttempt = 0; + private awaitingPong = false; + private readonly listeners = new Set<(event: RemoteClientEvent) => void>(); + private readonly pendingRequests = new Map(); + private disposed = false; + private bootstrapped = false; + private bufferedDomainEvents: OrchestrationEvent[] = []; + private requestCounter = 0n; + private domainEventsRequestId: RpcRequestId | null = null; + + constructor(private readonly connection: RemoteConnectionConfig) {} + + addListener(listener: (event: RemoteClientEvent) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + connect(): void { + this.disposed = false; + this.reconnectAttempt = 0; + this.clearReconnectTimer(); + this.rejectPendingRequests(new Error("Reconnecting.")); + this.closeSocket(); + this.openWebSocket(); + } + + disconnect(): void { + this.disposed = true; + this.bootstrapped = false; + this.bufferedDomainEvents = []; + this.domainEventsRequestId = null; + this.clearReconnectTimer(); + this.stopPingLoop(); + this.rejectPendingRequests(new Error("Remote connection closed.")); + this.closeSocket(); + this.emit({ type: "status", state: "idle" }); + } + + async refreshSnapshot(): Promise { + return await this.request( + ORCHESTRATION_WS_METHODS.getSnapshot, + {}, + "Request timed out: getSnapshot", + ); + } + + async getThreadMessagesPage(input: { + readonly threadId: ThreadId; + readonly offset: number; + readonly limit: number; + }): Promise { + return await this.request( + ORCHESTRATION_WS_METHODS.getThreadMessagesPage, + input, + "Request timed out: getThreadMessagesPage", + ); + } + + async refreshServerConfig(): Promise { + return await this.request( + WS_METHODS.serverGetConfig, + {}, + "Request timed out: server.getConfig", + ); + } + + async dispatchCommand(command: ClientOrchestrationCommand): Promise { + await this.request( + ORCHESTRATION_WS_METHODS.dispatchCommand, + command, + "Request timed out: orchestration.dispatchCommand", + ); + } + + private emit(event: RemoteClientEvent): void { + for (const listener of this.listeners) { + try { + listener(event); + } catch { + // Ignore listener failures in the mobile client. + } + } + } + + private openWebSocket(): void { + if (this.disposed) { + return; + } + + this.emit({ + type: "status", + state: this.reconnectAttempt > 0 ? "reconnecting" : "connecting", + }); + + const socket = new WebSocket(this.connection.wsUrl); + this.socket = socket; + + socket.addEventListener("open", () => { + if (this.disposed || this.socket !== socket) { + socket.close(); + return; + } + this.startPingLoop(socket); + void this.startSession(socket); + }); + + socket.addEventListener("message", (event) => { + void this.handleSocketMessage(socket, event.data); + }); + + socket.addEventListener("error", () => { + // Close handles reconnect state and pending request cleanup. + }); + + socket.addEventListener("close", (event) => { + if (this.socket !== socket) { + return; + } + this.socket = null; + + this.stopPingLoop(); + this.bootstrapped = false; + this.bufferedDomainEvents = []; + this.domainEventsRequestId = null; + this.rejectPendingRequests( + new Error( + event.reason.trim().length > 0 + ? event.reason + : `Remote connection closed (${event.code}).`, + ), + ); + + if (this.disposed) { + this.emit({ type: "status", state: "idle" }); + return; + } + + this.emit({ + type: "status", + state: "disconnected", + error: + event.reason.trim().length > 0 + ? event.reason + : event.code === 1000 + ? undefined + : `Remote connection closed (${event.code}).`, + }); + this.scheduleReconnect(); + }); + } + + private async startSession(socket: WebSocket): Promise { + this.bootstrapped = false; + this.bufferedDomainEvents = []; + this.domainEventsRequestId = null; + + this.subscribeToDomainEvents(socket); + + try { + const [config, snapshot] = await Promise.all([ + this.requestWithSocket( + socket, + WS_METHODS.serverGetConfig, + {}, + "server.getConfig", + ), + this.requestWithSocket( + socket, + ORCHESTRATION_WS_METHODS.getSnapshot, + {}, + "orchestration.getSnapshot", + ), + ]); + + if (this.socket !== socket || this.disposed) { + return; + } + + const bufferedEvents = this.drainBufferedDomainEvents(snapshot.snapshotSequence); + this.bootstrapped = true; + this.reconnectAttempt = 0; + this.emit({ type: "server-config", config }); + this.emit({ type: "snapshot", snapshot }); + for (const event of bufferedEvents) { + this.emit({ type: "domain-event", event }); + } + this.emit({ type: "status", state: "ready" }); + } catch (error) { + if (this.socket !== socket || this.disposed) { + return; + } + + this.emit({ + type: "status", + state: "disconnected", + error: toMessage(error, "Failed to bootstrap remote connection."), + }); + socket.close(1011, "Failed to bootstrap remote connection."); + } + } + + private subscribeToDomainEvents(socket: WebSocket): void { + const requestId = this.createRequestId(); + this.domainEventsRequestId = requestId; + this.pendingRequests.set(requestId, { + kind: "stream", + label: WS_METHODS.subscribeOrchestrationDomainEvents, + onChunk: (values) => { + for (const value of values) { + const event = value as OrchestrationEvent; + if (!this.bootstrapped) { + this.bufferedDomainEvents.push(event); + continue; + } + this.emit({ type: "domain-event", event }); + } + }, + onExit: (error) => { + if (this.domainEventsRequestId === requestId) { + this.domainEventsRequestId = null; + } + if (this.socket !== socket || this.disposed) { + return; + } + + this.emit({ + type: "status", + state: "disconnected", + error: toMessage(error, "Remote event stream disconnected."), + }); + socket.close(1011, "Remote event stream disconnected."); + }, + }); + + this.sendMessage(socket, { + _tag: "Request", + id: requestId, + tag: WS_METHODS.subscribeOrchestrationDomainEvents, + payload: {}, + headers: EMPTY_HEADERS, + }); + } + + private async handleSocketMessage(socket: WebSocket, data: unknown): Promise { + if (this.socket !== socket || this.disposed) { + return; + } + + try { + const raw = await readWebSocketMessageData(data); + const messages = parseRpcMessages(raw); + for (const message of messages) { + if (this.socket !== socket || this.disposed) { + return; + } + this.handleInboundMessage(socket, message); + } + } catch (error) { + if (this.socket !== socket || this.disposed) { + return; + } + + this.emit({ + type: "status", + state: "disconnected", + error: toMessage(error, "Failed to decode remote message."), + }); + socket.close(1003, "Failed to decode remote message."); + } + } + + private handleInboundMessage(socket: WebSocket, message: RpcInboundMessage): void { + switch (message._tag) { + case "Pong": { + this.awaitingPong = false; + return; + } + case "Ping": { + this.sendMessage(socket, { _tag: "Pong" }); + return; + } + case "Ack": { + return; + } + case "Defect": { + const error = toRpcError(message.defect, "Remote protocol defect."); + this.rejectPendingRequests(error); + this.emit({ type: "status", state: "disconnected", error: error.message }); + socket.close(1011, error.message); + return; + } + case "Chunk": { + const pending = this.pendingRequests.get(message.requestId); + if (!pending || pending.kind !== "stream") { + return; + } + + pending.onChunk(message.values); + this.sendMessage(socket, { + _tag: "Ack", + requestId: message.requestId, + }); + return; + } + case "Exit": { + const pending = this.pendingRequests.get(message.requestId); + if (!pending) { + return; + } + + this.pendingRequests.delete(message.requestId); + + if (pending.kind === "unary") { + clearTimeout(pending.timeout); + if (message.exit._tag === "Success") { + pending.resolve(message.exit.value); + } else { + pending.reject( + toRpcError(message.exit.cause, `Remote request failed: ${pending.label}`), + ); + } + return; + } + + pending.onExit( + message.exit._tag === "Success" + ? undefined + : toRpcError(message.exit.cause, `Remote stream failed: ${pending.label}`), + ); + return; + } + } + } + + private drainBufferedDomainEvents(snapshotSequence: number): OrchestrationEvent[] { + const bufferedEvents = this.bufferedDomainEvents.filter( + (event) => event.sequence > snapshotSequence, + ); + bufferedEvents.sort((left, right) => left.sequence - right.sequence); + this.bufferedDomainEvents = []; + return bufferedEvents; + } + + private async request(tag: string, payload: unknown, timeoutLabel: string): Promise { + const socket = this.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) { + throw new Error("Remote connection is not open yet."); + } + return await this.requestWithSocket(socket, tag, payload, timeoutLabel); + } + + private async requestWithSocket( + socket: WebSocket, + tag: string, + payload: unknown, + timeoutLabel: string, + ): Promise { + const requestId = this.createRequestId(); + + return await new Promise((resolve, reject) => { + if (this.socket !== socket || socket.readyState !== WebSocket.OPEN) { + reject(new Error("Remote connection is not open yet.")); + return; + } + + const timeout = setTimeout(() => { + if (!this.pendingRequests.has(requestId)) { + return; + } + this.pendingRequests.delete(requestId); + reject(new Error(timeoutLabel)); + }, REQUEST_TIMEOUT_MS); + + this.pendingRequests.set(requestId, { + kind: "unary", + label: tag, + timeout, + resolve: (value) => resolve(value as T), + reject, + }); + + try { + this.sendMessage(socket, { + _tag: "Request", + id: requestId, + tag, + payload, + headers: EMPTY_HEADERS, + }); + } catch (error) { + clearTimeout(timeout); + this.pendingRequests.delete(requestId); + reject(toRpcError(error, `Failed to send remote request: ${tag}`)); + } + }); + } + + private createRequestId(): RpcRequestId { + const requestId = this.requestCounter.toString(); + this.requestCounter += 1n; + return requestId; + } + + private sendMessage( + socket: WebSocket, + message: RpcAckMessage | RpcPingMessage | RpcPongMessage | RpcRequestMessage, + ): void { + socket.send(JSON.stringify(message)); + } + + private rejectPendingRequests(error: Error): void { + for (const [requestId, pending] of this.pendingRequests) { + this.pendingRequests.delete(requestId); + if (pending.kind === "unary") { + clearTimeout(pending.timeout); + pending.reject(error); + continue; + } + pending.onExit(error); + } + } + + private startPingLoop(socket: WebSocket): void { + this.stopPingLoop(); + this.awaitingPong = false; + this.pingTimer = setInterval(() => { + if (this.socket !== socket || socket.readyState !== WebSocket.OPEN) { + this.stopPingLoop(); + return; + } + if (this.awaitingPong) { + socket.close(1011, "Remote ping timed out."); + return; + } + + this.awaitingPong = true; + this.sendMessage(socket, { _tag: "Ping" }); + }, PING_INTERVAL_MS); + } + + private stopPingLoop(): void { + if (this.pingTimer !== null) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + this.awaitingPong = false; + } + + private scheduleReconnect(): void { + if (this.disposed || this.reconnectTimer !== null) { + return; + } + + const delay = + RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? + RECONNECT_DELAYS_MS[0]; + this.reconnectAttempt += 1; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.openWebSocket(); + }, delay); + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private closeSocket(): void { + const socket = this.socket; + this.socket = null; + if ( + socket && + (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) + ) { + socket.close(); + } + } +} diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts new file mode 100644 index 0000000000..750d916a03 --- /dev/null +++ b/apps/mobile/src/lib/storage.ts @@ -0,0 +1,162 @@ +import { Platform } from "react-native"; + +import type { RemoteConnectionInput } from "./connection"; + +const CONNECTION_URL_KEY = "t3remote:server-url"; +const CONNECTION_TOKEN_KEY = "t3remote:server-token"; +const memoryStorage = new Map(); + +type AsyncStorageModule = typeof import("@react-native-async-storage/async-storage"); +type AsyncStorageLike = Pick; +type SecureStoreModule = typeof import("expo-secure-store"); + +let asyncStoragePromise: Promise | null = null; +let secureStorePromise: Promise | null = null; + +async function loadAsyncStorage(): Promise { + if (!asyncStoragePromise) { + asyncStoragePromise = import("@react-native-async-storage/async-storage") + .then((module) => module.default) + .catch(() => null); + } + + return await asyncStoragePromise; +} + +async function loadSecureStore(): Promise { + if (Platform.OS === "web") { + return null; + } + + if (!secureStorePromise) { + secureStorePromise = import("expo-secure-store").catch(() => null); + } + + return await secureStorePromise; +} + +async function readFallbackItem(key: string): Promise { + return memoryStorage.get(key) ?? null; +} + +async function writeFallbackItem(key: string, value: string): Promise { + memoryStorage.set(key, value); +} + +async function removeFallbackItem(key: string): Promise { + memoryStorage.delete(key); +} + +async function readStorageItem(key: string): Promise { + const asyncStorage = await loadAsyncStorage(); + if (!asyncStorage) { + return await readFallbackItem(key); + } + + try { + return await asyncStorage.getItem(key); + } catch { + return await readFallbackItem(key); + } +} + +async function writeStorageItem(key: string, value: string): Promise { + const asyncStorage = await loadAsyncStorage(); + if (!asyncStorage) { + await writeFallbackItem(key, value); + return; + } + + try { + await asyncStorage.setItem(key, value); + } catch { + await writeFallbackItem(key, value); + } +} + +async function removeStorageItem(key: string): Promise { + const asyncStorage = await loadAsyncStorage(); + if (!asyncStorage) { + await removeFallbackItem(key); + return; + } + + try { + await asyncStorage.removeItem(key); + } catch { + await removeFallbackItem(key); + } +} + +async function loadToken(): Promise { + if (Platform.OS === "web") { + return (await readStorageItem(CONNECTION_TOKEN_KEY)) ?? ""; + } + + const secureStore = await loadSecureStore(); + try { + if (secureStore) { + return (await secureStore.getItemAsync(CONNECTION_TOKEN_KEY)) ?? ""; + } + } catch { + // fall through to async storage + } + return (await readStorageItem(CONNECTION_TOKEN_KEY)) ?? ""; +} + +async function storeToken(token: string): Promise { + if (token.trim().length === 0) { + if (Platform.OS === "web") { + await removeStorageItem(CONNECTION_TOKEN_KEY); + return; + } + + const secureStore = await loadSecureStore(); + try { + await secureStore?.deleteItemAsync(CONNECTION_TOKEN_KEY); + } catch { + // Ignore secure store cleanup failures and clear fallback storage. + } + await removeStorageItem(CONNECTION_TOKEN_KEY); + return; + } + + if (Platform.OS === "web") { + await writeStorageItem(CONNECTION_TOKEN_KEY, token); + return; + } + + const secureStore = await loadSecureStore(); + try { + if (secureStore) { + await secureStore.setItemAsync(CONNECTION_TOKEN_KEY, token); + return; + } + } catch { + // Fall through to async storage fallback. + } + + await writeStorageItem(CONNECTION_TOKEN_KEY, token); +} + +export async function loadSavedConnectionInput(): Promise { + const serverUrl = (await readStorageItem(CONNECTION_URL_KEY))?.trim() ?? ""; + if (serverUrl.length === 0) { + return null; + } + + return { + serverUrl, + authToken: await loadToken(), + }; +} + +export async function saveConnectionInput(input: RemoteConnectionInput): Promise { + await writeStorageItem(CONNECTION_URL_KEY, input.serverUrl.trim()); + await storeToken(input.authToken.trim()); +} + +export async function clearSavedConnectionInput(): Promise { + await removeStorageItem(CONNECTION_URL_KEY); + await storeToken(""); +} diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts new file mode 100644 index 0000000000..52095a4bf6 --- /dev/null +++ b/apps/mobile/src/lib/threadActivity.ts @@ -0,0 +1,449 @@ +import type { + ApprovalRequestId, + OrchestrationThread, + OrchestrationThreadActivity, + ThreadId, + UserInputQuestion, +} from "@t3tools/contracts"; + +import type { DraftComposerImageAttachment } from "./composerImages"; +import { sortCopy } from "./arrayCompat"; + +export interface PendingApproval { + readonly requestId: ApprovalRequestId; + readonly requestKind: "command" | "file-read" | "file-change"; + readonly createdAt: string; + readonly detail?: string; +} + +export interface PendingUserInput { + readonly requestId: ApprovalRequestId; + readonly createdAt: string; + readonly questions: ReadonlyArray; +} + +export interface PendingUserInputDraftAnswer { + readonly selectedOptionLabel?: string; + readonly customAnswer?: string; +} + +export interface QueuedThreadMessage { + readonly id: string; + readonly threadId: ThreadId; + readonly messageId: string; + readonly commandId: string; + readonly text: string; + readonly attachments: ReadonlyArray; + readonly createdAt: string; +} + +export interface ThreadFeedActivity { + readonly id: string; + readonly createdAt: string; + readonly summary: string; + readonly detail: string | null; + readonly status: string | null; +} + +type RawThreadFeedEntry = + | { + readonly type: "message"; + readonly id: string; + readonly createdAt: string; + readonly message: OrchestrationThread["messages"][number]; + } + | { + readonly type: "queued-message"; + readonly id: string; + readonly createdAt: string; + readonly queuedMessage: QueuedThreadMessage; + readonly sending: boolean; + } + | { + readonly type: "activity"; + readonly id: string; + readonly createdAt: string; + readonly activity: ThreadFeedActivity; + }; + +export type ThreadFeedEntry = + | Extract + | { + readonly type: "activity-group"; + readonly id: string; + readonly createdAt: string; + readonly activities: ReadonlyArray; + }; + +function compareActivitiesByOrder( + left: OrchestrationThreadActivity, + right: OrchestrationThreadActivity, +): number { + if (left.sequence !== undefined && right.sequence !== undefined) { + if (left.sequence !== right.sequence) { + return left.sequence - right.sequence; + } + } else if (left.sequence !== undefined) { + return 1; + } else if (right.sequence !== undefined) { + return -1; + } + + return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); +} + +function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { + switch (requestType) { + case "command_execution_approval": + case "exec_command_approval": + return "command"; + case "file_read_approval": + return "file-read"; + case "file_change_approval": + case "apply_patch_approval": + return "file-change"; + default: + return null; + } +} + +function isStalePendingRequestFailureDetail(detail: string | undefined): boolean { + const normalized = detail?.toLowerCase(); + if (!normalized) { + return false; + } + return ( + normalized.includes("stale pending approval request") || + normalized.includes("stale pending user-input request") || + normalized.includes("unknown pending approval request") || + normalized.includes("unknown pending permission request") || + normalized.includes("unknown pending user-input request") + ); +} + +function parseUserInputQuestions( + payload: Record | null, +): ReadonlyArray | null { + const questions = payload?.questions; + if (!Array.isArray(questions)) { + return null; + } + + const parsed = questions + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const question = entry as Record; + if ( + typeof question.id !== "string" || + typeof question.header !== "string" || + typeof question.question !== "string" || + !Array.isArray(question.options) + ) { + return null; + } + const options = question.options + .map((option) => { + if (!option || typeof option !== "object") return null; + const record = option as Record; + if (typeof record.label !== "string" || typeof record.description !== "string") { + return null; + } + return { + label: record.label, + description: record.description, + }; + }) + .filter((option): option is UserInputQuestion["options"][number] => option !== null); + if (options.length === 0) { + return null; + } + return { + id: question.id, + header: question.header, + question: question.question, + options, + }; + }) + .filter((question): question is UserInputQuestion => question !== null); + + return parsed.length > 0 ? parsed : null; +} + +function normalizeDraftAnswer(value: string | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function resolvePendingUserInputAnswer( + draft: PendingUserInputDraftAnswer | undefined, +): string | null { + const customAnswer = normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer) { + return customAnswer; + } + return normalizeDraftAnswer(draft?.selectedOptionLabel); +} + +function coercePayloadRecord(payload: unknown): Record | null { + return payload && typeof payload === "object" ? (payload as Record) : null; +} + +function isToolActivity(activity: OrchestrationThreadActivity): boolean { + return ( + activity.kind === "tool.started" || + activity.kind === "tool.updated" || + activity.kind === "tool.completed" + ); +} + +function activityDetail(activity: OrchestrationThreadActivity): string | null { + const payload = coercePayloadRecord(activity.payload); + const detail = + typeof payload?.detail === "string" + ? payload.detail + : typeof payload?.error === "string" + ? payload.error + : typeof payload?.message === "string" + ? payload.message + : null; + if (detail && detail.trim().length > 0) { + return detail; + } + return null; +} + +function activityStatus(activity: OrchestrationThreadActivity): string | null { + const payload = coercePayloadRecord(activity.payload); + const status = + typeof payload?.status === "string" + ? payload.status + : typeof payload?.state === "string" + ? payload.state + : null; + if (status && status.trim().length > 0) { + return status; + } + return null; +} + +function compareFeedEntries(left: RawThreadFeedEntry, right: RawThreadFeedEntry): number { + const byCreatedAt = left.createdAt.localeCompare(right.createdAt); + if (byCreatedAt !== 0) { + return byCreatedAt; + } + return left.id.localeCompare(right.id); +} + +function groupAdjacentActivities(entries: ReadonlyArray): ThreadFeedEntry[] { + const grouped: ThreadFeedEntry[] = []; + + for (const entry of entries) { + if (entry.type !== "activity") { + grouped.push(entry); + continue; + } + + const previous = grouped.at(-1); + if (previous?.type === "activity-group") { + grouped[grouped.length - 1] = { + ...previous, + activities: [...previous.activities, entry.activity], + }; + continue; + } + + grouped.push({ + type: "activity-group", + id: entry.id, + createdAt: entry.createdAt, + activities: [entry.activity], + }); + } + + return grouped; +} + +export function derivePendingApprovals( + activities: ReadonlyArray, +): PendingApproval[] { + const openByRequestId = new Map(); + const ordered = sortCopy(activities, compareActivitiesByOrder); + + for (const activity of ordered) { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const requestId = payload?.requestId; + const requestKind = + payload?.requestKind === "command" || + payload?.requestKind === "file-read" || + payload?.requestKind === "file-change" + ? payload.requestKind + : requestKindFromRequestType(payload?.requestType); + const detail = typeof payload?.detail === "string" ? payload.detail : undefined; + + if (activity.kind === "approval.requested" && typeof requestId === "string" && requestKind) { + openByRequestId.set(requestId as ApprovalRequestId, { + requestId: requestId as ApprovalRequestId, + requestKind, + createdAt: activity.createdAt, + ...(detail ? { detail } : {}), + }); + continue; + } + + if (activity.kind === "approval.resolved" && typeof requestId === "string") { + openByRequestId.delete(requestId as ApprovalRequestId); + continue; + } + + if ( + activity.kind === "provider.approval.respond.failed" && + typeof requestId === "string" && + isStalePendingRequestFailureDetail(detail) + ) { + openByRequestId.delete(requestId as ApprovalRequestId); + } + } + + return sortCopy([...openByRequestId.values()], (left, right) => + left.createdAt.localeCompare(right.createdAt), + ); +} + +export function derivePendingUserInputs( + activities: ReadonlyArray, +): PendingUserInput[] { + const openByRequestId = new Map(); + const ordered = sortCopy(activities, compareActivitiesByOrder); + + for (const activity of ordered) { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const requestId = payload?.requestId; + const detail = typeof payload?.detail === "string" ? payload.detail : undefined; + + if (activity.kind === "user-input.requested" && typeof requestId === "string") { + const questions = parseUserInputQuestions(payload); + if (!questions) { + continue; + } + openByRequestId.set(requestId as ApprovalRequestId, { + requestId: requestId as ApprovalRequestId, + createdAt: activity.createdAt, + questions, + }); + continue; + } + + if (activity.kind === "user-input.resolved" && typeof requestId === "string") { + openByRequestId.delete(requestId as ApprovalRequestId); + continue; + } + + if ( + activity.kind === "provider.user-input.respond.failed" && + typeof requestId === "string" && + isStalePendingRequestFailureDetail(detail) + ) { + openByRequestId.delete(requestId as ApprovalRequestId); + } + } + + return sortCopy([...openByRequestId.values()], (left, right) => + left.createdAt.localeCompare(right.createdAt), + ); +} + +export function setPendingUserInputCustomAnswer( + draft: PendingUserInputDraftAnswer | undefined, + customAnswer: string, +): PendingUserInputDraftAnswer { + const selectedOptionLabel = + customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; + return { + customAnswer, + ...(selectedOptionLabel ? { selectedOptionLabel } : {}), + }; +} + +export function buildPendingUserInputAnswers( + questions: ReadonlyArray, + draftAnswers: Record, +): Record | null { + const answers: Record = {}; + + for (const question of questions) { + const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); + if (!answer) { + return null; + } + answers[question.id] = answer; + } + + return answers; +} + +export function buildThreadFeed( + thread: OrchestrationThread, + queuedMessages: ReadonlyArray, + dispatchingQueuedMessageId: string | null, + options?: { + readonly loadedMessages?: ReadonlyArray; + }, +): ThreadFeedEntry[] { + const loadedMessages = options?.loadedMessages ?? thread.messages; + const oldestLoadedMessageCreatedAt = + options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; + const entries = sortCopy( + [ + ...loadedMessages.map((message) => ({ + type: "message", + id: message.id, + createdAt: message.createdAt, + message, + })), + ...queuedMessages.map((queuedMessage) => ({ + type: "queued-message", + id: queuedMessage.id, + createdAt: queuedMessage.createdAt, + queuedMessage, + sending: queuedMessage.id === dispatchingQueuedMessageId, + })), + ...thread.activities + .filter((activity) => { + if (!isToolActivity(activity)) { + return false; + } + if (options?.loadedMessages === undefined) { + return true; + } + return ( + oldestLoadedMessageCreatedAt === null || + activity.createdAt >= oldestLoadedMessageCreatedAt + ); + }) + .map((activity) => ({ + type: "activity", + id: activity.id, + createdAt: activity.createdAt, + activity: { + id: activity.id, + createdAt: activity.createdAt, + summary: activity.summary, + detail: activityDetail(activity), + status: activityStatus(activity), + }, + })), + ], + compareFeedEntries, + ); + + return groupAdjacentActivities(entries); +} diff --git a/apps/mobile/src/lib/time.ts b/apps/mobile/src/lib/time.ts new file mode 100644 index 0000000000..9cbdada68f --- /dev/null +++ b/apps/mobile/src/lib/time.ts @@ -0,0 +1,19 @@ +export function relativeTime(input: string): string { + const timestamp = Date.parse(input); + if (Number.isNaN(timestamp)) { + return "now"; + } + + const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + if (deltaSeconds < 10) return "now"; + if (deltaSeconds < 60) return `${deltaSeconds}s`; + + const deltaMinutes = Math.floor(deltaSeconds / 60); + if (deltaMinutes < 60) return `${deltaMinutes}m`; + + const deltaHours = Math.floor(deltaMinutes / 60); + if (deltaHours < 24) return `${deltaHours}h`; + + const deltaDays = Math.floor(deltaHours / 24); + return `${deltaDays}d`; +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 0000000000..b9567f6052 --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true + } +} diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index cee08b4f98..a0e91df165 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -13,6 +13,7 @@ import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolve const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; const FALLBACK_PROJECT_FAVICON_SVG = ``; +const REMOTE_HEALTH_ROUTE = "/api/remote/health"; export const attachmentsRouteLayer = HttpRouter.add( "GET", @@ -109,6 +110,40 @@ export const projectFaviconRouteLayer = HttpRouter.add( }), ); +export const remoteHealthRouteLayer = HttpRouter.add( + "GET", + REMOTE_HEALTH_ROUTE, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + + const config = yield* ServerConfig; + if (config.authToken && url.value.searchParams.get("token") !== config.authToken) { + return HttpServerResponse.text('{"ok":false}', { + status: 401, + contentType: "application/json; charset=utf-8", + }); + } + + return HttpServerResponse.text( + JSON.stringify({ + ok: true, + authRequired: Boolean(config.authToken), + }), + { + status: 200, + contentType: "application/json; charset=utf-8", + headers: { + "Cache-Control": "no-store", + }, + }, + ); + }), +); + export const staticAndDevRouteLayer = HttpRouter.add( "GET", "*", diff --git a/apps/server/src/orchestration/Layers/ThreadMessageHistoryQuery.ts b/apps/server/src/orchestration/Layers/ThreadMessageHistoryQuery.ts new file mode 100644 index 0000000000..dbbdf503ec --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadMessageHistoryQuery.ts @@ -0,0 +1,35 @@ +import { Effect, Layer } from "effect"; + +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { + ThreadMessageHistoryQuery, + type ThreadMessageHistoryQueryShape, +} from "../Services/ThreadMessageHistoryQuery.ts"; + +const make = Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + + const getThreadMessagesPage: ThreadMessageHistoryQueryShape["getThreadMessagesPage"] = (input) => + repository.listPageNewestFirst(input).pipe( + Effect.map(({ messages, total }) => ({ + messages: messages.map((message) => ({ + id: message.messageId, + role: message.role, + text: message.text, + ...(message.attachments ? { attachments: message.attachments } : {}), + turnId: message.turnId, + streaming: message.isStreaming, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + })), + total, + hasMore: input.offset + messages.length < total, + })), + ); + + return { + getThreadMessagesPage, + } satisfies ThreadMessageHistoryQueryShape; +}); + +export const ThreadMessageHistoryQueryLive = Layer.effect(ThreadMessageHistoryQuery, make); diff --git a/apps/server/src/orchestration/Services/ThreadMessageHistoryQuery.ts b/apps/server/src/orchestration/Services/ThreadMessageHistoryQuery.ts new file mode 100644 index 0000000000..7b03e46bcc --- /dev/null +++ b/apps/server/src/orchestration/Services/ThreadMessageHistoryQuery.ts @@ -0,0 +1,19 @@ +import type { + OrchestrationGetThreadMessagesPageInput, + OrchestrationGetThreadMessagesPageResult, +} from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; + +export interface ThreadMessageHistoryQueryShape { + readonly getThreadMessagesPage: ( + input: OrchestrationGetThreadMessagesPageInput, + ) => Effect.Effect; +} + +export class ThreadMessageHistoryQuery extends ServiceMap.Service< + ThreadMessageHistoryQuery, + ThreadMessageHistoryQueryShape +>()("t3/orchestration/Services/ThreadMessageHistoryQuery") {} diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 13b7086cec..0ce770c070 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -9,7 +9,9 @@ import { ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, DeleteProjectionThreadMessagesInput, + ListProjectionThreadMessagesPageInput, ListProjectionThreadMessagesInput, + ProjectionThreadMessagePage, ProjectionThreadMessage, } from "../Services/ProjectionThreadMessages.ts"; @@ -133,6 +135,42 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { `, }); + const countProjectionThreadMessageRows = SqlSchema.findOne({ + Request: Schema.Struct({ threadId: ListProjectionThreadMessagesPageInput.fields.threadId }), + Result: Schema.Struct({ + total: Schema.Number, + }), + execute: ({ threadId }) => + sql` + SELECT COUNT(*) AS "total" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + `, + }); + + const listProjectionThreadMessagePageRows = SqlSchema.findAll({ + Request: ListProjectionThreadMessagesPageInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId, offset, limit }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + ORDER BY created_at DESC, message_id DESC + LIMIT ${limit} + OFFSET ${offset} + `, + }); + const deleteProjectionThreadMessageRows = SqlSchema.void({ Request: DeleteProjectionThreadMessagesInput, execute: ({ threadId }) => @@ -163,6 +201,40 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.map((rows) => rows.map(toProjectionThreadMessage)), ); + const listPageNewestFirst: ProjectionThreadMessageRepositoryShape["listPageNewestFirst"] = ( + input, + ) => + Effect.all({ + totalRow: countProjectionThreadMessageRows({ threadId: input.threadId }).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.listPageNewestFirst:count"), + ), + ), + rows: listProjectionThreadMessagePageRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.listPageNewestFirst:query"), + ), + ), + }).pipe( + Effect.map( + ({ totalRow, rows }) => + ({ + total: totalRow.total, + messages: rows.map((row) => ({ + messageId: row.messageId, + threadId: row.threadId, + turnId: row.turnId, + role: row.role, + text: row.text, + isStreaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + })), + }) satisfies ProjectionThreadMessagePage, + ), + ); + const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadMessageRows(input).pipe( Effect.mapError( @@ -174,6 +246,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { upsert, getByMessageId, listByThreadId, + listPageNewestFirst, deleteByThreadId, } satisfies ProjectionThreadMessageRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index b1a769cd91..1f1a8d609c 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -9,7 +9,9 @@ import { ChatAttachment, MessageId, + NonNegativeInt, OrchestrationMessageRole, + PositiveInt, ThreadId, TurnId, IsoDateTime, @@ -43,6 +45,20 @@ export const GetProjectionThreadMessageInput = Schema.Struct({ }); export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type; +export const ListProjectionThreadMessagesPageInput = Schema.Struct({ + threadId: ThreadId, + offset: NonNegativeInt, + limit: PositiveInt, +}); +export type ListProjectionThreadMessagesPageInput = + typeof ListProjectionThreadMessagesPageInput.Type; + +export const ProjectionThreadMessagePage = Schema.Struct({ + messages: Schema.Array(ProjectionThreadMessage), + total: NonNegativeInt, +}); +export type ProjectionThreadMessagePage = typeof ProjectionThreadMessagePage.Type; + export const DeleteProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -77,6 +93,13 @@ export interface ProjectionThreadMessageRepositoryShape { input: ListProjectionThreadMessagesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * List projected thread messages newest-first using offset pagination. + */ + readonly listPageNewestFirst: ( + input: ListProjectionThreadMessagesPageInput, + ) => Effect.Effect; + /** * Delete projected thread messages by thread. */ diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 5a09d8b6ba..e59c391016 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -212,7 +212,7 @@ async function readFirstPromptText( return next.value.message.content; } const content = next.value.message.content[0]; - if (!content || content.type !== "text") { + if (!content || typeof content === "string" || content.type !== "text") { return undefined; } return content.text; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d99e2ad203..442d955af1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -497,7 +497,9 @@ function titleForTool(itemType: CanonicalItemType): string { } } -const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([ +type ClaudeImageMimeType = "image/gif" | "image/jpeg" | "image/png" | "image/webp"; + +const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([ "image/gif", "image/jpeg", "image/png", @@ -508,6 +510,14 @@ const CLAUDE_SETTING_SOURCES = [ "project", "local", ] as const satisfies ReadonlyArray; +type SDKUserMessageContent = Exclude; +type SDKUserContentBlock = SDKUserMessageContent[number]; +type SDKTextContentBlock = Extract; +type SDKImageContentBlock = Extract; + +function isClaudeImageMimeType(value: string): value is ClaudeImageMimeType { + return SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(value as ClaudeImageMimeType); +} function buildPromptText(input: ProviderSendTurnInput): string { const rawEffort = @@ -524,24 +534,23 @@ function buildPromptText(input: ProviderSendTurnInput): string { return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); } -function buildUserMessage(input: { - readonly sdkContent: Array>; -}): SDKUserMessage { +function buildUserMessage(input: { readonly sdkContent: SDKUserMessageContent }): SDKUserMessage { + const message: SDKUserMessage["message"] = { + role: "user", + content: input.sdkContent, + }; return { type: "user", session_id: "", parent_tool_use_id: null, - message: { - role: "user", - content: input.sdkContent as unknown as SDKUserMessage["message"]["content"], - }, - } as SDKUserMessage; + message, + }; } function buildClaudeImageContentBlock(input: { - readonly mimeType: string; + readonly mimeType: ClaudeImageMimeType; readonly bytes: Uint8Array; -}): Record { +}): SDKImageContentBlock { return { type: "image", source: { @@ -560,10 +569,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( }, ) { const text = buildPromptText(input); - const sdkContent: Array> = []; + const sdkContent: SDKUserMessageContent = []; if (text.length > 0) { - sdkContent.push({ type: "text", text }); + sdkContent.push({ type: "text", text } satisfies SDKTextContentBlock); } for (const attachment of input.attachments ?? []) { @@ -571,7 +580,7 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( continue; } - if (!SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(attachment.mimeType)) { + if (!isClaudeImageMimeType(attachment.mimeType)) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "turn/start", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3d8e36e0a7..7cbe1da39c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -47,6 +47,10 @@ import { ProjectionSnapshotQuery, type ProjectionSnapshotQueryShape, } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + ThreadMessageHistoryQuery, + type ThreadMessageHistoryQueryShape, +} from "./orchestration/Services/ThreadMessageHistoryQuery.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import { ProviderRegistry, @@ -137,6 +141,7 @@ const buildAppUnderTest = (options?: { terminalManager?: Partial; orchestrationEngine?: Partial; projectionSnapshotQuery?: Partial; + threadMessageHistoryQuery?: Partial; checkpointDiffQuery?: Partial; serverLifecycleEvents?: Partial; serverRuntimeStartup?: Partial; @@ -244,6 +249,17 @@ const buildAppUnderTest = (options?: { ...options?.layers?.projectionSnapshotQuery, }), ), + Layer.provide( + Layer.mock(ThreadMessageHistoryQuery)({ + getThreadMessagesPage: () => + Effect.succeed({ + messages: [], + total: 0, + hasMore: false, + }), + ...options?.layers?.threadMessageHistoryQuery, + }), + ), Layer.provide( Layer.mock(CheckpointDiffQuery)({ getTurnDiff: () => @@ -392,6 +408,27 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("serves a remote health check when authorized", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + authToken: "secret-token", + }, + }); + + const unauthorized = yield* HttpClient.get("/api/remote/health"); + assert.equal(unauthorized.status, 401); + assert.equal(yield* unauthorized.text, '{"ok":false}'); + + const authorized = yield* HttpClient.get("/api/remote/health?token=secret-token"); + assert.equal(authorized.status, 200); + assert.deepEqual(JSON.parse(yield* authorized.text), { + ok: true, + authRequired: true, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("serves attachment files from state dir", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 04ffaeeeeb..5a97d3f872 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -2,7 +2,12 @@ import { Effect, Layer } from "effect"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import { ServerConfig } from "./config"; -import { attachmentsRouteLayer, projectFaviconRouteLayer, staticAndDevRouteLayer } from "./http"; +import { + attachmentsRouteLayer, + projectFaviconRouteLayer, + remoteHealthRouteLayer, + staticAndDevRouteLayer, +} from "./http"; import { fixPath } from "./os-jank"; import { websocketRpcRouteLayer } from "./ws"; import { OpenLive } from "./open"; @@ -22,6 +27,8 @@ import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationE import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; +import { ThreadMessageHistoryQueryLive } from "./orchestration/Layers/ThreadMessageHistoryQuery"; +import { ProjectionThreadMessageRepositoryLive } from "./persistence/Layers/ProjectionThreadMessages"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; @@ -109,8 +116,13 @@ const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipeline Layer.provide(OrchestrationEventStoreLive), ); +const ThreadMessageHistoryLayerLive = ThreadMessageHistoryQueryLive.pipe( + Layer.provide(ProjectionThreadMessageRepositoryLive), +); + const OrchestrationInfrastructureLayerLive = Layer.mergeAll( OrchestrationProjectionSnapshotQueryLive, + ThreadMessageHistoryLayerLive, OrchestrationEventInfrastructureLayerLive, OrchestrationProjectionPipelineLayerLive, ); @@ -206,6 +218,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( export const makeRoutesLayer = Layer.mergeAll( attachmentsRouteLayer, projectFaviconRouteLayer, + remoteHealthRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 33a0518611..281e0cb0fe 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -9,6 +9,7 @@ import { type OrchestrationEvent, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, + OrchestrationGetThreadMessagesPageError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, ProjectSearchEntriesError, @@ -37,6 +38,7 @@ import { observeRpcStream, observeRpcStreamEffect, } from "./observability/RpcInstrumentation"; +import { ThreadMessageHistoryQuery } from "./orchestration/Services/ThreadMessageHistoryQuery"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; import { ServerLifecycleEvents } from "./serverLifecycleEvents"; import { ServerRuntimeStartup } from "./serverRuntimeStartup"; @@ -52,6 +54,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; const checkpointDiffQuery = yield* CheckpointDiffQuery; + const threadMessageHistoryQuery = yield* ThreadMessageHistoryQuery; const keybindings = yield* Keybindings; const open = yield* Open; const gitManager = yield* GitManager; @@ -392,6 +395,16 @@ const WsRpcLayer = WsRpcGroup.toLayer( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.getThreadMessagesPage]: (input) => + threadMessageHistoryQuery.getThreadMessagesPage(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetThreadMessagesPageError({ + message: "Failed to load thread messages page", + cause, + }), + ), + ), [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.getTurnDiff, diff --git a/apps/web/package.json b/apps/web/package.json index 499943c3f0..00d6585624 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "effect": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", @@ -48,6 +49,7 @@ "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", "@types/babel__core": "^7.20.5", + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^6.0.0", diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a2b27bb1e9..06488463a3 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -28,11 +28,11 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - + diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9d51fa5061..5a4fde3e7b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -576,6 +576,8 @@ function PersistentThreadTerminalDrawer({ export default function ChatView({ threadId }: ChatViewProps) { const serverThread = useThreadById(threadId); const setStoreThreadError = useStore((store) => store.setError); + const setStoreThreadBranch = useStore((store) => store.setThreadBranch); + const syncServerReadModel = useStore((store) => store.syncServerReadModel); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( (store) => store.threadLastVisitedAtById[threadId], @@ -642,6 +644,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); + const promoteDraftThread = useComposerDraftStore((store) => store.promoteDraftThread); const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, ); @@ -725,6 +728,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + const localDraftPromotionPromiseByThreadIdRef = useRef(new Map>()); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { @@ -1615,6 +1619,65 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [setStoreThreadError], ); + useEffect(() => { + if (!isLocalDraftThread || !activeProject || !activeThread || localDraftError !== null) { + return; + } + const api = readNativeApi(); + if (!api) { + return; + } + + const draftThreadId = activeThread.id; + if (localDraftPromotionPromiseByThreadIdRef.current.has(draftThreadId)) { + return; + } + + const promotionPromise = api.orchestration + .dispatchCommand({ + type: "thread.create", + commandId: newCommandId(), + threadId: draftThreadId, + projectId: activeProject.id, + title: activeThread.title, + modelSelection: selectedModelSelection, + runtimeMode, + interactionMode, + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + createdAt: activeThread.createdAt, + }) + .then(() => api.orchestration.getSnapshot()) + .then((snapshot) => { + syncServerReadModel(snapshot); + if (snapshot.threads.some((thread) => thread.id === draftThreadId)) { + promoteDraftThread(draftThreadId); + } + setThreadError(draftThreadId, null); + }) + .catch((error) => { + setThreadError( + draftThreadId, + error instanceof Error ? error.message : "Failed to sync the new thread.", + ); + }) + .finally(() => { + localDraftPromotionPromiseByThreadIdRef.current.delete(draftThreadId); + }); + + localDraftPromotionPromiseByThreadIdRef.current.set(draftThreadId, promotionPromise); + }, [ + activeProject, + activeThread, + interactionMode, + isLocalDraftThread, + localDraftError, + promoteDraftThread, + runtimeMode, + selectedModelSelection, + setThreadError, + syncServerReadModel, + ]); const focusComposer = useCallback(() => { composerEditorRef.current?.focusAtEnd(); @@ -2907,6 +2970,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (!activeProject) return; const threadIdForSend = activeThread.id; + if (localDraftPromotionPromiseByThreadIdRef.current.has(threadIdForSend)) { + setThreadError(threadIdForSend, "New thread is still syncing. Try again in a moment."); + return; + } const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath @@ -3892,23 +3959,31 @@ export default function ChatView({ threadId }: ChatViewProps) { // Empty state: no active thread if (!activeThread) { return ( -
+
{!isElectron && ( -
+
- + Threads
)} {isElectron && ( -
+
No active thread
)}
-
-

Select a thread or create a new one to get started.

+
+

+ Control Room Empty +

+

+ Select a thread or create a new one to start shipping. +

+

+ Projects, plans, diffs, and terminals all light up here once a session is active. +

@@ -3920,7 +3995,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Top bar */}
@@ -3968,7 +4043,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Messages */}
scrollMessagesToBottom("smooth")} - className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer" + className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card/90 px-3 py-1 text-xs text-muted-foreground shadow-lg shadow-black/10 backdrop-blur-md transition-colors hover:border-border hover:text-foreground hover:cursor-pointer" > Scroll to bottom @@ -4031,7 +4106,7 @@ export default function ChatView({ threadId }: ChatViewProps) { >
copyToClipboard(value, undefined)} + aria-label={`Copy ${label}`} + > + {isCopied ? "Copied" : "Copy"} + + ); +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4f4468279..f593c74c48 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -497,23 +497,23 @@ describe("resolveThreadStatusPill", () => { describe("resolveThreadRowClassName", () => { it("uses the darker selected palette when a thread is both selected and active", () => { const className = resolveThreadRowClassName({ isActive: true, isSelected: true }); - expect(className).toContain("bg-primary/22"); - expect(className).toContain("hover:bg-primary/26"); - expect(className).toContain("dark:bg-primary/30"); - expect(className).not.toContain("bg-accent/85"); + expect(className).toContain("bg-primary/18"); + expect(className).toContain("hover:bg-primary/24"); + expect(className).toContain("dark:bg-primary/24"); + expect(className).not.toContain("bg-accent/70"); }); it("uses selected hover colors for selected threads", () => { const className = resolveThreadRowClassName({ isActive: false, isSelected: true }); - expect(className).toContain("bg-primary/15"); - expect(className).toContain("hover:bg-primary/19"); - expect(className).toContain("dark:bg-primary/22"); + expect(className).toContain("bg-primary/12"); + expect(className).toContain("hover:bg-primary/18"); + expect(className).toContain("dark:bg-primary/18"); expect(className).not.toContain("hover:bg-accent"); }); it("keeps the accent palette for active-only threads", () => { const className = resolveThreadRowClassName({ isActive: true, isSelected: false }); - expect(className).toContain("bg-accent/85"); + expect(className).toContain("bg-accent/70"); expect(className).toContain("hover:bg-accent"); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ed151c6da8..7ff0d07ec6 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -282,30 +282,33 @@ export function resolveThreadRowClassName(input: { isSelected: boolean; }): string { const baseClassName = - "h-7 w-full translate-x-0 cursor-pointer justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"; + "h-8 w-full translate-x-0 cursor-pointer justify-start rounded-xl border border-transparent px-2.5 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"; if (input.isSelected && input.isActive) { return cn( baseClassName, - "bg-primary/22 text-foreground font-medium hover:bg-primary/26 hover:text-foreground dark:bg-primary/30 dark:hover:bg-primary/36", + "border-primary/30 bg-primary/18 text-foreground font-medium shadow-[0_10px_24px_color-mix(in_srgb,var(--primary)_12%,transparent)] hover:bg-primary/24 hover:text-foreground dark:bg-primary/24 dark:hover:bg-primary/30", ); } if (input.isSelected) { return cn( baseClassName, - "bg-primary/15 text-foreground hover:bg-primary/19 hover:text-foreground dark:bg-primary/22 dark:hover:bg-primary/28", + "border-primary/18 bg-primary/12 text-foreground hover:bg-primary/18 hover:text-foreground dark:bg-primary/18 dark:hover:bg-primary/24", ); } if (input.isActive) { return cn( baseClassName, - "bg-accent/85 text-foreground font-medium hover:bg-accent hover:text-foreground dark:bg-accent/55 dark:hover:bg-accent/70", + "border-border/60 bg-accent/70 text-foreground font-medium hover:bg-accent hover:text-foreground dark:bg-accent/50 dark:hover:bg-accent/66", ); } - return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); + return cn( + baseClassName, + "text-muted-foreground hover:border-border/50 hover:bg-accent/66 hover:text-foreground", + ); } export function resolveThreadStatusPill(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89ced46454..8e17c6185b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1976,10 +1976,10 @@ export default function Sidebar() { to="/" > - + Code - + {APP_STAGE_LABEL} @@ -1995,11 +1995,11 @@ export default function Sidebar() { return ( <> {isElectron ? ( - + {wordmark} ) : ( - + {wordmark} )} @@ -2032,9 +2032,9 @@ export default function Sidebar() { ) : null} - -
- + +
+ Projects
@@ -2057,7 +2057,7 @@ export default function Sidebar() { shouldShowProjectPathEntry ? "Cancel add project" : "Add project" } aria-pressed={shouldShowProjectPathEntry} - className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" + className="inline-flex size-6 cursor-pointer items-center justify-center rounded-full border border-border/50 bg-background/55 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" onClick={handleStartAddProject} /> } @@ -2079,7 +2079,7 @@ export default function Sidebar() { {isElectron && (