diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 92821b0..e418c6d 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -195,6 +195,7 @@ final class RemoteStore: ObservableObject { guard !fresh.isEmpty else { return } castLedger = ledger activityFeed = ActivityFeedItem.merged(activityFeed, adding: fresh) + updateComposerTyping(fresh) castPersistTask?.cancel() castPersistTask = Task { [ledger] in try? await Task.sleep(for: .milliseconds(500)) @@ -202,6 +203,123 @@ final class RemoteStore: ObservableObject { await CastCache.save(ledger) } } + + // MARK: - Live composer streaming (the "composer.typing" channel) + + /// The live remote typer per session (mesh composer streaming) — folded from + /// `composer.typing` casts, *other* devices only (this phone's own typing, republished by + /// the session's host, is skipped so the typer never locks itself). The session detail + /// locks its composer and renders the streamed draft while an entry is present; + /// expiry-guarded locally so a typer that vanished without its tombstone can't leave the + /// composer locked. + @Published private(set) var composerTypingBySession: [SessionID: ComposerTypingState] = [:] + /// Watcher-side expiry timers for `composerTypingBySession` (the crash guard). + private var composerExpiryTasks: [SessionID: Task] = [:] + /// Sender-side throttle + idle bookkeeping for streaming THIS device's drafts: the + /// trailing-edge send, the last send time, the newest not-yet-sent draft, the idle-stop + /// timer, and which sessions have an active report out (so `ended` sends exactly one stop). + private var composerSendTasks: [SessionID: Task] = [:] + private var composerLastSentAt: [SessionID: Date] = [:] + private var composerPendingText: [SessionID: String] = [:] + private var composerIdleTasks: [SessionID: Task] = [:] + private var composerStreamingSessions: Set = [] + + /// The composer's draft for `sessionID` changed on this phone — report it (throttled) to + /// the session's host, which republishes it on the `composer.typing` channel for the rest + /// of the mesh, and (re)arm the idle stop. An emptied field ends the typing immediately. + /// A no-op toward a host that never advertised `canStreamComposer` (an older host would + /// throw on the unknown tag). + func composerDraftChanged(_ sessionID: SessionID, text: String) { + guard !demoMode else { return } + guard !text.isEmpty else { composerDraftEnded(sessionID); return } + guard let conn = connection(owningSession: sessionID), conn.connectivity.isLive, + conn.capabilities.canStreamComposer else { return } + composerPendingText[sessionID] = text + scheduleComposerIdleStop(sessionID) + guard composerSendTasks[sessionID] == nil else { return } // trailing edge armed + let elapsed = Date().timeIntervalSince(composerLastSentAt[sessionID] ?? .distantPast) + let delay = max(0, ComposerTypingState.minPublishInterval - elapsed) + composerSendTasks[sessionID] = Task { [weak self] in + if delay > 0 { try? await Task.sleep(for: .seconds(delay)) } + guard !Task.isCancelled else { return } + self?.flushComposerDraft(sessionID) + } + } + + /// Typing for `sessionID` finished on this phone — sent, cleared, or the detail closed. + /// Sends the stop that tombstones the session's entry (the host also idle-stops on its own + /// after `idleTimeout`, so a phone that vanishes still unlocks everyone). + func composerDraftEnded(_ sessionID: SessionID) { + composerSendTasks[sessionID]?.cancel() + composerSendTasks[sessionID] = nil + composerPendingText[sessionID] = nil + composerIdleTasks[sessionID]?.cancel() + composerIdleTasks[sessionID] = nil + composerLastSentAt[sessionID] = nil + guard composerStreamingSessions.remove(sessionID) != nil else { return } + connection(owningSession: sessionID)?.send( + .composerTyping(WireComposerTyping(sessionID: sessionID, text: "", active: false))) + } + + /// One throttle-window flush: report the newest pending draft to the session's host. + private func flushComposerDraft(_ sessionID: SessionID) { + composerSendTasks[sessionID] = nil + guard let text = composerPendingText.removeValue(forKey: sessionID), + let conn = connection(owningSession: sessionID), conn.connectivity.isLive + else { return } + composerLastSentAt[sessionID] = Date() + composerStreamingSessions.insert(sessionID) + conn.send(.composerTyping(WireComposerTyping(sessionID: sessionID, text: text, active: true))) + } + + /// Re-arm the typer-side idle stop: `idleTimeout` with no keystrokes ends the typing. + private func scheduleComposerIdleStop(_ sessionID: SessionID) { + composerIdleTasks[sessionID]?.cancel() + composerIdleTasks[sessionID] = Task { [weak self] in + try? await Task.sleep(for: .seconds(ComposerTypingState.idleTimeout)) + guard !Task.isCancelled else { return } + self?.composerDraftEnded(sessionID) + } + } + + /// Fold composer-typing casts into the watcher projection: fresh entries from other + /// devices lock (and render into) that session's composer; tombstones — and local expiry, + /// for a typer that vanished without one — unlock it. + private func updateComposerTyping(_ casts: [WireCast]) { + for cast in casts where cast.channel == MeshCastChannel.composerTyping { + guard let state = ComposerTypingState.decode(cast) else { continue } + if cast.deleted { + guard composerTypingBySession[state.sessionID]?.deviceID == state.deviceID + else { continue } + composerTypingBySession[state.sessionID] = nil + composerExpiryTasks[state.sessionID]?.cancel() + composerExpiryTasks[state.sessionID] = nil + continue + } + // Own typing (republished by the session's host under this phone's deviceID) + // never locks this composer; an entry already past its window (a catch-up + // replay) never locks at all. + guard state.deviceID != IdentityStore.deviceID(), + state.isFresh(at: Date()) else { continue } + composerTypingBySession[state.sessionID] = state + scheduleComposerExpiry(state.sessionID, publishedAt: state.at) + } + } + + /// Arm (or re-arm) the watcher-side expiry for a session's typing entry. + private func scheduleComposerExpiry(_ sessionID: SessionID, publishedAt: Date) { + composerExpiryTasks[sessionID]?.cancel() + let deadline = publishedAt.addingTimeInterval(ComposerTypingState.staleTimeout) + let delay = max(0, deadline.timeIntervalSinceNow) + composerExpiryTasks[sessionID] = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self else { return } + guard let entry = self.composerTypingBySession[sessionID], + !entry.isFresh(at: Date()) else { return } + self.composerTypingBySession[sessionID] = nil + self.composerExpiryTasks[sessionID] = nil + } + } private var transcriptPersistTask: Task? @Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) @Published private(set) var grantedScope: DeviceScope = .approve @@ -2002,7 +2120,11 @@ final class RemoteStore: ObservableObject { .intelligenceResult, .credentialManifest, .credentialProvision, .createProject, // Cast subscriptions are per-connection (each `HostConnection` subscribes on its // own ready, with the merged ledger's cursors) — nothing routes them here. - .castSubscribe: + .castSubscribe, + // Composer typing goes straight to the owning connection from + // `composerDraftChanged`/`flushComposerDraft` — ephemeral by design, it must + // never be queued for optimistic replay, so it skips this router entirely. + .composerTyping: break } } @@ -2195,7 +2317,10 @@ final class RemoteStore: ObservableObject { // Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on. .createProject, // Mesh casting (demo has no host to cast). - .castSubscribe: + .castSubscribe, + // Live composer streaming — demo has no other devices to stream to, and + // `composerDraftChanged` already no-ops in demo mode before routing. + .composerTyping: break // passive / already handled by the seeded fixtures (demo has no mesh peers) } } diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 81e6278..031f69e 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -40,6 +40,18 @@ struct SessionDetailView: View { store.sessions.first { $0.sessionID == sessionID } } + /// Another device's live typing in this chat's composer (mesh composer streaming). While + /// present, this composer is locked and renders the streamed draft; it unlocks when the + /// typer sends or goes idle (their tombstone — or the local expiry guard — clears it). + private var remoteTyping: ComposerTypingState? { + store.composerTypingBySession[sessionID] + } + + /// The friendly label for a remote typer, never blank. + private func typerName(_ typing: ComposerTypingState) -> String { + typing.deviceName.isEmpty ? "Another device" : typing.deviceName + } + var body: some View { // The detail's height comes from the enclosing GeometryReader — a value fixed by the parent // (the navigation content area), never by anything inside it. The attention cards (approval / @@ -101,9 +113,43 @@ struct SessionDetailView: View { .onChange(of: proxy.size.height, initial: true) { _, height in availableHeight = height } + // Stream this composer's draft to the session's host (throttled in the store) + // so every other device viewing this chat sees it live and locks its own + // composer; an emptied field — including the clear on send — ends the typing + // and unlocks them. Leaving the detail ends it too. + .onChange(of: draft) { _, text in + store.composerDraftChanged(sessionID, text: text) + } + .onDisappear { store.composerDraftEnded(sessionID) } } } + /// The live view of another device's in-progress draft for this chat, shown above the + /// (locked) field. Head-truncated: the tail is where the typing is happening, so it's the + /// part that must stay visible. + private func remoteTypingRow(_ typing: ComposerTypingState) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "ellipsis.bubble") + Text("\(typerName(typing)) is typing…") + Spacer(minLength: 0) + } + .font(.footnote.weight(.semibold)) + .foregroundStyle(Palette.accent) + if !typing.text.isEmpty { + Text(typing.text) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(3) + .truncationMode(.head) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8).padding(.vertical, 6) + .background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 10)) + } + /// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise /// only in the ⋯ menu. A hidden button carries the shortcut; present only when it applies. @ViewBuilder @@ -409,6 +455,12 @@ struct SessionDetailView: View { } if store.canControl, let summary { controlRow(summary) } if canCompose { + // Someone is typing in this chat on another device: their draft + // streams in live here while the field below is locked (one typer + // per session at a time, mirroring the Mac composer). + if let typing = remoteTyping { + remoteTypingRow(typing) + } // Staged attachments ride above the field, matching the queued-message // chips and the Mac composer. if !attachments.isEmpty { @@ -430,11 +482,16 @@ struct SessionDetailView: View { // No keyboard-accessory Done button here (it floats awkwardly // over the glass bar on iOS 26) — a drag on the transcript // dismisses the keyboard instead (`scrollDismissesKeyboard`). - TextField(running ? "Queue a follow-up…" : "Send a follow-up…", - text: $draft, axis: .vertical) + TextField( + remoteTyping.map { "\(typerName($0)) is typing…" } + ?? (running ? "Queue a follow-up…" : "Send a follow-up…"), + text: $draft, axis: .vertical) .textFieldStyle(.plain) .lineLimit(1...4) .padding(.vertical, 3) + // Locked while another device is typing here — the + // mesh-wide "one typer per session" contract. + .disabled(remoteTyping != nil) // Stop the in-flight turn (the Mac's ⌘. / "Interrupt"). Shown only // while running and at control scope; send stays at the far right so // its position never shifts. Mirrors `interruptShortcut`. @@ -458,8 +515,10 @@ struct SessionDetailView: View { Image(systemName: "arrow.up.circle.fill") .font(.title2) } - // An attachment-only follow-up (files, no typed text) is sendable. - .disabled((draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty) || !store.connectivity.isLive) + // An attachment-only follow-up (files, no typed text) is sendable — + // but never while another device is mid-draft here (locked). + .disabled((draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty) + || !store.connectivity.isLive || remoteTyping != nil) // Hardware-keyboard send (Magic Keyboard on iPad), mirroring the // Mac — plain Return stays newline in the multiline field. .keyboardShortcut(.return, modifiers: .command)