diff --git a/apps/mobile/e2e/flows/add-host.yaml b/apps/mobile/e2e/flows/add-host.yaml index 8e0ed0d8f..312625c8d 100644 --- a/apps/mobile/e2e/flows/add-host.yaml +++ b/apps/mobile/e2e/flows/add-host.yaml @@ -19,9 +19,9 @@ appId: com.arcboxlabs.linkcode.mobile - openLink: linkcode://connect - waitForAnimationToEnd - assertVisible: 'Manage hosts' - - assertVisible: 'Add a host by URL' + - assertVisible: 'Other…' -- tapOn: 'Add a host by URL' +- tapOn: 'Other…' - waitForAnimationToEnd - assertVisible: 'Name' - assertVisible: 'Host URL' diff --git a/apps/mobile/modules/linkcode-daemon-discovery/LICENSE b/apps/mobile/modules/linkcode-daemon-discovery/LICENSE new file mode 100644 index 000000000..30b20e3b5 --- /dev/null +++ b/apps/mobile/modules/linkcode-daemon-discovery/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/modules/linkcode-daemon-discovery/android/build.gradle b/apps/mobile/modules/linkcode-daemon-discovery/android/build.gradle new file mode 100644 index 000000000..9875cebf0 --- /dev/null +++ b/apps/mobile/modules/linkcode-daemon-discovery/android/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'expo.modules.linkcodedaemondiscovery' +version = '0.1.0' + +android { + namespace "expo.modules.linkcodedaemondiscovery" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } +} diff --git a/apps/mobile/modules/linkcode-daemon-discovery/android/src/main/AndroidManifest.xml b/apps/mobile/modules/linkcode-daemon-discovery/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..bdae66c8f --- /dev/null +++ b/apps/mobile/modules/linkcode-daemon-discovery/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/mobile/modules/linkcode-daemon-discovery/android/src/main/java/expo/modules/linkcodedaemondiscovery/LinkCodeDaemonDiscoveryModule.kt b/apps/mobile/modules/linkcode-daemon-discovery/android/src/main/java/expo/modules/linkcodedaemondiscovery/LinkCodeDaemonDiscoveryModule.kt new file mode 100644 index 000000000..b681f051e --- /dev/null +++ b/apps/mobile/modules/linkcode-daemon-discovery/android/src/main/java/expo/modules/linkcodedaemondiscovery/LinkCodeDaemonDiscoveryModule.kt @@ -0,0 +1,326 @@ +package expo.modules.linkcodedaemondiscovery + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.net.wifi.WifiManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import java.util.ArrayDeque + +private const val EVENT_NAME = "onHostsChanged" +private const val SERVICE_TYPE = "_linkcode._tcp." +private const val MULTICAST_LOCK_TAG = "LinkCodeDaemonDiscovery" + +private data class DiscoveredDaemon( + val id: String, + val name: String, + val host: String, + val port: Int +) + +class LinkCodeDaemonDiscoveryModule : Module() { + private val handler = Handler(Looper.getMainLooper()) + private val hosts = mutableMapOf() + private val activeServiceNames = mutableSetOf() + private val pendingServices = ArrayDeque() + + private var manager: NsdManager? = null + private var discoveryListener: NsdManager.DiscoveryListener? = null + private var multicastLock: WifiManager.MulticastLock? = null + private var generation = 0 + private var isObserved = false + private var isInForeground = true + private var isResolving = false + private var status = "searching" + private var errorCode: String? = null + + override fun definition() = ModuleDefinition { + Name("LinkCodeDaemonDiscovery") + + Events(EVENT_NAME) + + OnStartObserving(EVENT_NAME) { + handler.post { + isObserved = true + startDiscovery() + } + } + + OnStopObserving(EVENT_NAME) { + handler.post { + isObserved = false + stopDiscovery() + } + } + + OnActivityEntersBackground { + handler.post { + isInForeground = false + stopDiscovery() + } + } + + OnActivityEntersForeground { + handler.post { + isInForeground = true + if (isObserved) { + startDiscovery() + } + } + } + + OnDestroy { + handler.post { + isObserved = false + isInForeground = false + stopDiscovery() + } + } + } + + private fun startDiscovery() { + if (discoveryListener != null) { + emitSnapshot() + return + } + + val context = appContext.reactContext?.applicationContext + val nsdManager = context?.getSystemService(Context.NSD_SERVICE) as? NsdManager + if (context == null || nsdManager == null) { + status = "error" + errorCode = "unavailable" + emitSnapshot() + return + } + + generation += 1 + val currentGeneration = generation + manager = nsdManager + hosts.clear() + activeServiceNames.clear() + pendingServices.clear() + isResolving = false + status = "searching" + errorCode = null + emitSnapshot() + acquireMulticastLock(context) + + val listener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String) { + handler.post { + if (currentGeneration != generation) return@post + status = "ready" + errorCode = null + emitSnapshot() + } + } + + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + handler.post { + if (currentGeneration != generation) return@post + val serviceName = serviceInfo.serviceName ?: return@post + if (activeServiceNames.add(serviceName)) { + pendingServices.addLast(serviceInfo) + resolveNext(currentGeneration) + } + } + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + handler.post { + if (currentGeneration != generation) return@post + val serviceName = serviceInfo.serviceName ?: return@post + activeServiceNames.remove(serviceName) + if (hosts.remove(serviceName) != null) { + emitSnapshot() + } + } + } + + override fun onStartDiscoveryFailed(serviceType: String, error: Int) { + handler.post { + if (currentGeneration != generation) return@post + discoveryListener = null + status = "error" + errorCode = "failed" + releaseMulticastLock() + emitSnapshot() + } + } + + override fun onStopDiscoveryFailed(serviceType: String, error: Int) { + handler.post { retryStop(this) } + } + + override fun onDiscoveryStopped(serviceType: String) { + handler.post { completeStop(this) } + } + } + + discoveryListener = listener + try { + nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) + } catch (_: SecurityException) { + discoveryListener = null + status = "error" + errorCode = "permissionDenied" + releaseMulticastLock() + emitSnapshot() + } catch (_: RuntimeException) { + discoveryListener = null + status = "error" + errorCode = "failed" + releaseMulticastLock() + emitSnapshot() + } + } + + private fun stopDiscovery() { + generation += 1 + val listener = discoveryListener + isResolving = false + pendingServices.clear() + activeServiceNames.clear() + hosts.clear() + if (listener == null) { + releaseMulticastLock() + } else { + requestStop(listener) + } + } + + private fun requestStop(listener: NsdManager.DiscoveryListener) { + if (discoveryListener !== listener) return + + try { + manager?.stopServiceDiscovery(listener) + } catch (_: IllegalArgumentException) { + completeStop(listener) + } catch (_: SecurityException) { + completeStop(listener) + } catch (_: RuntimeException) { + retryStop(listener) + } + } + + private fun retryStop(listener: NsdManager.DiscoveryListener) { + if (discoveryListener !== listener) return + handler.postDelayed({ requestStop(listener) }, 500) + } + + private fun completeStop(listener: NsdManager.DiscoveryListener) { + if (discoveryListener !== listener) return + discoveryListener = null + releaseMulticastLock() + if (isObserved && isInForeground) { + startDiscovery() + } + } + + // The executor-based resolver starts at API 34; this callback is required by the API 24 floor. + @Suppress("DEPRECATION") + private fun resolveNext(currentGeneration: Int) { + if (isResolving || currentGeneration != generation) return + + val serviceInfo = pendingServices.pollFirst() ?: return + val serviceName = serviceInfo.serviceName ?: run { + resolveNext(currentGeneration) + return + } + if (!activeServiceNames.contains(serviceName)) { + resolveNext(currentGeneration) + return + } + + val nsdManager = manager ?: return + isResolving = true + try { + nsdManager.resolveService( + serviceInfo, + object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, error: Int) { + handler.post { + if (currentGeneration != generation) return@post + isResolving = false + resolveNext(currentGeneration) + } + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + handler.post { + if (currentGeneration != generation) return@post + isResolving = false + val resolvedName = serviceInfo.serviceName ?: serviceName + val host = serviceInfo.host?.hostAddress + val port = serviceInfo.port + if ( + activeServiceNames.contains(resolvedName) && + !host.isNullOrBlank() && + port in 1..65_535 + ) { + hosts[resolvedName] = DiscoveredDaemon( + id = resolvedName, + name = resolvedName, + host = host, + port = port + ) + emitSnapshot() + } + resolveNext(currentGeneration) + } + } + } + ) + } catch (_: SecurityException) { + isResolving = false + status = "error" + errorCode = "permissionDenied" + emitSnapshot() + } catch (_: RuntimeException) { + isResolving = false + resolveNext(currentGeneration) + } + } + + private fun acquireMulticastLock(context: Context) { + val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return + multicastLock = wifiManager.createMulticastLock(MULTICAST_LOCK_TAG).apply { + setReferenceCounted(false) + acquire() + } + } + + private fun releaseMulticastLock() { + multicastLock?.let { lock -> + if (lock.isHeld) { + lock.release() + } + } + multicastLock = null + } + + private fun emitSnapshot() { + val serializedHosts = ArrayList( + hosts.values + .sortedBy { it.name.lowercase() } + .map { host -> + Bundle().apply { + putString("id", host.id) + putString("name", host.name) + putString("host", host.host) + putInt("port", host.port) + } + } + ) + val payload = Bundle().apply { + putString("status", status) + putParcelableArrayList("hosts", serializedHosts) + errorCode?.let { putString("error", it) } + } + sendEvent(EVENT_NAME, payload) + } +} diff --git a/apps/mobile/modules/linkcode-daemon-discovery/expo-module.config.json b/apps/mobile/modules/linkcode-daemon-discovery/expo-module.config.json new file mode 100644 index 000000000..e5dc06683 --- /dev/null +++ b/apps/mobile/modules/linkcode-daemon-discovery/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android"], + "apple": { + "modules": ["LinkCodeDaemonDiscoveryModule"] + }, + "android": { + "modules": ["expo.modules.linkcodedaemondiscovery.LinkCodeDaemonDiscoveryModule"] + } +} diff --git a/apps/mobile/modules/linkcode-daemon-discovery/index.ts b/apps/mobile/modules/linkcode-daemon-discovery/index.ts new file mode 100644 index 000000000..88cb781d3 --- /dev/null +++ b/apps/mobile/modules/linkcode-daemon-discovery/index.ts @@ -0,0 +1,8 @@ +export type { + DaemonDiscoveryError, + DaemonDiscoverySnapshot, + DaemonDiscoveryStatus, + LinkCodeDaemonDiscoveryModuleEvents, + NativeDiscoveredDaemon, +} from './src/LinkCodeDaemonDiscovery.types'; +export { default } from './src/LinkCodeDaemonDiscoveryModule'; diff --git a/apps/mobile/modules/linkcode-daemon-discovery/ios/LinkCodeDaemonDiscovery.podspec b/apps/mobile/modules/linkcode-daemon-discovery/ios/LinkCodeDaemonDiscovery.podspec new file mode 100644 index 000000000..ce0cfc022 --- /dev/null +++ b/apps/mobile/modules/linkcode-daemon-discovery/ios/LinkCodeDaemonDiscovery.podspec @@ -0,0 +1,23 @@ +Pod::Spec.new do |s| + s.name = 'LinkCodeDaemonDiscovery' + s.version = '1.0.0' + s.summary = 'LinkCode daemon discovery' + s.description = 'Browses for LinkCode daemons on the local network.' + s.author = 'ArcBox' + s.homepage = 'https://github.com/arcboxlabs/linkcode' + s.platforms = { + :ios => '16.4' + } + s.source = { git: 'https://github.com/arcboxlabs/linkcode.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + s.frameworks = 'Network' + + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/apps/mobile/modules/linkcode-daemon-discovery/ios/LinkCodeDaemonDiscoveryModule.swift b/apps/mobile/modules/linkcode-daemon-discovery/ios/LinkCodeDaemonDiscoveryModule.swift new file mode 100644 index 000000000..b46cc685a --- /dev/null +++ b/apps/mobile/modules/linkcode-daemon-discovery/ios/LinkCodeDaemonDiscoveryModule.swift @@ -0,0 +1,289 @@ +import ExpoModulesCore +import Foundation +import Network + +private let eventName = "onHostsChanged" +private let serviceType = "_linkcode._tcp" +// TODO: Verify daemon identity and carry its paired capability once the advert contract exists. + +private struct DiscoveredDaemon { + let id: String + let name: String + let host: String + let port: Int +} + +public final class LinkCodeDaemonDiscoveryModule: Module { + private let queue = DispatchQueue(label: "ai.linkcode.daemon-discovery") + private var browser: NWBrowser? + private var connections: [String: NWConnection] = [:] + private var resolutionTimeouts: [String: DispatchWorkItem] = [:] + private var hosts: [String: DiscoveredDaemon] = [:] + private var activeServiceIds = Set() + private var generation = 0 + private var isObserved = false + private var status = "searching" + private var errorCode: String? + + public func definition() -> ModuleDefinition { + Name("LinkCodeDaemonDiscovery") + + Events(eventName) + + OnStartObserving(eventName) { [weak self] in + self?.queue.async { [weak self] in + self?.isObserved = true + self?.startDiscovery() + } + } + + OnStopObserving(eventName) { [weak self] in + self?.queue.async { [weak self] in + self?.isObserved = false + self?.stopDiscovery() + } + } + + OnAppEntersBackground { [weak self] in + self?.queue.async { [weak self] in + self?.stopDiscovery() + } + } + + OnAppEntersForeground { [weak self] in + self?.queue.async { [weak self] in + guard self?.isObserved == true else { return } + self?.startDiscovery() + } + } + + OnDestroy { [weak self] in + self?.queue.async { [weak self] in + self?.stopDiscovery() + } + } + } + + private func startDiscovery() { + guard browser == nil else { + emitSnapshot() + return + } + + generation += 1 + let currentGeneration = generation + hosts.removeAll() + activeServiceIds.removeAll() + status = "searching" + errorCode = nil + emitSnapshot() + + let parameters = NWParameters.tcp + parameters.includePeerToPeer = true + parameters.allowLocalEndpointReuse = true + + let browser = NWBrowser( + for: .bonjourWithTXTRecord(type: serviceType, domain: nil), + using: parameters + ) + self.browser = browser + + browser.stateUpdateHandler = { [weak self] state in + self?.handleBrowserState(state, generation: currentGeneration) + } + browser.browseResultsChangedHandler = { [weak self] results, _ in + self?.handleBrowseResults(results, generation: currentGeneration) + } + browser.start(queue: queue) + } + + private func stopDiscovery() { + generation += 1 + browser?.stateUpdateHandler = nil + browser?.browseResultsChangedHandler = nil + browser?.cancel() + browser = nil + connections.values.forEach { $0.cancel() } + connections.removeAll() + resolutionTimeouts.values.forEach { $0.cancel() } + resolutionTimeouts.removeAll() + hosts.removeAll() + activeServiceIds.removeAll() + } + + private func handleBrowserState(_ state: NWBrowser.State, generation: Int) { + guard generation == self.generation else { return } + + switch state { + case .ready: + status = "ready" + errorCode = nil + emitSnapshot() + case .waiting(let error): + if case .dns(let dnsError) = error, dnsError == kDNSServiceErr_PolicyDenied { + failDiscovery(with: "permissionDenied") + } + case .failed(let error): + if case .dns(let dnsError) = error, dnsError == kDNSServiceErr_PolicyDenied { + failDiscovery(with: "permissionDenied") + } else { + failDiscovery(with: "failed") + } + default: + break + } + } + + private func handleBrowseResults(_ results: Set, generation: Int) { + guard generation == self.generation else { return } + + var indexedResults: [String: (String, NWEndpoint)] = [:] + for result in results { + guard case let .service(name, type, domain, interface) = result.endpoint else { + continue + } + let id = [name, type, domain, interface?.name ?? ""].joined(separator: "|") + indexedResults[id] = (name, result.endpoint) + } + activeServiceIds = Set(indexedResults.keys) + + for id in Array(hosts.keys) where !activeServiceIds.contains(id) { + hosts.removeValue(forKey: id) + } + for id in Array(connections.keys) where !activeServiceIds.contains(id) { + finishResolution(id: id, connection: connections[id]) + } + emitSnapshot() + + for (id, service) in indexedResults where hosts[id] == nil && connections[id] == nil { + resolveService(id: id, name: service.0, endpoint: service.1, generation: generation) + } + } + + private func resolveService(id: String, name: String, endpoint: NWEndpoint, generation: Int) { + let parameters = NWParameters.tcp + parameters.includePeerToPeer = true + parameters.allowLocalEndpointReuse = true + parameters.requiredInterface = endpoint.interface + + let connection = NWConnection(to: endpoint, using: parameters) + connections[id] = connection + let timeout = DispatchWorkItem { [weak self, weak connection] in + guard let self, let connection, self.connections[id] === connection else { return } + self.finishResolution(id: id, connection: connection) + } + resolutionTimeouts[id] = timeout + connection.stateUpdateHandler = { [weak self, weak connection] state in + guard let self, let connection else { return } + self.handleConnectionState( + state, + connection: connection, + id: id, + name: name, + generation: generation + ) + } + connection.start(queue: queue) + queue.asyncAfter(deadline: .now() + 5, execute: timeout) + } + + private func handleConnectionState( + _ state: NWConnection.State, + connection: NWConnection, + id: String, + name: String, + generation: Int + ) { + guard generation == self.generation, connections[id] === connection else { + connection.cancel() + return + } + + switch state { + case .ready: + defer { + finishResolution(id: id, connection: connection) + } + guard + activeServiceIds.contains(id), + let endpoint = connection.currentPath?.remoteEndpoint, + case let .hostPort(host, port) = endpoint, + let hostName = hostName(host) + else { + return + } + hosts[id] = DiscoveredDaemon( + id: id, + name: name, + host: hostName, + port: Int(port.rawValue) + ) + emitSnapshot() + case .failed, .cancelled: + finishResolution(id: id, connection: connection) + default: + break + } + } + + private func finishResolution(id: String, connection: NWConnection?) { + guard let connection, connections[id] === connection else { return } + resolutionTimeouts.removeValue(forKey: id)?.cancel() + connections.removeValue(forKey: id) + connection.stateUpdateHandler = nil + connection.cancel() + } + + private func failDiscovery(with error: String) { + browser?.stateUpdateHandler = nil + browser?.browseResultsChangedHandler = nil + browser?.cancel() + browser = nil + connections.values.forEach { $0.cancel() } + connections.removeAll() + resolutionTimeouts.values.forEach { $0.cancel() } + resolutionTimeouts.removeAll() + hosts.removeAll() + activeServiceIds.removeAll() + status = "error" + errorCode = error + emitSnapshot() + } + + private func hostName(_ host: NWEndpoint.Host) -> String? { + switch host { + case .name(let name, _): + return name + case .ipv4(let address): + return IPv4Address(address.rawValue)?.debugDescription + case .ipv6(let address): + return address.debugDescription + @unknown default: + return nil + } + } + + private func emitSnapshot() { + let serializedHosts: [[String: Any]] = hosts.values + .sorted { lhs, rhs in + lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + .map { host in + [ + "id": host.id, + "name": host.name, + "host": host.host, + "port": host.port, + ] + } + + var payload: [String: Any] = [ + "status": status, + "hosts": serializedHosts, + ] + if let errorCode { + payload["error"] = errorCode + } + sendEvent(eventName, payload) + } +} diff --git a/apps/mobile/modules/linkcode-daemon-discovery/src/LinkCodeDaemonDiscovery.types.ts b/apps/mobile/modules/linkcode-daemon-discovery/src/LinkCodeDaemonDiscovery.types.ts new file mode 100644 index 000000000..d1e2505d8 --- /dev/null +++ b/apps/mobile/modules/linkcode-daemon-discovery/src/LinkCodeDaemonDiscovery.types.ts @@ -0,0 +1,22 @@ +export interface LinkCodeDaemonDiscoveryModuleEvents { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Expo's NativeModule event contract uses arbitrary argument lists. + [eventName: string]: (...args: any[]) => void; + onHostsChanged: (event: DaemonDiscoverySnapshot) => void; +} + +export type DaemonDiscoveryStatus = 'searching' | 'ready' | 'error'; + +export type DaemonDiscoveryError = 'permissionDenied' | 'unavailable' | 'failed'; + +export interface NativeDiscoveredDaemon { + id: string; + name: string; + host: string; + port: number; +} + +export interface DaemonDiscoverySnapshot { + status: DaemonDiscoveryStatus; + hosts: NativeDiscoveredDaemon[]; + error?: DaemonDiscoveryError; +} diff --git a/apps/mobile/modules/linkcode-daemon-discovery/src/LinkCodeDaemonDiscoveryModule.ts b/apps/mobile/modules/linkcode-daemon-discovery/src/LinkCodeDaemonDiscoveryModule.ts new file mode 100644 index 000000000..5891a8e74 --- /dev/null +++ b/apps/mobile/modules/linkcode-daemon-discovery/src/LinkCodeDaemonDiscoveryModule.ts @@ -0,0 +1,7 @@ +import { NativeModule, requireNativeModule } from 'expo'; + +import type { LinkCodeDaemonDiscoveryModuleEvents } from './LinkCodeDaemonDiscovery.types'; + +declare class LinkCodeDaemonDiscoveryModule extends NativeModule {} + +export default requireNativeModule('LinkCodeDaemonDiscovery'); diff --git a/apps/mobile/modules/linkcode-daemon-discovery/src/LinkCodeDaemonDiscoveryModule.web.ts b/apps/mobile/modules/linkcode-daemon-discovery/src/LinkCodeDaemonDiscoveryModule.web.ts new file mode 100644 index 000000000..3b20ec500 --- /dev/null +++ b/apps/mobile/modules/linkcode-daemon-discovery/src/LinkCodeDaemonDiscoveryModule.web.ts @@ -0,0 +1,7 @@ +import { NativeModule, registerWebModule } from 'expo'; + +import type { LinkCodeDaemonDiscoveryModuleEvents } from './LinkCodeDaemonDiscovery.types'; + +class LinkCodeDaemonDiscoveryModule extends NativeModule {} + +export default registerWebModule(LinkCodeDaemonDiscoveryModule, 'LinkCodeDaemonDiscovery'); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index dc9bcd36c..8d64cec35 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -22,6 +22,7 @@ "@linkcode/client-core": "workspace:*", "@linkcode/common": "workspace:*", "@linkcode/i18n": "workspace:*", + "@linkcode/providers": "workspace:*", "@linkcode/schema": "workspace:*", "@linkcode/transport": "workspace:*", "@linkcode/ui": "workspace:*", diff --git a/apps/mobile/src/app/(tabs)/threads/index.tsx b/apps/mobile/src/app/(tabs)/threads/index.tsx index f36a737a6..052d99180 100644 --- a/apps/mobile/src/app/(tabs)/threads/index.tsx +++ b/apps/mobile/src/app/(tabs)/threads/index.tsx @@ -7,7 +7,7 @@ import { Text as UIText, } from '@expo/ui/swift-ui'; import { useSessions } from '@linkcode/client-core'; -import type { AgentKind, SessionId, SessionInfo } from '@linkcode/schema'; +import type { SessionInfo } from '@linkcode/schema'; import type { ThreadGroup } from '@linkcode/ui/native'; import { AGENT_LABELS, @@ -17,7 +17,6 @@ import { } from '@linkcode/ui/native'; import { SECONDARY } from '@mobile/components/form/styles'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; -import { NewThreadSheet } from '@mobile/components/host/new-thread-sheet'; import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; import { useHostMenuItems } from '@mobile/components/host/use-host-menu-items'; import type { PrimaryAction } from '@mobile/components/shell/primary-action'; @@ -25,7 +24,6 @@ import { usePrimaryAction } from '@mobile/components/shell/primary-action'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useTrailingActions } from '@mobile/components/shell/use-trailing-actions'; import { useHostConnection } from '@mobile/runtime/host-connection'; -import { captureMobileProductEvent } from '@mobile/runtime/product-analytics'; import { useWorkspaces } from '@mobile/runtime/use-workspaces'; import { Stack, useRouter } from 'expo-router'; import { SquarePenIcon } from 'lucide-react-native'; @@ -44,13 +42,13 @@ function threadTitle(session: SessionInfo): string { } /** The header outlives the connection: it carries the host switcher, which is the way out of a host - * that cannot be reached, so it is mounted above the gate rather than inside it. New-thread is the - * one part that needs a client, and it is dropped rather than left to fail. */ + * that cannot be reached, so it is mounted above the gate rather than inside it. New-thread needs a + * client, so its entry points are dropped until the connection is ready. */ export default function ThreadsRoute(): React.ReactNode { const t = useTranslations('mobile.sessions'); + const router = useRouter(); const hostMenuItems = useHostMenuItems(); const connection = useHostConnection(); - const [sheetOpen, setSheetOpen] = useState(false); const primaryAction: PrimaryAction | null = connection?.status === 'ready' @@ -58,7 +56,7 @@ export default function ThreadsRoute(): React.ReactNode { sf: 'square.and.pencil', icon: SquarePenIcon, label: t('newThread'), - onPress: () => setSheetOpen(true), + onPress: () => router.push('/new-thread'), } : null; usePrimaryAction('threads', primaryAction); @@ -75,28 +73,21 @@ export default function ThreadsRoute(): React.ReactNode { }} /> - + ); } /** Threads inbox: sessions grouped by workspace (project) under collapsible headers, with the - * native search bar stacked below the navigation bar. Empty workspace groups are hidden — the sheet - * is where they surface. */ -function ThreadsScreen({ - sheetOpen, - onSheetOpenChange, -}: { - sheetOpen: boolean; - onSheetOpenChange: (open: boolean) => void; -}): React.ReactNode { + * native search bar stacked below the navigation bar. Empty workspace groups are hidden — the + * new-thread page is where they surface. */ +function ThreadsScreen(): React.ReactNode { const t = useTranslations('mobile.sessions'); const router = useRouter(); - const { sessions, create, refresh, loading } = useSessions(); + const { sessions, refresh, loading } = useSessions(); const { workspaces, refresh: refreshWorkspaces } = useWorkspaces(); - const [creating, setCreating] = useState(false); const [query, setQuery] = useState(''); // Stable so the search bar's options object survives a keystroke without re-registering. @@ -127,33 +118,6 @@ function ThreadsScreen({ await Promise.all([refresh(), refreshWorkspaces()]); }; - const onCreate = async (kind: AgentKind, cwd: string) => { - if (creating) return; - const startedAt = Date.now(); - setCreating(true); - try { - let sessionId: SessionId; - try { - sessionId = await create({ kind, cwd }); - captureMobileProductEvent('thread created', { - agent_kind: kind, - duration_ms: Date.now() - startedAt, - }); - } catch (error) { - captureMobileProductEvent('thread create failed', { - agent_kind: kind, - duration_ms: Date.now() - startedAt, - }); - throw error; - } - await refreshWorkspaces(); - onSheetOpenChange(false); - router.push(`/session/${sessionId}`); - } finally { - setCreating(false); - } - }; - return ( <> {/* `stacked` keeps the field below the inline title instead of moving into the iOS 26 toolbar. */} @@ -181,7 +145,7 @@ function ThreadsScreen({ {needle === '' ? (
{t('emptyHint')}}> {t('emptyTitle')} - onSheetOpenChange(true)} /> + router.push('/new-thread')} />
) : (
@@ -198,13 +162,6 @@ function ThreadsScreen({ /> )} - ); } diff --git a/apps/mobile/src/app/connect.tsx b/apps/mobile/src/app/connect.tsx index ddb66ce32..e6199a053 100644 --- a/apps/mobile/src/app/connect.tsx +++ b/apps/mobile/src/app/connect.tsx @@ -1,5 +1,5 @@ import { Form, Host } from '@expo/ui/swift-ui'; -import { ManualHostSection } from '@mobile/components/connect/manual-host-section'; +import { DiscoveredHostsSection } from '@mobile/components/connect/discovered-hosts-section'; import { MyMachinesSection } from '@mobile/components/connect/my-machines-section'; import { SavedHostsSection } from '@mobile/components/connect/saved-hosts-section'; import { SignInSection } from '@mobile/components/connect/sign-in-section'; @@ -33,7 +33,7 @@ export default function ConnectScreen(): React.ReactNode { {hosts.length > 0 ? : null} - + diff --git a/apps/mobile/src/app/new-thread.tsx b/apps/mobile/src/app/new-thread.tsx new file mode 100644 index 000000000..e30fe5230 --- /dev/null +++ b/apps/mobile/src/app/new-thread.tsx @@ -0,0 +1,58 @@ +import { Composer } from '@mobile/components/conversation/composer'; +import { HostClientGate } from '@mobile/components/host/host-client-gate'; +import { AgentSelectorChip, ApprovalChip } from '@mobile/components/host/new-thread/draft-tools'; +import { ProjectRow } from '@mobile/components/host/new-thread/project-row'; +import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; +import { useNewThreadDraft } from '@mobile/runtime/use-new-thread-draft'; +import { Stack, useRouter } from 'expo-router'; +import { noop } from 'foxact/noop'; +import { View } from 'react-native'; +import { KeyboardStickyView } from 'react-native-keyboard-controller'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +/** Composer-first new-thread page, the mobile shape of the desktop draft surface: the start + * options live inside the composer as menu chips — type the first message, send, and the thread + * starts on the host with the prompt riding behind it. A root push, so it covers the tab bar. */ +export default function NewThreadRoute(): React.ReactNode { + const insets = useSafeAreaInsets(); + + return ( + + {/* Title-less on purpose: the composer says everything, and the bare back chevron keeps + the page reading as a sheet of options rather than a destination. */} + + + + + + ); +} + +function NewThreadScreen(): React.ReactNode { + const router = useRouter(); + const { text, setText, start, creating, sendBlocked, error, project, approval, selector } = + useNewThreadDraft((sessionId) => router.replace(`/session/${sessionId}`)); + + return ( + <> + + + + { + void start(text); + }} + onStop={noop} + isRunning={false} + disabled={creating} + sendBlocked={sendBlocked} + error={error} + tools={} + trailing={} + /> + + + ); +} diff --git a/apps/mobile/src/app/session/[sessionId].tsx b/apps/mobile/src/app/session/[sessionId].tsx index 7ae1558e1..f5dca6630 100644 --- a/apps/mobile/src/app/session/[sessionId].tsx +++ b/apps/mobile/src/app/session/[sessionId].tsx @@ -3,18 +3,28 @@ import type { SessionId, ToolCall } from '@linkcode/schema'; import { SessionIdSchema } from '@linkcode/schema'; import { AGENT_LABELS, + EFFORT_OPTIONS_BY_ID, EmptyState, + effortOptionsForModel, + modelChoiceKey, repositoryLabel, + resolveModel, selectCurrentPlan, selectPendingPromptItems, } from '@linkcode/ui/native'; import { Composer } from '@mobile/components/conversation/composer'; import { PromptDock } from '@mobile/components/conversation/prompt-dock/prompt-dock'; -import { SessionStatusChip } from '@mobile/components/conversation/session-status-chip'; +import { SessionTitle } from '@mobile/components/conversation/session-title'; +import { + SessionApprovalChip, + SessionSelectorChip, +} from '@mobile/components/conversation/session-tools'; import { TimelineItem } from '@mobile/components/conversation/timeline-item'; import { ToolDetailSheet } from '@mobile/components/conversation/tool-detail-sheet/tool-detail-sheet'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; +import { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; +import { useAccountModels } from '@mobile/runtime/use-account-models'; import { useSeededConversation } from '@mobile/runtime/use-seeded-conversation'; import { useSessionActions } from '@mobile/runtime/use-session-actions'; import { useSessionAutoResume } from '@mobile/runtime/use-session-auto-resume'; @@ -47,6 +57,7 @@ export default function SessionRoute(): React.ReactNode { function SessionScreen(): React.ReactNode { const t = useTranslations('mobile.conversation'); const tChat = useTranslations('mobile.chat'); + const tSettings = useTranslations('mobile.settings'); const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); const muted = useThemeColor('muted'); @@ -74,11 +85,38 @@ function SessionScreen(): React.ReactNode { const actions = useSessionActions(sessionId, conversation.status); const { stop } = useSessionAutoResume(sessionId, session?.status, autoResumeSuppressed); const [openToolCallId, setOpenToolCallId] = useState(null); + // Measured height of the floating composer block, fed back to the list as its bottom inset so + // resting content clears the card while scrolling still flows under the glass. + const [dockHeight, setDockHeight] = useState(0); const title = session ? (session.title ?? `${AGENT_LABELS[session.kind]} in ${repositoryLabel(session.cwd)}`) : ''; + // Composer tools mirror the desktop live composer: account-backed models, effort options for + // the model the session actually runs on, and the adapter-advertised policies — all values + // server-reflected off the conversation, never held locally. + const models = useAccountModels(session?.kind ?? null); + const currentModelOption = resolveModel( + models ?? undefined, + conversation.currentModel, + session?.accountId, + ); + const effortOptions = session + ? effortOptionsForModel( + session.kind, + resolveModel(conversation.availableModels ?? undefined, conversation.currentModel), + ) + : undefined; + const selectorValue = [ + currentModelOption?.label ?? + conversation.currentModel ?? + (session ? AGENT_LABELS[session.kind] : ''), + ...(conversation.currentEffort + ? [EFFORT_OPTIONS_BY_ID[conversation.currentEffort].shortLabel] + : []), + ].join(' · '); + const prompts = selectPendingPromptItems(conversation); const plan = selectCurrentPlan(conversation); const openToolCall: ToolCall | null = @@ -87,23 +125,20 @@ function SessionScreen(): React.ReactNode { item.kind === 'tool' && item.toolCall.toolCallId === openToolCallId, )?.toolCall ?? null; + const stopThread = (): void => { + router.setParams({ autoResume: 'false' }); + stop(); + }; + const copyThreadId = (): void => { + if (sessionId) void Clipboard.setStringAsync(sessionId); + }; + + // Android fallback only — native bar items are iOS-only, so the menu degrades to an alert. const showMenu = (): void => { if (!sessionId) return; Alert.alert(title, undefined, [ - { - text: tChat('stopThread'), - style: 'destructive', - onPress() { - router.setParams({ autoResume: 'false' }); - stop(); - }, - }, - { - text: tChat('copyThreadId'), - onPress() { - void Clipboard.setStringAsync(sessionId); - }, - }, + { text: tChat('stopThread'), style: 'destructive', onPress: stopThread }, + { text: tChat('copyThreadId'), onPress: copyThreadId }, { text: tChat('cancel'), style: 'cancel' }, ]); }; @@ -112,24 +147,54 @@ function SessionScreen(): React.ReactNode { const reversed = [...conversation.items].reverse(); return ( - + ( - - {conversation.status ? : null} - - - - - ), + headerTitle: () => , + ...(process.env.EXPO_OS === 'ios' + ? { + unstable_headerRightItems: () => [ + { + type: 'menu', + label: tSettings('more'), + icon: { type: 'sfSymbol', name: 'ellipsis' }, + menu: { + items: [ + { + type: 'action', + label: tChat('copyThreadId'), + icon: { type: 'sfSymbol', name: 'doc.on.doc' }, + onPress: copyThreadId, + }, + { + type: 'action', + label: tChat('stopThread'), + icon: { type: 'sfSymbol', name: 'stop.circle' }, + destructive: true, + onPress: stopThread, + }, + ], + }, + }, + ], + } + : { + headerRight: () => ( + + + + ), + }), }} /> {conversation.items.length === 0 ? ( @@ -150,30 +215,76 @@ function SessionScreen(): React.ReactNode { ListFooterComponent={ process.env.EXPO_OS === 'ios' ? : null } + // Inverted list: the header renders at the visual bottom — the clearance that keeps + // resting content out from under the floating composer. + ListHeaderComponent={ + USES_IOS_26_NAVIGATION && dockHeight > 0 ? ( + + ) : null + } contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 12, gap: 12 }} className="flex-1" /> )} {/* Sticky rather than an avoiding view: the inverted list already pins to the bottom, so - the composer only has to ride the keyboard instead of resizing the whole screen. */} - - - - + the composer only has to ride the keyboard instead of resizing the whole screen. On + iOS 26 the block floats over the list so content scrolls under the glass. */} + setDockHeight(event.nativeEvent.layout.height)} + > + + + + } + trailing={ + session ? ( + + ) : undefined + } + /> + + setOpenToolCallId(null)} /> ); diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx index 4fd40694e..85fb6c986 100644 --- a/apps/mobile/src/app/sign-in.tsx +++ b/apps/mobile/src/app/sign-in.tsx @@ -40,7 +40,6 @@ const styles = StyleSheet.create({ flexGrow: 1, justifyContent: 'center', paddingHorizontal: 24, - paddingVertical: 24, }, content: { gap: 48, @@ -156,14 +155,14 @@ export default function SignInScreen() { diff --git a/apps/mobile/src/components/connect/discovered-hosts-section.tsx b/apps/mobile/src/components/connect/discovered-hosts-section.tsx new file mode 100644 index 000000000..7d600f8b3 --- /dev/null +++ b/apps/mobile/src/components/connect/discovered-hosts-section.tsx @@ -0,0 +1,57 @@ +import { HStack, ProgressView, Section, Text } from '@expo/ui/swift-ui'; +import { accessibilityLabel, controlSize, foregroundStyle } from '@expo/ui/swift-ui/modifiers'; +import { ManualHostRow } from '@mobile/components/connect/manual-host-row'; +import { NavigationRow } from '@mobile/components/form/navigation-row'; +import { canonicalDirectHostUrl } from '@mobile/runtime/daemon-discovery'; +import { useDaemonDiscovery } from '@mobile/runtime/use-daemon-discovery'; +import { useOpenHost } from '@mobile/runtime/use-open-host'; +import { useHostRegistryStore } from '@mobile/stores/host-store'; +import { useTranslations } from 'use-intl'; + +export function DiscoveredHostsSection(): React.ReactNode { + const t = useTranslations('mobile.connect'); + const { hosts, status } = useDaemonDiscovery(); + const openHost = useOpenHost(); + const savedHosts = useHostRegistryStore((state) => state.hosts); + const addHost = useHostRegistryStore((state) => state.addHost); + const savedUrls = new Set(); + for (let i = 0, len = savedHosts.length; i < len; i++) { + const host = savedHosts[i]; + if ('url' in host) savedUrls.add(canonicalDirectHostUrl(host.url)); + } + const discoveredHosts = hosts.filter((host) => !savedUrls.has(canonicalDirectHostUrl(host.url))); + + const saveAndOpen = (host: (typeof discoveredHosts)[number]) => { + const profile = addHost({ name: host.name, url: host.url }); + openHost(profile.id); + }; + + return ( +
+ {t('discovery.title')} + {status === 'error' ? null : ( + + )} + + } + > + {status === 'error' ? ( + {t('discovery.error')} + ) : ( + discoveredHosts.map((host) => ( + saveAndOpen(host)} + /> + )) + )} + +
+ ); +} diff --git a/apps/mobile/src/components/connect/manual-host-section.tsx b/apps/mobile/src/components/connect/manual-host-row.tsx similarity index 50% rename from apps/mobile/src/components/connect/manual-host-section.tsx rename to apps/mobile/src/components/connect/manual-host-row.tsx index b1ae4f5f0..af814f644 100644 --- a/apps/mobile/src/components/connect/manual-host-section.tsx +++ b/apps/mobile/src/components/connect/manual-host-row.tsx @@ -1,15 +1,10 @@ -import { Section } from '@expo/ui/swift-ui'; import { NavigationRow } from '@mobile/components/form/navigation-row'; import { useRouter } from 'expo-router'; import { useTranslations } from 'use-intl'; -export function ManualHostSection(): React.ReactNode { +export function ManualHostRow(): React.ReactNode { const t = useTranslations('mobile.connect'); const router = useRouter(); - return ( -
- router.push('/add-host')} /> -
- ); + return router.push('/add-host')} />; } diff --git a/apps/mobile/src/components/conversation/composer.tsx b/apps/mobile/src/components/conversation/composer.tsx index 5964783b8..f1fed929f 100644 --- a/apps/mobile/src/components/conversation/composer.tsx +++ b/apps/mobile/src/components/conversation/composer.tsx @@ -1,77 +1,113 @@ +import { Host, Spacer, VStack } from '@expo/ui/swift-ui'; +import { frame, glassEffect } from '@expo/ui/swift-ui/modifiers'; +import { SendButton } from '@mobile/components/conversation/send-button'; +import { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; import { useThemeColor } from 'heroui-native'; -import { ArrowUpIcon, SquareIcon } from 'lucide-react-native'; -import { useState } from 'react'; -import { Pressable, Text, TextInput, View } from 'react-native'; +import { Text, TextInput, View } from 'react-native'; import { useTranslations } from 'use-intl'; -/** Cap the growing input so a long draft scrolls internally instead of eating the timeline. */ +/** Cap the growing input so a long draft scrolls internally instead of eating the screen. */ const MAX_INPUT_HEIGHT = 140; -/** Message composer pinned below the timeline. One circular action morphs between send and - * stop, because a turn in flight is the only thing the user wants to do to it. Send and stop are - * wired by the screen; this owns nothing but the draft. */ +/** Matches the card's `rounded-3xl`, so the glass shape and the RN clip agree. */ +const CARD_RADIUS = 24; + +const GLASS = [ + frame({ maxWidth: Number.POSITIVE_INFINITY, maxHeight: Number.POSITIVE_INFINITY }), + glassEffect({ + glass: { variant: 'regular' }, + shape: 'roundedRectangle', + cornerRadius: CARD_RADIUS, + }), +]; + +/** The message composer card, shaped like the web composer: the editor on top and a footer row + * below it — optional tool slots left and trailing, then the circular send/stop action. Chat + * screens pass no tools; the new-thread draft fills the slots with its start-option chips. + * Send and stop are wired by the screen; this owns nothing but the draft text. */ export function Composer({ onSend, onStop, isRunning, disabled, error, + sendBlocked = false, + text, + onTextChange, + tools, + trailing, }: { onSend: (text: string) => void; onStop: () => void; isRunning: boolean; disabled: boolean; error?: string; + /** Typing stays possible while send is off (e.g. no working directory resolved yet). */ + sendBlocked?: boolean; + text: string; + onTextChange: (text: string) => void; + /** Footer-left tool cluster (the draft's approval chip). */ + tools?: React.ReactNode; + /** Footer-right cluster before send (the draft's harness/model/effort selector). */ + trailing?: React.ReactNode; }): React.ReactNode { const t = useTranslations('mobile.conversation'); - const [text, setText] = useState(''); - const [muted, background, foreground] = useThemeColor(['muted', 'background', 'foreground']); + const muted = useThemeColor('muted'); const trimmed = text.trim(); - const canSend = !disabled && trimmed.length > 0; + const canSend = !disabled && !sendBlocked && trimmed.length > 0; const actionEnabled = isRunning ? !disabled : canSend; const submit = (): void => { if (!canSend) return; onSend(trimmed); - setText(''); }; - const ActionIcon = isRunning ? SquareIcon : ArrowUpIcon; - return ( - + {error ? {error} : null} - + {/* On iOS 26 the card's material is real Liquid Glass, drawn by a SwiftUI backdrop behind + the RN input (the input itself must stay RN: multiline auto-grow + keyboard riding). + Pre-26 and Android keep the filled card. */} + + {USES_IOS_26_NAVIGATION ? ( + + + + + + + + ) : null} - ({ - backgroundColor: actionEnabled ? foreground : muted, - opacity: pressed ? 0.6 : 1, - })} - > - + {tools} + + {trailing} + - + ); diff --git a/apps/mobile/src/components/conversation/option-chip.tsx b/apps/mobile/src/components/conversation/option-chip.tsx new file mode 100644 index 000000000..8faddbc05 --- /dev/null +++ b/apps/mobile/src/components/conversation/option-chip.tsx @@ -0,0 +1,73 @@ +import { HStack, Image, Menu, Text } from '@expo/ui/swift-ui'; +import { + accessibilityLabel, + font, + foregroundStyle, + frame, + lineLimit, + padding, + tint, +} from '@expo/ui/swift-ui/modifiers'; +import { Color } from 'expo-router'; +import type { SFSymbol } from 'sf-symbols-typescript'; + +const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); +const CHIP_FONT = font({ textStyle: 'footnote' }); +/** SwiftUI draws a Menu label with the accent tint, and hierarchical styles derive from it — + * re-tinting to the label color makes the secondary style read as gray, not light blue. */ +const LABEL_TINT = tint(Color.ios.label); + +/** One composer tool: a ghost chip opening a native `UIMenu` — the web composer's footer buttons + * at touch size, without a fill. The `label` names the chip for accessibility only. */ +export function OptionChip({ + sf, + label, + value, + iconOnly = false, + maxValueWidth, + children, +}: { + /** Optional SF symbol; omit when an RN brand icon sits beside the chip instead. */ + sf?: SFSymbol; + /** Accessibility name of the chip ("Model"); not rendered. */ + label: string; + /** Resolved current value — rendered unless `iconOnly`, always spoken. */ + value: string; + /** Icon-size the chip (the shield); the value still reaches accessibility. */ + iconOnly?: boolean; + /** Cap for long values so one chip cannot push the send button out. */ + maxValueWidth?: number; + /** Menu items: `Picker` / `Button` / `Section` / `Divider` / nested `Menu`. */ + children: React.ReactNode; +}): React.ReactNode { + return ( + + {sf ? : null} + {iconOnly ? null : ( + + {value} + + )} + + } + > + {children} + + ); +} diff --git a/apps/mobile/src/components/conversation/send-button.tsx b/apps/mobile/src/components/conversation/send-button.tsx new file mode 100644 index 000000000..8c75b1779 --- /dev/null +++ b/apps/mobile/src/components/conversation/send-button.tsx @@ -0,0 +1,47 @@ +import { useThemeColor } from 'heroui-native'; +import { ArrowUpIcon, SquareIcon } from 'lucide-react-native'; +import { Pressable } from 'react-native'; + +/** The round send action shared by the conversation composer and the new-thread draft: one + * circular button that morphs between send and stop, because a turn in flight is the only thing + * the user wants to do to it. */ +export function SendButton({ + isRunning, + enabled, + sendLabel, + stopLabel, + onSend, + onStop, +}: { + isRunning: boolean; + enabled: boolean; + sendLabel: string; + stopLabel: string; + onSend: () => void; + onStop: () => void; +}): React.ReactNode { + const [muted, background, foreground] = useThemeColor(['muted', 'background', 'foreground']); + const ActionIcon = isRunning ? SquareIcon : ArrowUpIcon; + + return ( + ({ + backgroundColor: enabled ? foreground : muted, + opacity: pressed ? 0.6 : 1, + })} + > + + + ); +} diff --git a/apps/mobile/src/components/conversation/session-status-chip.tsx b/apps/mobile/src/components/conversation/session-status-chip.tsx deleted file mode 100644 index 6ad69d8a4..000000000 --- a/apps/mobile/src/components/conversation/session-status-chip.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { SessionStatus } from '@linkcode/schema'; -import { Chip } from 'heroui-native'; -import { useTranslations } from 'use-intl'; - -const STATUS_COLOR = { - starting: 'warning', - idle: 'default', - running: 'success', - 'awaiting-input': 'warning', - stopped: 'default', -} as const satisfies Record; - -export function SessionStatusChip({ status }: { status: SessionStatus }): React.ReactNode { - const t = useTranslations('mobile.sessions.status'); - - return ( - - {t(status)} - - ); -} diff --git a/apps/mobile/src/components/conversation/session-title.tsx b/apps/mobile/src/components/conversation/session-title.tsx new file mode 100644 index 000000000..36cf1b448 --- /dev/null +++ b/apps/mobile/src/components/conversation/session-title.tsx @@ -0,0 +1,31 @@ +import type { SessionStatus } from '@linkcode/schema'; +import { useThemeColor } from 'heroui-native'; +import { Text, View } from 'react-native'; + +/** Header title with the session's status as a dot — the same 8pt dot and palette as the thread + * list rows, replacing the labeled chip. */ +export function SessionTitle({ + title, + status, +}: { + title: string; + status: SessionStatus | null; +}): React.ReactNode { + const [success, warning, muted] = useThemeColor(['success', 'warning', 'muted']); + const color = + status === 'running' + ? success + : status === 'starting' || status === 'awaiting-input' + ? warning + : muted; + + return ( + + {/* `headline` is the 17pt semibold metric UIKit draws inline nav titles with. */} + + {title} + + {status ? : null} + + ); +} diff --git a/apps/mobile/src/components/conversation/session-tools.tsx b/apps/mobile/src/components/conversation/session-tools.tsx new file mode 100644 index 000000000..e09721a6e --- /dev/null +++ b/apps/mobile/src/components/conversation/session-tools.tsx @@ -0,0 +1,137 @@ +import { Host, Menu, Picker, Text as UIText } from '@expo/ui/swift-ui'; +import { tag } from '@expo/ui/swift-ui/modifiers'; +import type { AgentKind, ApprovalPolicyState, EffortLevel } from '@linkcode/schema'; +import type { EffortOption, ModelOption } from '@linkcode/ui/native'; +import { AgentIcon, groupModelsByProvider, modelChoiceKey } from '@linkcode/ui/native'; +import { OptionChip } from '@mobile/components/conversation/option-chip'; +import { useThemeColor } from 'heroui-native'; +import { View } from 'react-native'; +import { useTranslations } from 'use-intl'; + +/** The live session's approval chip: a ghost shield opening the adapter-advertised policy menu. + * Absent state means the adapter has no switchable policies, so nothing renders. Selection is + * server-reflected — the checkmark moves once `approval-policy-update` echoes the switch. */ +export function SessionApprovalChip({ + approvalPolicy, + onPolicyChange, +}: { + approvalPolicy: ApprovalPolicyState | null; + onPolicyChange: (policyId: string) => void; +}): React.ReactNode { + const t = useTranslations('mobile.sessions'); + if (!approvalPolicy || approvalPolicy.availablePolicies.length === 0) return null; + + // eslint-disable-next-line vibe-proof/react-no-performance-impacting-array-find -- one lookup against a handful of policies per render + const current = approvalPolicy.availablePolicies.find( + (policy) => policy.policyId === approvalPolicy.currentPolicyId, + ); + return ( + + + + {approvalPolicy.availablePolicies.map((policy) => ( + + {policy.name} + + ))} + + + + ); +} + +/** The live session's selector: the harness brand mark beside one menu with Model / Effort + * submenus. No harness submenu (the agent is fixed) and no "Default" entries — a running session + * is always on a concrete value, and the checkmarks follow the `model-update` / `effort-update` + * echoes rather than any local pick. */ +export function SessionSelectorChip({ + kind, + selectorValue, + models, + currentModelKey, + onModelChange, + effortOptions, + currentEffort, + onEffortChange, +}: { + kind: AgentKind; + /** Text on the chip — the running model (and effort), or a placeholder. */ + selectorValue: string; + /** Account-backed options; null while loading (model submenu hidden). */ + models: ModelOption[] | null; + /** `modelChoiceKey` of the entry matching the running model, or null while unknown. */ + currentModelKey: string | null; + onModelChange: (model: ModelOption) => void; + effortOptions: EffortOption[] | undefined; + currentEffort: EffortLevel | null; + onEffortChange: (effort: EffortLevel) => void; +}): React.ReactNode { + const t = useTranslations('mobile.sessions'); + const muted = useThemeColor('muted'); + const hasModels = models !== null && models.length > 0; + const hasEfforts = effortOptions !== undefined && effortOptions.length > 0; + if (!hasModels && !hasEfforts) return null; + + // The account label joins a row only when the list spans several accounts — the same threshold + // as the web's provider grouping; a single-account list repeating its account is noise. + const spansAccounts = models !== null && groupModelsByProvider(models) !== null; + + return ( + + {/* Footnote's 13pt metric, so the mark scales with the chip text beside it. */} + + + {/* Keyed by menu shape: children added to an already-created Menu never reach the + native UIMenu, so late-loading models/efforts must remount the chip. */} + + {hasModels ? ( + + { + const option = models.find((model) => modelChoiceKey(model) === key); + if (option) onModelChange(option); + }} + > + {models.map((model) => ( + + {spansAccounts && model.description + ? `${model.label} — ${model.description}` + : model.label} + + ))} + + + ) : null} + {hasEfforts ? ( + + { + const option = effortOptions.find((candidate) => candidate.id === value); + if (option) onEffortChange(option.id); + }} + > + {effortOptions.map((option) => ( + + {option.label} + + ))} + + + ) : null} + + + + ); +} diff --git a/apps/mobile/src/components/host/new-thread-sheet.tsx b/apps/mobile/src/components/host/new-thread-sheet.tsx deleted file mode 100644 index 852204e9f..000000000 --- a/apps/mobile/src/components/host/new-thread-sheet.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { - BottomSheet, - Button, - Form, - Host, - HStack, - Picker, - Section, - Text, - TextField, - useNativeState, - VStack, -} from '@expo/ui/swift-ui'; -import { - autocorrectionDisabled, - disabled, - font, - foregroundStyle, - pickerStyle, - tag, - textInputAutocapitalization, -} from '@expo/ui/swift-ui/modifiers'; -import type { AgentKind, WorkspaceRecord } from '@linkcode/schema'; -import { AgentKindSchema } from '@linkcode/schema'; -import { AGENT_LABELS, repositoryLabel } from '@linkcode/ui/native'; -import { useState } from 'react'; -import { useTranslations } from 'use-intl'; - -const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); -const FOOTNOTE = font({ textStyle: 'footnote' }); - -/** New-thread sheet: agent picker + workspace (project) picker with a custom-path fallback. - * The parent owns creation and presentation; it closes the sheet by flipping `isPresented`. */ -export function NewThreadSheet({ - isPresented, - onIsPresentedChange, - workspaces, - creating, - onCreate, -}: { - isPresented: boolean; - onIsPresentedChange: (isPresented: boolean) => void; - workspaces: WorkspaceRecord[]; - creating: boolean; - onCreate: (kind: AgentKind, cwd: string) => void; -}): React.ReactNode { - const t = useTranslations('mobile.sessions'); - - const [kind, setKind] = useState(AgentKindSchema.options[0]); - const [selectedCwd, setSelectedCwd] = useState(null); - const customPath = useNativeState(''); - - // Recency order mirrors the thread groups; the most recent project is the default pick. - const ordered = [...workspaces].sort((a, b) => b.lastUsedAt - a.lastUsedAt); - const effectiveCwd = selectedCwd ?? ordered[0]?.cwd ?? null; - - const create = () => { - const target = effectiveCwd ?? customPath.get().trim(); - if (target) onCreate(kind, target); - }; - - return ( - // `BottomSheet` is SwiftUI like any other `@expo/ui` view and red-boxes when mounted straight - // into the RN tree. The host carries no layout of its own — the sheet presents over the whole - // screen from UIKit — so it stays zero-sized and lets touches through to the screen behind it. - - {/* Sized to its content, or SwiftUI presents it at a near-full-screen detent — a sheet this - short reads as a takeover otherwise. */} - -
- {/* Segmented rather than the old icon chips: the agent brand marks are RN SVG - components, which have no place in a SwiftUI view tree. */} -
- - {AgentKindSchema.options.map((option) => ( - - {AGENT_LABELS[option]} - - ))} - -
- - {ordered.length > 0 ? ( - // An inline picker draws the selection checkmark itself, replacing the hand-placed one. -
- - {ordered.map((workspace) => ( - - {workspace.name ?? repositoryLabel(workspace.cwd)} - {workspace.cwd} - - ))} - -
- ) : ( -
- - {t('cwdLabel')} - - -
- )} - -
-
-
-
-
- ); -} diff --git a/apps/mobile/src/components/host/new-thread/draft-tools.tsx b/apps/mobile/src/components/host/new-thread/draft-tools.tsx new file mode 100644 index 000000000..42e74c645 --- /dev/null +++ b/apps/mobile/src/components/host/new-thread/draft-tools.tsx @@ -0,0 +1,152 @@ +import { Host, Menu, Picker, Text as UIText } from '@expo/ui/swift-ui'; +import { tag } from '@expo/ui/swift-ui/modifiers'; +import type { AgentKind, ApprovalPolicy } from '@linkcode/schema'; +import { AgentKindSchema } from '@linkcode/schema'; +import type { EffortOption, ModelOption } from '@linkcode/ui/native'; +import { + AGENT_LABELS, + AgentIcon, + groupModelsByProvider, + modelChoiceKey, +} from '@linkcode/ui/native'; +import { OptionChip } from '@mobile/components/conversation/option-chip'; +import { useThemeColor } from 'heroui-native'; +import { View } from 'react-native'; +import { useTranslations } from 'use-intl'; + +/** Tag for the leading "Default" entry in each menu — picking it clears the explicit pick, so + * the agent's own startup resolution applies again. */ +const DEFAULT_TAG = '__default__'; + +function clearable(onChange: (value: string | null) => void): (value: string) => void { + return (value) => { + onChange(value === DEFAULT_TAG ? null : value); + }; +} + +/** The draft's approval-policy chip: a ghost shield opening the policy menu. */ +export function ApprovalChip({ + policies, + policyId, + policyValue, + onPolicyIdChange, +}: { + policies: ApprovalPolicy[]; + policyId: string | null; + policyValue: string; + onPolicyIdChange: (policyId: string | null) => void; +}): React.ReactNode { + const t = useTranslations('mobile.sessions'); + if (policies.length === 0) return null; + + return ( + + + + {t('defaultOption')} + {policies.map((policy) => ( + + {policy.name} + + ))} + + + + ); +} + +/** The draft's combined selector: the harness brand mark beside one menu with Agent / Model / + * Effort submenus, mirroring the desktop `ModelSelectorMenu`. The brand mark is an RN view and + * cannot enter the SwiftUI tree, so it sits flush beside the menu trigger — ghost styling makes + * the pair read as one control. */ +export function AgentSelectorChip({ + kind, + onKindChange, + models, + modelKey, + onModelKeyChange, + selectorValue, + effortOptions, + effort, + onEffortChange, +}: { + kind: AgentKind; + onKindChange: (kind: AgentKind) => void; + /** Account-backed options; null while loading (model submenu hidden). */ + models: ModelOption[] | null; + /** `modelChoiceKey` of the explicit pick, or null for default. */ + modelKey: string | null; + onModelKeyChange: (key: string | null) => void; + /** Text on the chip — resolved model (and effort), or the harness name. */ + selectorValue: string; + effortOptions: EffortOption[] | undefined; + effort: string | null; + onEffortChange: (effort: string | null) => void; +}): React.ReactNode { + const t = useTranslations('mobile.sessions'); + const muted = useThemeColor('muted'); + // The account label joins a row only when the list spans several accounts — the same threshold + // as the web's provider grouping; a single-account list repeating its account is noise. + const spansAccounts = models !== null && groupModelsByProvider(models) !== null; + + return ( + + {/* Footnote's 13pt metric, so the mark scales with the chip text beside it. */} + + + {/* Keyed by menu shape: children added to an already-created Menu never reach the + native UIMenu, so late-loading models/efforts must remount the chip. */} + + + + {AgentKindSchema.options.map((option) => ( + + {AGENT_LABELS[option]} + + ))} + + + {/* A labeled submenu, not an inline group: a bare Picker between sibling menus drops + out of the native UIMenu entirely. */} + {models !== null && models.length > 0 ? ( + + + {t('defaultOption')} + {models.map((model) => ( + + {spansAccounts && model.description + ? `${model.label} — ${model.description}` + : model.label} + + ))} + + + ) : null} + {effortOptions !== undefined && effortOptions.length > 0 ? ( + + + {t('defaultOption')} + {effortOptions.map((option) => ( + + {option.label} + + ))} + + + ) : null} + + + + ); +} diff --git a/apps/mobile/src/components/host/new-thread/project-row.tsx b/apps/mobile/src/components/host/new-thread/project-row.tsx new file mode 100644 index 000000000..45c6ad16f --- /dev/null +++ b/apps/mobile/src/components/host/new-thread/project-row.tsx @@ -0,0 +1,95 @@ +import { Host, HStack, Image, Menu, Picker, Text as UIText } from '@expo/ui/swift-ui'; +import { + accessibilityLabel, + font, + foregroundStyle, + padding, + tag, + tint, +} from '@expo/ui/swift-ui/modifiers'; +import type { WorkspaceRecord } from '@linkcode/schema'; +import { repositoryLabel } from '@linkcode/ui/native'; +import { Color } from 'expo-router'; +import { useThemeColor } from 'heroui-native'; +import { FolderIcon } from 'lucide-react-native'; +import { TextInput, View } from 'react-native'; +import { useTranslations } from 'use-intl'; + +const ICON = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); +const VALUE = foregroundStyle({ type: 'hierarchical', style: 'primary' }); +const BODY = font({ textStyle: 'body' }); +/** SwiftUI draws a Menu label with the accent tint, and hierarchical styles derive from it — + * re-tinting to the label color turns the value primary and the icons secondary. */ +const LABEL_TINT = tint(Color.ios.label); + +/** The workspace picker as its own frame segment above the composer — outside the card, like the + * web composer's context bar: a bare row (icon + value + the up/down affordance) opening a native + * `UIMenu`. Falls back to a free path field while the host has no workspaces. */ +export function ProjectRow({ + workspaces, + workspaceLabel, + cwd, + onCwdChange, + customPath, + onCustomPathChange, +}: { + /** Already in recency order; the head is the default pick. */ + workspaces: WorkspaceRecord[]; + workspaceLabel: string; + cwd: string | null; + onCwdChange: (cwd: string) => void; + customPath: string; + onCustomPathChange: (path: string) => void; +}): React.ReactNode { + const t = useTranslations('mobile.sessions'); + const muted = useThemeColor('muted'); + + if (workspaces.length === 0) { + return ( + + + + + ); + } + + return ( + + + + + {workspaceLabel} + + + } + > + + {workspaces.map((workspace) => ( + + {workspace.name ?? repositoryLabel(workspace.cwd)} + + ))} + + + + + ); +} diff --git a/apps/mobile/src/runtime/__tests__/daemon-discovery.test.ts b/apps/mobile/src/runtime/__tests__/daemon-discovery.test.ts new file mode 100644 index 000000000..49eb062b4 --- /dev/null +++ b/apps/mobile/src/runtime/__tests__/daemon-discovery.test.ts @@ -0,0 +1,36 @@ +import { + canonicalDirectHostUrl, + formatDiscoveryUrl, + toDiscoveredDaemon, +} from '@mobile/runtime/daemon-discovery'; +import { describe, expect, it } from 'vitest'; + +describe('daemon discovery endpoints', () => { + it('formats DNS, IPv4, and IPv6 endpoints as Socket.IO origins', () => { + expect(formatDiscoveryUrl('studio.local.', 19523)).toBe('http://studio.local:19523'); + expect(formatDiscoveryUrl('192.168.1.8', 19523)).toBe('http://192.168.1.8:19523'); + expect(formatDiscoveryUrl('2001:db8::1234', 19523)).toBe('http://[2001:db8::1234]:19523'); + expect(formatDiscoveryUrl('fe80::1234%en0', 19523)).toBeUndefined(); + }); + + it('maps the native service identity without changing its display name', () => { + expect( + toDiscoveredDaemon({ + id: 'Studio|_linkcode._tcp|local', + name: 'Studio', + host: 'studio.local.', + port: 19523, + }), + ).toEqual({ + id: 'Studio|_linkcode._tcp|local', + name: 'Studio', + url: 'http://studio.local:19523', + }); + }); + + it('matches saved origins regardless of URL casing or a root slash', () => { + expect(canonicalDirectHostUrl('HTTP://Studio.Local:19523/')).toBe( + canonicalDirectHostUrl('http://studio.local:19523'), + ); + }); +}); diff --git a/apps/mobile/src/runtime/__tests__/initial-session-prompt.test.tsx b/apps/mobile/src/runtime/__tests__/initial-session-prompt.test.tsx new file mode 100644 index 000000000..c96703301 --- /dev/null +++ b/apps/mobile/src/runtime/__tests__/initial-session-prompt.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import type { SessionId } from '@linkcode/schema'; +import { queueInitialSessionPrompt } from '@mobile/runtime/initial-session-prompt'; +import { useSeededConversation } from '@mobile/runtime/use-seeded-conversation'; +import { useSessionActions } from '@mobile/runtime/use-session-actions'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { expect, it } from 'vitest'; +import { clientWrapper, connectClient } from './client-test-helpers'; + +const SESSION = 'session-1' as SessionId; + +it('attaches before sending once, preserves a rejected draft, and retries the same session', async () => { + const { client, transport } = await connectClient(); + queueInitialSessionPrompt(client, SESSION, 'Create a calculator'); + const Provider = clientWrapper(client); + const view = renderHook( + () => { + useSeededConversation(SESSION, null); + return useSessionActions(SESSION, 'idle'); + }, + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + await waitFor(() => + expect(transport.sent.some((frame) => frame.kind === 'agent.input')).toBe(true), + ); + const inputs = transport.sent.filter((frame) => frame.kind === 'agent.input'); + expect(inputs).toHaveLength(1); + expect(transport.sent[0]).toEqual({ kind: 'session.attach', sessionId: SESSION }); + expect(inputs[0].input).toEqual({ + type: 'prompt', + content: [{ type: 'text', text: 'Create a calculator' }], + }); + + act(() => + transport.receive({ + kind: 'request.failed', + replyTo: inputs[0].clientReqId, + message: 'Rejected', + }), + ); + await waitFor(() => expect(view.result.current.failure).toBe('send')); + expect(view.result.current.text).toBe('Create a calculator'); + expect(view.result.current.sending).toBe(false); + + act(() => view.result.current.send(view.result.current.text)); + const retry = transport.sent.filter((frame) => frame.kind === 'agent.input')[1]; + expect(retry.sessionId).toBe(SESSION); + expect(transport.sent.some((frame) => frame.kind === 'session.start')).toBe(false); + act(() => transport.receive({ kind: 'request.succeeded', replyTo: retry.clientReqId })); + await waitFor(() => expect(view.result.current.text).toBe('')); + expect(view.result.current.failure).toBeNull(); + view.unmount(); + client.dispose(); +}); + +it('does not clear text edited while the previous send is awaiting acknowledgment', async () => { + const { client, transport } = await connectClient(); + const view = renderHook(() => useSessionActions(SESSION, 'idle'), { + wrapper: clientWrapper(client), + }); + act(() => view.result.current.setText('First draft')); + act(() => view.result.current.send('First draft')); + const input = transport.sent.find((frame) => frame.kind === 'agent.input'); + expect(input).toBeDefined(); + act(() => { + view.result.current.setText('Next draft'); + transport.receive({ kind: 'request.succeeded', replyTo: input!.clientReqId }); + }); + await waitFor(() => expect(view.result.current.sending).toBe(false)); + expect(view.result.current.text).toBe('Next draft'); + view.unmount(); + client.dispose(); +}); diff --git a/apps/mobile/src/runtime/__tests__/new-thread-start-options.test.ts b/apps/mobile/src/runtime/__tests__/new-thread-start-options.test.ts new file mode 100644 index 000000000..d1f8baedd --- /dev/null +++ b/apps/mobile/src/runtime/__tests__/new-thread-start-options.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { buildStartOptions } from '../new-thread-start-options'; + +describe('buildStartOptions', () => { + it('sends only kind and cwd when nothing is picked', () => { + expect( + buildStartOptions('claude-code', '/repo', { + model: null, + effort: null, + approvalPolicyId: null, + }), + ).toEqual({ kind: 'claude-code', cwd: '/repo' }); + }); + + it('sends every explicit pick', () => { + expect( + buildStartOptions('codex', '/repo', { + model: { id: 'gpt-6.1-codex', accountId: 'acct-1' }, + effort: 'xhigh', + approvalPolicyId: 'full-auto', + }), + ).toEqual({ + kind: 'codex', + cwd: '/repo', + model: 'gpt-6.1-codex', + accountId: 'acct-1', + effort: 'xhigh', + approvalPolicyId: 'full-auto', + }); + }); + + it('omits accountId when the picked model does not name one', () => { + expect( + buildStartOptions('pi', '/repo', { + model: { id: 'some-model' }, + effort: null, + approvalPolicyId: null, + }), + ).toEqual({ kind: 'pi', cwd: '/repo', model: 'some-model' }); + }); +}); diff --git a/apps/mobile/src/runtime/daemon-discovery.ts b/apps/mobile/src/runtime/daemon-discovery.ts new file mode 100644 index 000000000..78d18e1b1 --- /dev/null +++ b/apps/mobile/src/runtime/daemon-discovery.ts @@ -0,0 +1,56 @@ +export interface NativeDiscoveredDaemon { + id: string; + name: string; + host: string; + port: number; +} + +export interface DiscoveredDaemon { + id: string; + name: string; + url: string; +} + +const rTrailingDot = /\.$/g; +const rUnescapedScope = /%(?!25)/g; +const rTrailingSlash = /\/$/g; + +function unbracketHost(host: string): string { + return host[0] === '[' && host.at(-1) === ']' ? host.slice(1, -1) : host; +} + +export function formatDiscoveryUrl(host: string, port: number): string | undefined { + const normalizedHost = unbracketHost(host.trim().replaceAll(rTrailingDot, '')); + const urlHost = normalizedHost.includes(':') + ? `[${normalizedHost.replaceAll(rUnescapedScope, '%25')}]` + : normalizedHost; + const url = `http://${urlHost}:${port}`; + try { + return new URL(url).hostname ? url : undefined; + } catch { + return undefined; + } +} + +export function toDiscoveredDaemon(host: NativeDiscoveredDaemon): DiscoveredDaemon | undefined { + const url = formatDiscoveryUrl(host.host, host.port); + if (!url) return undefined; + return { + id: host.id, + name: host.name, + url, + }; +} + +export function canonicalDirectHostUrl(url: string): string { + try { + const parsed = new URL(url); + parsed.hash = ''; + const serialized = parsed.href; + return parsed.pathname === '/' && !parsed.search + ? serialized.replaceAll(rTrailingSlash, '') + : serialized; + } catch { + return url; + } +} diff --git a/apps/mobile/src/runtime/initial-session-prompt.ts b/apps/mobile/src/runtime/initial-session-prompt.ts new file mode 100644 index 000000000..ac2d2481a --- /dev/null +++ b/apps/mobile/src/runtime/initial-session-prompt.ts @@ -0,0 +1,28 @@ +import type { LinkCodeClient } from '@linkcode/client-core'; +import type { SessionId } from '@linkcode/schema'; + +// Prompt text crosses the route boundary in memory, never in a deep link or navigation URL. +const prompts = new WeakMap>(); + +export function queueInitialSessionPrompt( + client: LinkCodeClient, + sessionId: SessionId, + text: string, +): void { + let pending = prompts.get(client); + if (!pending) { + pending = new Map(); + prompts.set(client, pending); + } + pending.set(sessionId, text); +} + +export function readInitialSessionPrompt(client: LinkCodeClient, sessionId: SessionId): string { + return prompts.get(client)?.get(sessionId) ?? ''; +} + +export function takeInitialSessionPrompt(client: LinkCodeClient, sessionId: SessionId): string { + const text = readInitialSessionPrompt(client, sessionId); + prompts.get(client)?.delete(sessionId); + return text; +} diff --git a/apps/mobile/src/runtime/new-thread-start-options.ts b/apps/mobile/src/runtime/new-thread-start-options.ts new file mode 100644 index 000000000..6ec943631 --- /dev/null +++ b/apps/mobile/src/runtime/new-thread-start-options.ts @@ -0,0 +1,25 @@ +import type { AgentKind, EffortLevel, StartOptions } from '@linkcode/schema'; + +export interface NewThreadPicks { + /** An explicitly picked model; `accountId` pins the session to the account it came from. */ + model: { id: string; accountId?: string } | null; + effort: EffortLevel | null; + approvalPolicyId: string | null; +} + +/** Only explicit picks travel. A displayed default submitted as if chosen would override the + * agent's own startup resolution (claude's `permissions.defaultMode`, codex's `config.toml`). */ +export function buildStartOptions( + kind: AgentKind, + cwd: string, + picks: NewThreadPicks, +): StartOptions { + return { + kind, + cwd, + ...(picks.model && { model: picks.model.id }), + ...(picks.model?.accountId !== undefined && { accountId: picks.model.accountId }), + ...(picks.effort && { effort: picks.effort }), + ...(picks.approvalPolicyId !== null && { approvalPolicyId: picks.approvalPolicyId }), + }; +} diff --git a/apps/mobile/src/runtime/use-account-models.ts b/apps/mobile/src/runtime/use-account-models.ts new file mode 100644 index 000000000..2983e67a8 --- /dev/null +++ b/apps/mobile/src/runtime/use-account-models.ts @@ -0,0 +1,42 @@ +import { useLinkCodeClient } from '@linkcode/client-core'; +import { enabledAccountModels } from '@linkcode/providers'; +import type { Accounts, AgentKind, ProvidersConfig } from '@linkcode/schema'; +import type { ModelOption } from '@linkcode/ui/native'; +import { noop } from 'foxact/noop'; +import { useEffect } from 'foxact/use-abortable-effect'; +import { useState } from 'react'; + +/** Account-backed model options for one agent; null until both daemon-owned sources load, so the + * picker never briefly offers a set the enabled list would have narrowed. The head of the list is + * what an unpicked start runs on. The agent's own catalog models are deliberately not offered — a + * model nobody enabled an account for is not on offer (same rule as the desktop draft picker). */ +export function useAccountModels(kind: AgentKind | null): ModelOption[] | null { + const client = useLinkCodeClient(); + const [sources, setSources] = useState<{ + accounts: Accounts; + providers: ProvidersConfig; + } | null>(null); + + useEffect( + (signal) => { + Promise.all([client.getAccounts(), client.getProviderConfig()]) + .then(([accounts, providers]) => { + if (!signal.aborted) setSources({ accounts, providers }); + }) + .catch(noop); + }, + [client], + ); + + if (!sources || kind === null) return null; + // `description` carries the account label and `accountId` rides along, so a pick names the + // account it came from — two accounts can legitimately serve the same model id. + return enabledAccountModels(sources.accounts, sources.providers, kind).map( + ({ account, model }) => ({ + id: model.id, + label: model.label ?? model.id, + description: account.label, + accountId: account.id, + }), + ); +} diff --git a/apps/mobile/src/runtime/use-agent-start-catalog.ts b/apps/mobile/src/runtime/use-agent-start-catalog.ts new file mode 100644 index 000000000..d40e15ac2 --- /dev/null +++ b/apps/mobile/src/runtime/use-agent-start-catalog.ts @@ -0,0 +1,32 @@ +import { useLinkCodeClient } from '@linkcode/client-core'; +import type { AgentKind, AgentStartCatalog } from '@linkcode/schema'; +import { noop } from 'foxact/noop'; +import { useEffect } from 'foxact/use-abortable-effect'; +import { useState } from 'react'; + +/** Pre-session capability catalog for one agent in one workspace; null while loading or when the + * host cannot answer. `cwd` is load-bearing: adapters resolve workspace-scoped defaults from it + * (claude-code reads `.claude/settings*.json`), so every (kind, cwd) pair refetches. */ +export function useAgentStartCatalog( + kind: AgentKind, + cwd: string | null, +): AgentStartCatalog | null { + const client = useLinkCodeClient(); + const [entry, setEntry] = useState<{ key: string; catalog: AgentStartCatalog } | null>(null); + + const key = `${kind}:${cwd ?? ''}`; + useEffect( + (signal) => { + client + .getAgentCatalog(kind, cwd ?? undefined) + .then((catalog) => { + if (!signal.aborted) setEntry({ key, catalog }); + }) + .catch(noop); + }, + [client, kind, cwd, key], + ); + + // Keyed so a stale catalog never answers for the wrong (kind, cwd) while the next one loads. + return entry?.key === key ? entry.catalog : null; +} diff --git a/apps/mobile/src/runtime/use-daemon-discovery.ts b/apps/mobile/src/runtime/use-daemon-discovery.ts new file mode 100644 index 000000000..4023cf55e --- /dev/null +++ b/apps/mobile/src/runtime/use-daemon-discovery.ts @@ -0,0 +1,44 @@ +import type { DiscoveredDaemon } from '@mobile/runtime/daemon-discovery'; +import { toDiscoveredDaemon } from '@mobile/runtime/daemon-discovery'; +import { useFocusEffect } from 'expo-router'; +import { useCallback, useState } from 'react'; +import type { + DaemonDiscoveryError, + DaemonDiscoverySnapshot, + DaemonDiscoveryStatus, +} from '../../modules/linkcode-daemon-discovery'; +import LinkCodeDaemonDiscovery from '../../modules/linkcode-daemon-discovery'; + +const INITIAL_SNAPSHOT: DaemonDiscoverySnapshot = { + status: 'searching', + hosts: [], +}; + +export interface DaemonDiscoveryState { + status: DaemonDiscoveryStatus; + hosts: DiscoveredDaemon[]; + error?: DaemonDiscoveryError; +} + +export function useDaemonDiscovery(): DaemonDiscoveryState { + const [snapshot, setSnapshot] = useState(INITIAL_SNAPSHOT); + useFocusEffect( + useCallback(() => { + setSnapshot(INITIAL_SNAPSHOT); + const subscription = LinkCodeDaemonDiscovery.addListener('onHostsChanged', setSnapshot); + return () => subscription.remove(); + }, []), + ); + const hosts: DiscoveredDaemon[] = []; + for (let i = 0, len = snapshot.hosts.length; i < len; i++) { + const nativeHost = snapshot.hosts[i]; + const host = toDiscoveredDaemon(nativeHost); + if (host) hosts.push(host); + } + + return { + status: snapshot.status, + hosts, + error: snapshot.error, + }; +} diff --git a/apps/mobile/src/runtime/use-new-thread-draft.ts b/apps/mobile/src/runtime/use-new-thread-draft.ts new file mode 100644 index 000000000..6e6e6f8f8 --- /dev/null +++ b/apps/mobile/src/runtime/use-new-thread-draft.ts @@ -0,0 +1,192 @@ +import { useLinkCodeClient, useSessions } from '@linkcode/client-core'; +import type { AgentKind, EffortLevel, SessionId } from '@linkcode/schema'; +import { AgentKindSchema } from '@linkcode/schema'; +import { + AGENT_LABELS, + EFFORT_OPTIONS_BY_ID, + effortOptionsForModel, + modelChoiceKey, + repositoryLabel, + resolveModel, +} from '@linkcode/ui/native'; +import { queueInitialSessionPrompt } from '@mobile/runtime/initial-session-prompt'; +import { buildStartOptions } from '@mobile/runtime/new-thread-start-options'; +import { captureMobileProductEvent } from '@mobile/runtime/product-analytics'; +import { useAccountModels } from '@mobile/runtime/use-account-models'; +import { useAgentStartCatalog } from '@mobile/runtime/use-agent-start-catalog'; +import { useWorkspaces } from '@mobile/runtime/use-workspaces'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { useState } from 'react'; +import { useTranslations } from 'use-intl'; + +/** Explicit picks, each remembering the agent (and list) it was made for so a kind switch + * invalidates them by derivation instead of effects. */ +interface ModelPick { + kind: AgentKind; + key: string; + id: string; + accountId?: string; + label: string; +} + +export function useNewThreadDraft(onCreated: (sessionId: SessionId) => void) { + const t = useTranslations('mobile.sessions'); + const client = useLinkCodeClient(); + const { create } = useSessions(); + const { workspaces } = useWorkspaces(); + const [text, setText] = useState(''); + + const [kind, setKind] = useState(AgentKindSchema.options[0]); + const [selectedCwd, setSelectedCwd] = useState(null); + const [customPath, setCustomPath] = useState(''); + const [modelPick, setModelPick] = useState(null); + const [effortPick, setEffortPick] = useState<{ kind: AgentKind; effort: EffortLevel } | null>( + null, + ); + const [policyPick, setPolicyPick] = useState<{ kind: AgentKind; policyId: string } | null>(null); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + // Recency order mirrors the thread groups; the most recent project is the default pick. + const ordered = [...workspaces].sort((a, b) => b.lastUsedAt - a.lastUsedAt); + // eslint-disable-next-line vibe-proof/react-no-performance-impacting-array-find -- one lookup against a short workspace list; the Map the rule asks for costs the same walk to build each render + const pickedWorkspace = ordered.find((workspace) => workspace.cwd === selectedCwd); + const selectedWorkspace = pickedWorkspace ?? ordered.at(0); + const cwd = selectedWorkspace?.cwd ?? null; + + const target = cwd ?? customPath.trim(); + const catalog = useAgentStartCatalog(kind, target || null); + const models = useAccountModels(kind); + + // Desktop draft rules apply throughout: every catalog default is a display value; only an + // explicit pick survives to the wire, and a pick made for another agent never leaks over. + const pickedModel = modelPick?.kind === kind ? modelPick : null; + const headModel = models?.at(0); + + // Effort follows the model the session will actually run on; the axis stays truthful even + // when nothing here is offered. + const effortModelId = pickedModel?.id ?? headModel?.id ?? catalog?.defaultModel ?? null; + const catalogModel = resolveModel(catalog?.models, effortModelId); + const effortOptions = effortOptionsForModel(kind, catalogModel); + const pickedEffort = effortPick?.kind === kind ? effortPick.effort : null; + const constrainedEffort = + pickedEffort !== null && effortOptions?.some((option) => option.id === pickedEffort) + ? pickedEffort + : null; + // A catalog effort paired with a default model belongs to that model; once something else picks + // the model, that model's own advertised default is the honest value. + const catalogEffort = + catalog?.defaultModel === undefined || catalog.defaultModel === effortModelId + ? catalog?.defaultEffort + : catalogModel?.defaultEffort; + const displayedEffort = + constrainedEffort ?? + (catalogEffort !== undefined && effortOptions?.some((option) => option.id === catalogEffort) + ? catalogEffort + : null); + + // The selector chip reads like the desktop trigger: the running model plus its effort (short + // form — chip width is precious), or the harness name while no account backs a model list. + const displayedModelLabel = pickedModel?.label ?? headModel?.label ?? catalog?.defaultModel; + const selectorValue = [ + displayedModelLabel ?? AGENT_LABELS[kind], + ...(displayedEffort ? [EFFORT_OPTIONS_BY_ID[displayedEffort].shortLabel] : []), + ].join(' · '); + + const policies = catalog?.policies ?? []; + const pickedPolicyId = + policyPick?.kind === kind && policies.some((policy) => policy.policyId === policyPick.policyId) + ? policyPick.policyId + : null; + const displayedPolicyId = + pickedPolicyId ?? catalog?.defaultPolicyId ?? policies.at(0)?.policyId ?? null; + // eslint-disable-next-line vibe-proof/react-no-performance-impacting-array-find -- one lookup against a handful of policies per render + const displayedPolicy = policies.find((policy) => policy.policyId === displayedPolicyId); + const policyValue = displayedPolicy?.name ?? t('defaultOption'); + + const onModelKeyChange = (key: string | null): void => { + if (key === null) { + setModelPick(null); + return; + } + const option = models?.find((model) => modelChoiceKey(model) === key); + if (option) { + setModelPick({ kind, key, id: option.id, accountId: option.accountId, label: option.label }); + } + }; + + const onEffortChange = (value: string | null): void => { + const option = effortOptions?.find((candidate) => candidate.id === value); + setEffortPick(option ? { kind, effort: option.id } : null); + }; + + const onPolicyIdChange = (policyId: string | null): void => { + setPolicyPick(policyId === null ? null : { kind, policyId }); + }; + + const start = async (text: string) => { + if (!target || creating) return; + const startedAt = Date.now(); + setCreating(true); + setCreateError(null); + try { + let sessionId: SessionId; + try { + sessionId = await create( + buildStartOptions(kind, target, { + model: pickedModel, + effort: constrainedEffort, + approvalPolicyId: pickedPolicyId, + }), + ); + captureMobileProductEvent('thread created', { + agent_kind: kind, + duration_ms: Date.now() - startedAt, + }); + } catch (error) { + captureMobileProductEvent('thread create failed', { + agent_kind: kind, + duration_ms: Date.now() - startedAt, + }); + throw error; + } + queueInitialSessionPrompt(client, sessionId, text); + onCreated(sessionId); + } catch (error) { + setCreateError(extractErrorMessage(error, false) ?? 'Unknown error'); + } finally { + setCreating(false); + } + }; + + return { + text, + setText, + start, + creating, + sendBlocked: target.length === 0, + error: createError ? t('createError', { error: createError }) : undefined, + project: { + workspaces: ordered, + workspaceLabel: selectedWorkspace + ? (selectedWorkspace.name ?? repositoryLabel(selectedWorkspace.cwd)) + : t('projectLabel'), + cwd, + onCwdChange: setSelectedCwd, + customPath, + onCustomPathChange: setCustomPath, + }, + approval: { policies, policyId: pickedPolicyId, policyValue, onPolicyIdChange }, + selector: { + kind, + onKindChange: setKind, + models, + modelKey: pickedModel?.key ?? null, + onModelKeyChange, + selectorValue, + effortOptions, + effort: constrainedEffort, + onEffortChange, + }, + }; +} diff --git a/apps/mobile/src/runtime/use-session-actions.ts b/apps/mobile/src/runtime/use-session-actions.ts index 6867ea712..ee5a3e8f2 100644 --- a/apps/mobile/src/runtime/use-session-actions.ts +++ b/apps/mobile/src/runtime/use-session-actions.ts @@ -1,16 +1,18 @@ import { useLinkCodeClient } from '@linkcode/client-core'; import type { + EffortLevel, PermissionOutcome, QuestionOutcome, SessionId, SessionStatus, } from '@linkcode/schema'; import { useSet } from 'foxact/use-set'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { readInitialSessionPrompt, takeInitialSessionPrompt } from './initial-session-prompt'; /** Which composer action failed. Failures carry no message: the daemon's reasons are not * user-actionable here, and the caller owns the copy. */ -export type SessionActionFailure = 'send' | 'stop'; +export type SessionActionFailure = 'send' | 'stop' | 'control'; export interface SessionActions { /** The turn is in flight, so the composer's single action should read as stop, not send. */ @@ -18,12 +20,20 @@ export interface SessionActions { /** False when there is no live session to prompt — a cold or stopped thread. */ readonly canCompose: boolean; readonly failure: SessionActionFailure | null; + readonly text: string; + readonly setText: (text: string) => void; + readonly sending: boolean; /** Ask ids with a response in flight. */ readonly respondingIds: ReadonlySet; /** Ask ids whose last response failed. */ readonly failedResponseIds: ReadonlySet; readonly send: (text: string) => void; readonly stop: () => void; + /** Live control switches; the pick reflects back via the session's `*-update` events, so a + * rejected switch simply leaves the previous value showing. */ + readonly setModel: (model: { id: string; accountId?: string }) => void; + readonly setEffort: (effort: EffortLevel) => void; + readonly setApprovalPolicy: (policyId: string) => void; readonly respondPermission: (requestId: string, outcome: PermissionOutcome) => void; readonly respondQuestion: (requestId: string, outcome: QuestionOutcome) => void; } @@ -40,21 +50,75 @@ export function useSessionActions( const [respondingIds, addResponding, removeResponding] = useSet(); const [failedResponseIds, addFailedResponse, removeFailedResponse] = useSet(); const [failure, setFailure] = useState(null); + const [text, setText] = useState(() => + sessionId ? readInitialSessionPrompt(client, sessionId) : '', + ); + const [sending, setSending] = useState(() => + Boolean(sessionId && readInitialSessionPrompt(client, sessionId)), + ); + + const dispatch = useCallback( + (text: string) => { + if (!sessionId) return; + client + .promptText(sessionId, text) + .then(() => setText((current) => (current.trim() === text ? '' : current))) + .catch(() => setFailure('send')) + .finally(() => setSending(false)); + }, + [client, sessionId], + ); const send = useCallback( (text: string) => { if (!sessionId) return; setFailure(null); - client.promptText(sessionId, text).catch(() => setFailure('send')); + setSending(true); + dispatch(text); }, - [client, sessionId], + [dispatch, sessionId], ); + // The screen calls useSeededConversation first, so its attach effect precedes this dispatch. + // Taking the handoff in an effect prevents Strict Mode's render/replay from sending it twice. + useEffect(() => { + if (!sessionId) return; + const initial = takeInitialSessionPrompt(client, sessionId); + if (initial) dispatch(initial); + }, [client, dispatch, sessionId]); + const stop = useCallback(() => { if (!sessionId) return; client.cancel(sessionId).catch(() => setFailure('stop')); }, [client, sessionId]); + const setModel = useCallback( + (model: { id: string; accountId?: string }) => { + if (!sessionId) return; + setFailure(null); + client.setModel(sessionId, model.id, model.accountId).catch(() => setFailure('control')); + }, + [client, sessionId], + ); + + const setEffort = useCallback( + (effort: EffortLevel) => { + if (!sessionId) return; + setFailure(null); + client.setEffort(sessionId, effort).catch(() => setFailure('control')); + }, + [client, sessionId], + ); + + const setApprovalPolicy = useCallback( + (policyId: string) => { + if (!sessionId) return; + setFailure(null); + client.setApprovalPolicy(sessionId, policyId).catch(() => setFailure('control')); + }, + [client, sessionId], + ); + const respond = useCallback( (requestId: string, send_: () => Promise) => { removeFailedResponse(requestId); @@ -86,10 +150,16 @@ export function useSessionActions( isRunning: status === 'running' || status === 'starting', canCompose: sessionId !== null && status !== null && status !== 'stopped', failure, + text, + setText, + sending, respondingIds, failedResponseIds, send, stop, + setModel, + setEffort, + setApprovalPolicy, respondPermission, respondQuestion, }; diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index d22f68f85..7ca194b4b 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1251,7 +1251,12 @@ export const en = { open: 'Open', remove: 'Remove', viaTunnel: 'via LinkCode Cloud', - addManually: 'Add a host by URL', + addManually: 'Other…', + discovery: { + title: 'Discovered hosts', + searching: 'Searching the local network…', + error: 'Could not search the local network.', + }, cloud: { title: 'LinkCode Cloud', hint: 'Sign in to reach your machines from anywhere through LinkCode Cloud.', @@ -1268,7 +1273,7 @@ export const en = { 'Your agents, wherever you are. Sign in to reach the machines running LinkCode from this phone.', signIn: 'Sign in with LinkCode', other: 'More sign-in options', - skip: 'Skip — connect manually', + skip: 'Skip and connect manually', error: 'Sign-in failed. Check your connection and try again.', }, account: { @@ -1331,9 +1336,14 @@ export const en = { otherThreads: 'Other threads', projectLabel: 'Project', kindLabel: 'Agent', + modelLabel: 'Model', + effortLabel: 'Effort', + approvalLabel: 'Permission mode', + defaultOption: 'Default', cwdLabel: 'Working directory', cwdPlaceholder: '/absolute/path/on/the/host', create: 'Start', + createError: 'Unable to start the thread: {error}', terminals: 'Terminals', settings: 'Settings', status: { @@ -1383,6 +1393,7 @@ export const en = { send: 'Send', stop: 'Stop', sendError: 'Could not send the message. Try again.', + controlError: 'Could not apply the change.', stopError: 'Could not stop the turn. Try again.', reasoning: 'Reasoning', plan: 'Plan', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index c90072b15..badd487a0 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1217,7 +1217,12 @@ export const zhCN = { open: '打开', remove: '移除', viaTunnel: '经由 LinkCode Cloud', - addManually: '通过地址添加 host', + addManually: '其他…', + discovery: { + title: '发现的 host', + searching: '正在搜索局域网…', + error: '无法搜索局域网。', + }, cloud: { title: 'LinkCode Cloud', hint: '登录后即可通过 LinkCode Cloud 随时随地连接你的机器。', @@ -1232,7 +1237,7 @@ export const zhCN = { tagline: '你的智能体,随身可达。登录后即可从这台手机连接运行 LinkCode 的机器。', signIn: '使用 LinkCode 登录', other: '其他登录方式', - skip: '跳过,手动连接', + skip: '跳过并手动连接', error: '登录失败,请检查网络后重试。', }, account: { @@ -1287,9 +1292,14 @@ export const zhCN = { otherThreads: '其他线程', projectLabel: '项目', kindLabel: '智能体', + modelLabel: '模型', + effortLabel: '推理强度', + approvalLabel: '权限模式', + defaultOption: '默认', cwdLabel: '工作目录', cwdPlaceholder: '/host 上的绝对路径', create: '启动', + createError: '无法启动线程:{error}', terminals: '终端', settings: '设置', status: { @@ -1339,6 +1349,7 @@ export const zhCN = { send: '发送', stop: '停止', sendError: '消息没能发出去,请重试。', + controlError: '无法应用更改。', stopError: '没能停止这一轮,请重试。', reasoning: '思考', plan: '计划', diff --git a/packages/presentation/ui/src/__tests__/agent-models.test.ts b/packages/presentation/ui/src/__tests__/agent-models.test.ts index c37c6c94d..a1199559e 100644 --- a/packages/presentation/ui/src/__tests__/agent-models.test.ts +++ b/packages/presentation/ui/src/__tests__/agent-models.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { effortOptionsForModel } from '../shell/agent-efforts'; -import type { ModelOption } from '../shell/agent-models'; -import { groupModelsByProvider, resolveModel, switchesAccount } from '../shell/agent-models'; +import { effortOptionsForModel } from '../agent-efforts'; +import type { ModelOption } from '../agent-models'; +import { groupModelsByProvider, resolveModel, switchesAccount } from '../agent-models'; // Ids and aliases straight from `CURATED_AGENT_MODELS`; the prefix rules under test are about the // shape of the ids a provider serves, not about where the list came from. diff --git a/packages/presentation/ui/src/shell/agent-efforts.ts b/packages/presentation/ui/src/agent-efforts.ts similarity index 100% rename from packages/presentation/ui/src/shell/agent-efforts.ts rename to packages/presentation/ui/src/agent-efforts.ts diff --git a/packages/presentation/ui/src/shell/agent-models.ts b/packages/presentation/ui/src/agent-models.ts similarity index 100% rename from packages/presentation/ui/src/shell/agent-models.ts rename to packages/presentation/ui/src/agent-models.ts diff --git a/packages/presentation/ui/src/native/index.ts b/packages/presentation/ui/src/native/index.ts index d011658dd..59233841b 100644 --- a/packages/presentation/ui/src/native/index.ts +++ b/packages/presentation/ui/src/native/index.ts @@ -1,4 +1,8 @@ +export type { EffortOption } from '../agent-efforts'; +export { EFFORT_OPTIONS_BY_ID, effortOptionsForModel } from '../agent-efforts'; export { AGENT_INITIALS, AGENT_LABELS } from '../agent-meta'; +export type { ModelOption } from '../agent-models'; +export { groupModelsByProvider, modelChoiceKey, resolveModel } from '../agent-models'; export { stripAnsi } from '../ansi'; export type { CurrentPlan, PromptConversationItem } from '../chat/conversation-prompts'; export { selectCurrentPlan, selectPendingPromptItems } from '../chat/conversation-prompts'; diff --git a/packages/presentation/ui/src/shell/composer-controls.tsx b/packages/presentation/ui/src/shell/composer-controls.tsx index 63d92e59d..3622bcbb6 100644 --- a/packages/presentation/ui/src/shell/composer-controls.tsx +++ b/packages/presentation/ui/src/shell/composer-controls.tsx @@ -26,16 +26,16 @@ import { TargetIcon, } from 'lucide-react'; import { useTranslations } from 'use-intl'; -import { AGENT_LABELS, AgentIcon } from '../chat/agent-icon'; -import type { EffortOption } from './agent-efforts'; -import { EFFORT_OPTIONS_BY_ID } from './agent-efforts'; -import type { ModelOption } from './agent-models'; +import type { EffortOption } from '../agent-efforts'; +import { EFFORT_OPTIONS_BY_ID } from '../agent-efforts'; +import type { ModelOption } from '../agent-models'; import { groupModelsByProvider, modelChoiceKey, resolveModel, switchesAccount, -} from './agent-models'; +} from '../agent-models'; +import { AGENT_LABELS, AgentIcon } from '../chat/agent-icon'; import type { AgentRuntimeCue, AgentRuntimeCues } from './agent-onboarding-card'; // Linear lookup: the policy/effort lists are a handful of entries at most. diff --git a/packages/presentation/ui/src/shell/composer.tsx b/packages/presentation/ui/src/shell/composer.tsx index 4a19e75fd..c1384a2ab 100644 --- a/packages/presentation/ui/src/shell/composer.tsx +++ b/packages/presentation/ui/src/shell/composer.tsx @@ -22,6 +22,9 @@ import { ShieldIcon } from 'lucide-react'; import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import { useId, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'use-intl'; +import { effortOptionsForModel } from '../agent-efforts'; +import type { ModelOption } from '../agent-models'; +import { resolveModel } from '../agent-models'; import type { ChatAttachment } from '../chat/attachments'; import { Attachments } from '../chat/attachments'; import { @@ -31,9 +34,6 @@ import { PromptInputTools, } from '../chat/prompt-input'; import { cn } from '../lib/cn'; -import { effortOptionsForModel } from './agent-efforts'; -import type { ModelOption } from './agent-models'; -import { resolveModel } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import type { ComposerAttachment } from './composer-attachments'; import { diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index 22ea9aa06..cd4e71bd5 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -1,6 +1,7 @@ import type { AgentKind, ContentBlock, EffortLevel, QuestionOutcome } from '@linkcode/schema'; import { useRef } from 'react'; import type { StickToBottomContext } from 'use-stick-to-bottom'; +import type { ModelOption } from '../agent-models'; import { ArtifactHostActionsProvider } from '../chat/artifacts/context'; import { CommandCatalogProvider } from '../chat/command-brand'; import type { PermissionDecision } from '../chat/conversation-prompts'; @@ -8,7 +9,6 @@ import { selectPendingPromptItems } from '../chat/conversation-prompts'; import { ConversationView } from '../chat/conversation-view'; import type { ConversationViewModel, PromptEditState } from '../chat/types'; import { cn } from '../lib/cn'; -import type { ModelOption } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; import type { ComposerDirectiveControls, ComposerHandle, MentionItem } from './composer'; diff --git a/packages/presentation/ui/src/shell/index.ts b/packages/presentation/ui/src/shell/index.ts index c6fc5e94c..38d88b50d 100644 --- a/packages/presentation/ui/src/shell/index.ts +++ b/packages/presentation/ui/src/shell/index.ts @@ -1,5 +1,5 @@ -export * from './agent-efforts'; -export * from './agent-models'; +export * from '../agent-efforts'; +export * from '../agent-models'; export * from './agent-onboarding-card'; export * from './appearance-settings-panel'; export * from './billing-settings-panel'; diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index 15cb6d300..0a13b4b99 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -32,11 +32,11 @@ import { } from 'lucide-react'; import { useState } from 'react'; import { useTranslations } from 'use-intl'; +import type { ModelOption } from '../agent-models'; +import { resolveModel } from '../agent-models'; import { AGENT_LABELS } from '../chat/agent-icon'; import { cn } from '../lib/cn'; import { repositoryLabel } from '../repository-label'; -import type { ModelOption } from './agent-models'; -import { resolveModel } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; import type { ComposerDirectiveControls, MentionItem } from './composer'; diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 390a6839d..fcd10dc70 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -9,9 +9,9 @@ import type { WorkspaceId, WorkspaceRecord, } from '@linkcode/schema'; +import type { ModelOption } from '../agent-models'; import type { ConversationViewModel } from '../chat'; import type { PermissionDecision } from '../chat/conversation-prompts'; -import type { ModelOption } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import type { MentionItem } from './composer'; import type { ConversationComposerController } from './conversation-surface'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51575166a..9fcb0df18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -458,6 +458,9 @@ importers: '@linkcode/i18n': specifier: workspace:* version: link:../../packages/presentation/i18n + '@linkcode/providers': + specifier: workspace:* + version: link:../../packages/foundation/providers '@linkcode/schema': specifier: workspace:* version: link:../../packages/foundation/schema