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 (
+
+ );
+}
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 ? (
+
+ ) : null}
+ {hasEfforts ? (
+
+ ) : 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. */}
-
-
-
-
- );
-}
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. */}
+
+
+ {/* 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 ? (
+
+ ) : null}
+ {effortOptions !== undefined && effortOptions.length > 0 ? (
+
+ ) : 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 (
+
+
+
+
+
+ );
+}
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