Merge nucleic/bold-pebble-seal into dev

This commit is contained in:
2026-07-11 21:01:56 -07:00
parent 83a9b02899
commit 85ce7725ce
2 changed files with 190 additions and 6 deletions
@@ -195,6 +195,7 @@ final class RemoteStore: ObservableObject {
guard !fresh.isEmpty else { return } guard !fresh.isEmpty else { return }
castLedger = ledger castLedger = ledger
activityFeed = ActivityFeedItem.merged(activityFeed, adding: fresh) activityFeed = ActivityFeedItem.merged(activityFeed, adding: fresh)
updateComposerTyping(fresh)
castPersistTask?.cancel() castPersistTask?.cancel()
castPersistTask = Task { [ledger] in castPersistTask = Task { [ledger] in
try? await Task.sleep(for: .milliseconds(500)) try? await Task.sleep(for: .milliseconds(500))
@@ -202,6 +203,123 @@ final class RemoteStore: ObservableObject {
await CastCache.save(ledger) 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<Void, Never>] = [:]
/// 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<Void, Never>] = [:]
private var composerLastSentAt: [SessionID: Date] = [:]
private var composerPendingText: [SessionID: String] = [:]
private var composerIdleTasks: [SessionID: Task<Void, Never>] = [:]
private var composerStreamingSessions: Set<SessionID> = []
/// 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<Void, Never>? private var transcriptPersistTask: Task<Void, Never>?
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) @Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
@Published private(set) var grantedScope: DeviceScope = .approve @Published private(set) var grantedScope: DeviceScope = .approve
@@ -2002,7 +2120,11 @@ final class RemoteStore: ObservableObject {
.intelligenceResult, .credentialManifest, .credentialProvision, .createProject, .intelligenceResult, .credentialManifest, .credentialProvision, .createProject,
// Cast subscriptions are per-connection (each `HostConnection` subscribes on its // Cast subscriptions are per-connection (each `HostConnection` subscribes on its
// own ready, with the merged ledger's cursors) — nothing routes them here. // 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 break
} }
} }
@@ -2195,7 +2317,10 @@ final class RemoteStore: ObservableObject {
// Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on. // Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on.
.createProject, .createProject,
// Mesh casting (demo has no host to cast). // 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) break // passive / already handled by the seeded fixtures (demo has no mesh peers)
} }
} }
@@ -40,6 +40,18 @@ struct SessionDetailView: View {
store.sessions.first { $0.sessionID == sessionID } 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 { var body: some View {
// The detail's height comes from the enclosing GeometryReader — a value fixed by the parent // 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 / // (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 .onChange(of: proxy.size.height, initial: true) { _, height in
availableHeight = height 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 /// ⌘. 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. /// only in the ⋯ menu. A hidden button carries the shortcut; present only when it applies.
@ViewBuilder @ViewBuilder
@@ -409,6 +455,12 @@ struct SessionDetailView: View {
} }
if store.canControl, let summary { controlRow(summary) } if store.canControl, let summary { controlRow(summary) }
if canCompose { 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 // Staged attachments ride above the field, matching the queued-message
// chips and the Mac composer. // chips and the Mac composer.
if !attachments.isEmpty { if !attachments.isEmpty {
@@ -430,11 +482,16 @@ struct SessionDetailView: View {
// No keyboard-accessory Done button here (it floats awkwardly // No keyboard-accessory Done button here (it floats awkwardly
// over the glass bar on iOS 26) — a drag on the transcript // over the glass bar on iOS 26) — a drag on the transcript
// dismisses the keyboard instead (`scrollDismissesKeyboard`). // dismisses the keyboard instead (`scrollDismissesKeyboard`).
TextField(running ? "Queue a follow-up…" : "Send a follow-up…", TextField(
text: $draft, axis: .vertical) remoteTyping.map { "\(typerName($0)) is typing…" }
?? (running ? "Queue a follow-up…" : "Send a follow-up…"),
text: $draft, axis: .vertical)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.lineLimit(1...4) .lineLimit(1...4)
.padding(.vertical, 3) .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 // Stop the in-flight turn (the Mac's ⌘. / "Interrupt"). Shown only
// while running and at control scope; send stays at the far right so // while running and at control scope; send stays at the far right so
// its position never shifts. Mirrors `interruptShortcut`. // its position never shifts. Mirrors `interruptShortcut`.
@@ -458,8 +515,10 @@ struct SessionDetailView: View {
Image(systemName: "arrow.up.circle.fill") Image(systemName: "arrow.up.circle.fill")
.font(.title2) .font(.title2)
} }
// An attachment-only follow-up (files, no typed text) is sendable. // An attachment-only follow-up (files, no typed text) is sendable —
.disabled((draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty) || !store.connectivity.isLive) // 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 // Hardware-keyboard send (Magic Keyboard on iPad), mirroring the
// Mac — plain Return stays newline in the multiline field. // Mac — plain Return stays newline in the multiline field.
.keyboardShortcut(.return, modifiers: .command) .keyboardShortcut(.return, modifiers: .command)