Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/* @vitest-environment jsdom */

import React from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAppStore } from "../../state/appStore";
import { WorktreesCard } from "./WorktreesCard";

describe("WorktreesCard", () => {
const originalAde = window.ade;
const originalRefreshLanes = useAppStore.getState().refreshLanes;
let refreshLanes: typeof originalRefreshLanes;

beforeEach(() => {
refreshLanes = vi.fn(async () => undefined) as unknown as typeof originalRefreshLanes;
useAppStore.setState({ refreshLanes });
window.ade = ({
...(originalAde ?? {}),
lanes: {
...((originalAde as typeof window.ade | undefined)?.lanes ?? {}),
listUnregisteredWorktrees: vi.fn()
.mockResolvedValueOnce([{ path: "/repo/worktree-a", branch: "feature/a" }])
.mockResolvedValueOnce([]),
attach: vi.fn(async () => ({})),
},
} as unknown) as typeof window.ade;
});

afterEach(() => {
cleanup();
window.ade = originalAde;
useAppStore.setState({ refreshLanes: originalRefreshLanes });
vi.restoreAllMocks();
});

it("refreshes the shared lane store after importing existing worktrees", async () => {
render(<WorktreesCard />);

await screen.findByText("feature/a");
fireEvent.click(screen.getByRole("button", { name: "Add 1 as lane" }));

await waitFor(() => {
expect(window.ade.lanes.attach).toHaveBeenCalledWith({
name: "feature/a",
attachedPath: "/repo/worktree-a",
});
expect(refreshLanes).toHaveBeenCalledWith({ includeStatus: false });
expect(window.ade.lanes.listUnregisteredWorktrees).toHaveBeenCalledTimes(2);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { CircleNotch, GitFork, WarningCircle, Check } from "@phosphor-icons/react";
import type { UnregisteredLaneCandidate } from "../../../shared/types/lanes";
import { useAppStore } from "../../state/appStore";
import { COLORS, SANS_FONT, MONO_FONT } from "../lanes/laneDesignTokens";
import { Button } from "../ui/Button";
import { RescanButton } from "./RescanButton";
Expand All @@ -9,6 +10,7 @@ import { CARD_BASE, SECTION_LABEL } from "./onboardingTheme";
type LoadOptions = { preselectAll?: boolean; keepSelected?: Set<string> };

export function WorktreesCard() {
const refreshLanes = useAppStore((s) => s.refreshLanes);
const [worktrees, setWorktrees] = useState<UnregisteredLaneCandidate[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
Expand Down Expand Up @@ -85,6 +87,15 @@ export function WorktreesCard() {
);
}
}
if (ok > 0) {
try {
await refreshLanes({ includeStatus: false });
} catch (err) {
collectedErrors.push(
`Added ${ok} worktree${ok !== 1 ? "s" : ""}, but the lane list did not refresh: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
setErrors(collectedErrors);
setImportedCount((c) => c + ok);
setAttaching(false);
Expand Down
115 changes: 91 additions & 24 deletions apps/ios/ADE/Services/SyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ func isRemoteCommandApplicationError(_ error: Error) -> Bool {
&& nsError.userInfo["ADEErrorCode"] != nil
}

func isSyncRequestTimeoutError(_ error: Error) -> Bool {
let nsError = error as NSError
return nsError.domain == "ADE" && nsError.code == 23
}

func syncShouldQueueCommandAfterSendFailure(error: Error, canSendLiveRequests: Bool, queueable: Bool) -> Bool {
guard queueable else { return false }
guard !isRemoteCommandApplicationError(error) else { return false }
return !canSendLiveRequests || isSyncRequestTimeoutError(error)
}

func decodeHydrationPayload<T: Decodable>(_ raw: Any, as type: T.Type, domainLabel: String, decoder: JSONDecoder) throws -> T {
do {
let data = try adeJSONData(withJSONObject: raw)
Expand Down Expand Up @@ -1305,6 +1316,7 @@ final class SyncService: ObservableObject {
private var hydrationTask: Task<Void, Never>?
private var reconnectTask: Task<Void, Never>?
private var networkPathReconnectTask: Task<Void, Never>?
private var pendingOperationFlushTask: Task<Void, Never>?
private var lanePresenceHeartbeatTask: Task<Void, Never>?
private var openLaneReferenceCounts: [String: Int] = [:]
private var terminalBufferRevisionTask: Task<Void, Never>?
Expand Down Expand Up @@ -2128,6 +2140,7 @@ final class SyncService: ObservableObject {
projectSelectionTask?.cancel()
reconnectTask?.cancel()
networkPathReconnectTask?.cancel()
pendingOperationFlushTask?.cancel()
lanePresenceHeartbeatTask?.cancel()
terminalBufferRevisionTask?.cancel()
chatEventRevisionTask?.cancel()
Expand Down Expand Up @@ -7771,6 +7784,8 @@ final class SyncService: ObservableObject {
clientHeartbeatTask = nil
hydrationTask?.cancel()
hydrationTask = nil
pendingOperationFlushTask?.cancel()
pendingOperationFlushTask = nil
lanePresenceHeartbeatTask?.cancel()
lanePresenceHeartbeatTask = nil
if let socket {
Expand Down Expand Up @@ -7959,6 +7974,23 @@ final class SyncService: ObservableObject {
pendingOperationCount = operations.count
}

private func schedulePendingOperationFlush(delayNanoseconds: UInt64 = 2_000_000_000) {
guard pendingOperationFlushTask == nil else { return }
pendingOperationFlushTask = Task { @MainActor [weak self] in
var nextDelay = delayNanoseconds
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: nextDelay)
guard let self, !Task.isCancelled else { return }
let shouldRetry = await self.flushPendingOperations()
if !shouldRetry || !self.canSendLiveRequests() {
self.pendingOperationFlushTask = nil
return
}
nextDelay = 15_000_000_000
}
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private func enqueueOperation(kind: String, action: String, args: [String: Any], id: String? = nil) throws {
guard JSONSerialization.isValidJSONObject(args) else {
throw NSError(domain: "ADE", code: 11, userInfo: [NSLocalizedDescriptionKey: "Invalid queued operation payload."])
Expand All @@ -7976,6 +8008,9 @@ final class SyncService: ObservableObject {
projectRootPath: activeProjectRootPath
))
savePendingOperations(queued)
if canSendLiveRequests() {
schedulePendingOperationFlush()
}
}

private func pendingOperationMatchesActiveProject(_ operation: PendingOperation) -> Bool {
Expand Down Expand Up @@ -8009,21 +8044,33 @@ final class SyncService: ObservableObject {
return dict
}

private func flushPendingOperations() async {
guard canSendLiveRequests() else { return }
private func pendingOperationMatches(_ lhs: PendingOperation, _ rhs: PendingOperation) -> Bool {
lhs.id == rhs.id
&& lhs.kind == rhs.kind
&& lhs.action == rhs.action
&& lhs.payload == rhs.payload
}

private func removePendingOperation(_ operation: PendingOperation) {
var queued = loadPendingOperations()
if let index = queued.firstIndex(where: { pendingOperationMatches($0, operation) }) {
queued.remove(at: index)
savePendingOperations(queued)
} else {
pendingOperationCount = queued.count
}
}

@discardableResult
private func flushPendingOperations() async -> Bool {
guard canSendLiveRequests() else { return false }
var queued = loadPendingOperations()
guard !queued.isEmpty else {
pendingOperationCount = 0
return
return false
}

var index = queued.startIndex
while index < queued.endIndex {
let operation = queued[index]
guard pendingOperationMatchesActiveProject(operation) else {
index = queued.index(after: index)
continue
}
while let operation = queued.first(where: pendingOperationMatchesActiveProject) {
do {
let args = try decodeQueuedArgs(operation)
switch operation.kind {
Expand All @@ -8040,20 +8087,28 @@ final class SyncService: ObservableObject {
default:
throw NSError(domain: "ADE", code: 13, userInfo: [NSLocalizedDescriptionKey: "Unknown queued operation type."])
}
queued.remove(at: index)
savePendingOperations(queued)
removePendingOperation(operation)
} catch {
lastError = SyncUserFacingError.message(for: error)
if canSendLiveRequests() {
queued.remove(at: index)
savePendingOperations(queued)
let stillLive = canSendLiveRequests()
if isRemoteCommandApplicationError(error) || (stillLive && !isSyncRequestTimeoutError(error)) {
removePendingOperation(operation)
queued = loadPendingOperations()
continue
}
savePendingOperations(queued)
connectionState = .error
break
if !stillLive {
connectionState = .error
}
if isSyncRequestTimeoutError(error) {
verifyTransportAliveAfterRequestTimeout(error as NSError)
return stillLive
Comment on lines +8117 to +8119

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reschedule live-timeout queue flushes

When a queued operation times out during the reconnect/foreground flush path, flushPendingOperations() now preserves it and returns true, but the existing callers in initial hydration and foreground handling discard that return value. If the transport probe later proves the socket is still alive, nothing schedules another flush, so an offline-queued chat.send can stay in UserDefaults until a later foreground/reconnect event instead of replaying after the short retry described by this change. The retry needs to be scheduled here (or all callers need to honor the true return), not only from schedulePendingOperationFlush().

Useful? React with 👍 / 👎.

}
return false
}
queued = loadPendingOperations()
}
pendingOperationCount = loadPendingOperations().count
return false
}

private func performCommandRequest(
Expand Down Expand Up @@ -8109,10 +8164,16 @@ final class SyncService: ObservableObject {
timeoutNanoseconds: timeoutNanoseconds
)
} catch {
if !isRemoteCommandApplicationError(error),
!canSendLiveRequests(),
commandPolicy(for: action)?.queueable == true {
let stillLive = canSendLiveRequests()
if syncShouldQueueCommandAfterSendFailure(
error: error,
canSendLiveRequests: stillLive,
queueable: commandPolicy(for: action)?.queueable == true
) {
try enqueueOperation(kind: "command", action: action, args: args, id: commandId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist chat.send dedupe before queueing timed-out sends

When a live chat.send request times out after the desktop has already accepted it, this line now stores the same commandId for replay. That is safe only while the host keeps its in-memory command cache; the persisted command ledger in apps/ade-cli/src/services/sync/syncHostService.ts:246-263 does not include chat.send, so if the desktop restarts before the queued retry flushes, the replay can execute the message a second time instead of returning the cached result. Either persist chat.send results as well or avoid queueing timed-out actions that are not restart-deduped.

Useful? React with 👍 / 👎.

if stillLive, isSyncRequestTimeoutError(error) {
verifyTransportAliveAfterRequestTimeout(error as NSError)
}
return ["queued": true]
}
throw error
Expand Down Expand Up @@ -8744,10 +8805,16 @@ extension SyncService {
do {
return try await performCommandRequest(action: action, args: args, commandId: commandId)
} catch {
if !isRemoteCommandApplicationError(error),
!canSendLiveRequests(),
commandPolicy(for: action)?.queueable == true {
let stillLive = canSendLiveRequests()
if syncShouldQueueCommandAfterSendFailure(
error: error,
canSendLiveRequests: stillLive,
queueable: commandPolicy(for: action)?.queueable == true
) {
try enqueueOperation(kind: "command", action: action, args: args, id: commandId)
if stillLive, isSyncRequestTimeoutError(error) {
verifyTransportAliveAfterRequestTimeout(error as NSError)
}
return ["queued": true]
}
throw error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ extension WorkSessionDestinationView {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return false }
guard canSendChatMessages else { return false }
copySubmittedWorkPromptToPasteboard(text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not copy every chat send to the system pasteboard

Whenever a user sends a chat message, this writes the text to UIPasteboard.general before the send result is known. Because the general pasteboard is systemwide (and can sync through Universal Clipboard), ordinary prompts that may include code, logs, or secrets are exposed to other apps/devices and the user's existing clipboard is overwritten even when the send succeeds. Please keep recovery state in app-local storage or copy only after an explicit user action.

Useful? React with 👍 / 👎.


let initialDeliveryState = (sendWillQueueChatMessage || useSteer) ? "queued" : "sending"
let echo = WorkLocalEchoMessage(
Expand Down Expand Up @@ -480,6 +481,13 @@ extension WorkSessionDestinationView {
}
}

@MainActor
func copySubmittedWorkPromptToPasteboard(_ text: String) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
UIPasteboard.general.string = trimmed
}
Comment on lines +483 to +488

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate all usages of UIPasteboard.general
rg -n "UIPasteboard\.general" apps/ios/ADE/Views/Work -S || true
rg -n "copySubmittedWorkPromptToPasteboard" apps/ios/ADE/Views/Work -S || true

# Show the helper definition and surrounding context
sed -n '430,520p' apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift | cat -n

# Show where it’s called from WorkSessionDestinationView.swift around the provided line
sed -n '780,880p' apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift | cat -n

# Also find sendMessage / sendInitialOpeningPromptIfNeeded implementations and print nearby blocks
rg -n "func sendMessage|sendInitialOpeningPromptIfNeeded" apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift -n -S
rg -n "sendInitialOpeningPromptIfNeeded" apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift -n -S || true

# Print larger context around those functions if present
# (Use line ranges that likely cover them; adjust after rg output)
rg -n "func sendMessage|sendInitialOpeningPromptIfNeeded" apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift -S | head -n 20

Repository: arul28/ADE

Length of output: 10046


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show all call sites of the helper (should include sendMessage and/or initial opening prompt)
rg -n "copySubmittedWorkPromptToPasteboard\(" apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift -S

# Extract surrounding code for sendMessage (and any related prompt flow) in WorkSessionDestinationView.swift
rg -n "func sendMessage" apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift -S
# Show a window around the sendMessage function (line numbers from rg output will guide; for now print a reasonable region)
# We'll also search for sendInitialOpeningPromptIfNeeded call sites and print nearby context.
rg -n "sendInitialOpeningPromptIfNeeded\(" apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift -S
# Show around the call site near line 411 mentioned by prior output
sed -n '360,460p' apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift | cat -n

# Now print the portion of WorkSessionDestinationView.swift around where the helper is called at ~827
sed -n '800,860p' apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift | cat -n

# Also extract WorkSessionDestinationView.swift around the sendMessage function once found
# (Use awk to print between matching lines if rg line number is stable)
send_line=$(rg -n "func sendMessage" apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift -S | head -n1 | cut -d: -f1)
if [[ -n "${send_line}" ]]; then
  start=$((send_line-80))
  end=$((send_line+120))
  sed -n "${start},${end}p" apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift | cat -n
fi

Repository: arul28/ADE

Length of output: 407


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,120p' apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift | cat -n

# Find the function containing the call at the line where copySubmittedWorkPromptToPasteboard(text) is invoked.
# Print a window around that line number.
call_line=$(rg -n "copySubmittedWorkPromptToPasteboard\\(text\\)" apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift -S | head -n1 | cut -d: -f1)
echo "call_line=$call_line"

if [[ -n "${call_line}" ]]; then
  start=$((call_line-80))
  end=$((call_line+80))
  sed -n "${start},${end}p" apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift | cat -n
fi

# Also look for the function name near the call site by searching backwards for "func " above the call.
# (simple heuristic: print last few "func " lines before call)
awk -v n="$call_line" 'NR<=n && $0 ~ /func / {print NR ":" $0}' apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift | tail -n 20 || true

Repository: arul28/ADE

Length of output: 6772


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "copySubmittedWorkPromptToPasteboard\(" -S . || true

Repository: arul28/ADE

Length of output: 413


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '760,860p' apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift | cat -n

Repository: arul28/ADE

Length of output: 4630


Remove automatic clipboard copying of submitted prompts
Writing trimmed chat prompts to the shared system pasteboard (UIPasteboard.general) in both sendMessage and sendInitialOpeningPromptIfNeeded can leak sensitive user content to other apps without explicit user consent.

  • apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift: sendMessage calls copySubmittedWorkPromptToPasteboard(_:), which assigns UIPasteboard.general.string = trimmed.
  • apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift: sendInitialOpeningPromptIfNeeded copies the initial opening prompt to the pasteboard before it dispatches the async send/steer request (so failures still leave data on the clipboard).

Consider only copying on explicit user intent (e.g., “Copy” action), or gate behind opt-in/confirmation with clear UI feedback; otherwise use an app-local/private store instead of UIPasteboard.general.

📍 Affects 2 files
  • apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift#L484-L489 (this comment)
  • apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift#L827-L827
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ios/ADE/Views/Work/WorkSessionDestinationView`+Actions.swift around
lines 484 - 489, In both affected sites remove the automatic writing of user
prompts to the shared pasteboard: in
apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift (lines 484-489)
stop assigning UIPasteboard.general.string in
copySubmittedWorkPromptToPasteboard(_:) — either delete the function or make it
a no-op and remove all callers (notably sendMessage) so prompts are not copied
automatically; in apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift (line
827) remove the call that copies the initial opening prompt to the pasteboard in
sendInitialOpeningPromptIfNeeded and instead persist the text in an
app-local/private store or require explicit user intent (e.g., expose a “Copy”
action or an opt‑in confirmation) before writing to UIPasteboard.general.


func presentCreateLanePr() {
createPrPresented = true
}
Expand Down
1 change: 1 addition & 0 deletions apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,7 @@ struct WorkSessionDestinationView: View {
handledOpeningPromptKey = promptKey
return
}
copySubmittedWorkPromptToPasteboard(prompt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid overwriting the user's pasteboard on auto-send

When a session opens with any non-empty initialOpeningPrompt, this path runs automatically and writes the prompt into UIPasteboard.general before knowing whether the send will fail. In the common successful auto-send case this silently replaces whatever the user had copied in another app, and prompts can contain sensitive text; recovery should only copy on an explicit user action or at least only after the failure path that needs it.

Useful? React with 👍 / 👎.

handledOpeningPromptKey = promptKey

let echo: WorkLocalEchoMessage
Expand Down
41 changes: 41 additions & 0 deletions apps/ios/ADETests/ADETests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,47 @@ final class ADETests: XCTestCase {
)
}

func testCommandSendFailureQueuePolicyPreservesQueueableTimeoutsOnly() {
XCTAssertTrue(
syncShouldQueueCommandAfterSendFailure(
error: SyncRequestTimeout.error(),
canSendLiveRequests: true,
queueable: true
)
)
XCTAssertTrue(
syncShouldQueueCommandAfterSendFailure(
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut),
canSendLiveRequests: false,
queueable: true
)
)

let error = NSError(
domain: "ADE",
code: 17,
userInfo: [
NSLocalizedDescriptionKey: "Remote command failed.",
"ADEErrorCode": "command_failed",
]
)

XCTAssertFalse(
syncShouldQueueCommandAfterSendFailure(
error: error,
canSendLiveRequests: false,
queueable: true
)
)
XCTAssertFalse(
syncShouldQueueCommandAfterSendFailure(
error: SyncRequestTimeout.error(),
canSendLiveRequests: true,
queueable: false
)
)
}

func testSyncConnectionHealthTreatsSyncingAsConnectedTransport() {
let health = syncConnectionHealth(
connectionState: .syncing,
Expand Down
3 changes: 3 additions & 0 deletions docs/features/onboarding-and-settings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ Renderer — onboarding:
- `apps/desktop/src/renderer/components/onboarding/GitHubCard.tsx`,
`LinearCard.tsx`, `WorktreesCard.tsx` — setup cards for repository auth,
Linear OAuth / API-key auth, and importing existing worktrees as lanes.
`WorktreesCard` refreshes the shared lane store after successful imports so
the first post-onboarding Work/Lanes views render the attached lanes without
waiting for a later project refresh.
- `apps/desktop/src/renderer/components/onboarding/InputPopover.tsx`,
`RescanButton.tsx`, `onboardingTheme.ts` — shared setup-card controls and
brand/status styling tokens.
Expand Down
11 changes: 6 additions & 5 deletions docs/features/sync-and-multi-device/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -752,8 +752,9 @@ project scope split.
rows, plus rows for tables that no longer exist locally, before
opening the apply transaction. A batch that contains only ignored
tables is a no-op and preserves the local database version.
- **Controller command queues replay on reconnect.** If the runtime
advertises `chat.send` as queueable and the user sends while the
desktop is reconnecting, the iOS app stores the command locally with
a queued delivery state and replays on reconnect. Do not assume
synchronous semantics from the phone side.
- **Controller command queues replay on reconnect and on live-send
timeouts.** If the runtime advertises `chat.send` as queueable and the user
sends while the desktop is reconnecting, or the send request times out while
the socket still appears connected, the iOS app stores the command locally
with a queued delivery state and replays it with the same `commandId`. Do not
assume synchronous semantics from the phone side.
Loading