Triage
The two apps are not being treated as one device. The stuck reconnect and the Devices row are different systems, and removing that row does not touch the host session you checked.
apps/swift-ios is only on t3code/rebuild-mobile-app-swift (157476f1fb, #5178), not on main. With a T3 account signed in, Devices does not list host auth_sessions. managesServerSessions is false in that case, and the screen loads relay push registrations instead.
| 1 | var managesServerSessions: Bool { |
| 2 | !t3ConnectDeviceManager.hasActiveAccount |
| 3 | } |
| 4 | |
| 5 | func loadDeviceSessions() async throws -> [FeatureDeviceSession] { |
| 6 | if t3ConnectDeviceManager.hasActiveAccount { |
| 7 | let devices = try await t3ConnectDeviceManager.registeredDevices() |
| 8 | relayDeviceSessionIDs = Set(devices.map(\.deviceId)) |
| 9 | return devices.map { |
| 10 | FeatureDeviceSession( |
| 11 | relayDevice: $0, |
| 12 | currentDeviceID: t3ConnectDeviceManager.currentRegisteredDeviceID |
| 13 | ) |
| 14 | } |
| 15 | } |
Remove then calls DELETE /v1/mobile/devices/:deviceId. That deletes the relay row only. It does not revoke the desktop pairing, which matches auth_sessions staying live for both 0.1.0 and 1.3.0. Host session revoke is the other branch, used only when there is no signed-in T3 account. The confirmation copy in that account-signed-in path says the device will stop receiving T3 Connect notifications.
The two "iPhone" rows are two installations. SwiftUI stores its id as a UUID in the keychain service com.t3tools.t3code.swiftui.installation (PlatformInstallationIdentity). The iOS app stores a different UUID in its own secure storage (loadOrCreateAgentAwarenessDeviceId). The relay primary key is (userId, deviceId), so those stay two rows. Both titles are the platform device name (UIDevice.current.name and Constants.deviceName). On a current iPhone that string is often just "iPhone" for every app from this vendor. The subtitle is where the version shows up (iOS 27 · T3 Code 1.3.0 versus 0.1.0), when the registration included appVersion.
"Last seen 5 hours ago" is also that relay row, not the host connection. The mapping copies updatedAt into lastConnectedAt and hardcodes isConnected: false, so the row can never say "Active now":
| 1 | init(relayDevice: T3ConnectRelayDevice, currentDeviceID: String?) { |
| 2 | let updatedAt = Self.t3ConnectRelayDate(relayDevice.updatedAt) |
| 3 | self.init( |
| 4 | sessionID: relayDevice.deviceId, |
| 5 | label: relayDevice.label, |
| 6 | ... |
| 7 | lastConnectedAt: updatedAt, |
| 8 | isConnected: false, |
| 9 | isCurrent: relayDevice.deviceId == currentDeviceID |
| 10 | ) |
| 11 | } |
updatedAt moves only when a registration is written. The iOS app skips that request when the payload fingerprint is unchanged, including across launches that are actively talking to the desktop:
| 1 | if ( |
| 2 | persisted && |
| 3 | persisted.identity === identity && |
| 4 | persisted.signature === signature && |
| 5 | !needsAndroidReplay |
| 6 | ) { |
| 7 | setRegistrationStatus("registered"); |
| 8 | logRegistrationDebug("relay device registration skipped; already registered for account", { |
| 9 | expectedGeneration, |
| 10 | }); |
| 11 | return; |
| 12 | } |
So a minute-old auth_sessions connection next to a hours-old Devices "last seen" is what this screen shows today. It is not evidence that one install replaced the other. After a successful remove, the 1.3.0 app will not put the row back until that fingerprint changes (app version, push token, notification prefs, relay URL). If the row is present again on the next nightly with none of those changed, that part is new.
The reconnect stall is not explained by this list. Nothing in the direct Tailscale socket path reads relay devices. SwiftUI also has no "Syncing threads" string. That copy is the React Native home, when a shell snapshot is still synchronizing and one is already cached (workspace-connection-status.ts). On SwiftUI the matching UI is the sidebar "{environment} reconnecting", or "Reconnecting..." / "Catching up..." on a thread. HTTP fallback loads a shell without leaving that state. consumeFallbackShell publishes with markSourceConnected: false, and only a websocket shell-event batch calls emitConnection(.connected). A socket that never delivers a batch after the desktop restart will sit on "reconnecting" even though HTTP already has threads.
Please reply with three things so we can separate a socket bug from the Devices row:
- The exact SwiftUI string while it is stuck (sidebar "reconnecting", "Catching up...", "Reconnecting...", or something else), and whether the thread list is already filled in behind it.
- Whether it recovers if you open Devices and pull to refresh without removing the other row. Removing it unregisters the 1.3.0 app from T3 Connect pushes, so it is worth not using that as the test.
- A host trace slice for the 0.1.0 client during the stuck window: websocket subscribe versus
shellSnapshotHTTP, and whether those requests are hanging or failing.