Repository navigation
Fix onboarding lane import and iOS chat send recovery #574
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
ac65cd7
8f37c1f
ec19219
91a4edf
e62af55
9ee8bfa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
|
|
@@ -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) | ||
|
|
@@ -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>? | ||
|
|
@@ -2128,6 +2140,7 @@ final class SyncService: ObservableObject { | |
| projectSelectionTask?.cancel() | ||
| reconnectTask?.cancel() | ||
| networkPathReconnectTask?.cancel() | ||
| pendingOperationFlushTask?.cancel() | ||
| lanePresenceHeartbeatTask?.cancel() | ||
| terminalBufferRevisionTask?.cancel() | ||
| chatEventRevisionTask?.cancel() | ||
|
|
@@ -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 { | ||
|
|
@@ -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 | ||
| } | ||
| } | ||
| } | ||
|
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."]) | ||
|
|
@@ -7976,6 +8008,9 @@ final class SyncService: ObservableObject { | |
| projectRootPath: activeProjectRootPath | ||
| )) | ||
| savePendingOperations(queued) | ||
| if canSendLiveRequests() { | ||
| schedulePendingOperationFlush() | ||
| } | ||
| } | ||
|
|
||
| private func pendingOperationMatchesActiveProject(_ operation: PendingOperation) -> Bool { | ||
|
|
@@ -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 { | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a queued operation times out during the reconnect/foreground flush path, Useful? React with 👍 / 👎. |
||
| } | ||
| return false | ||
| } | ||
| queued = loadPendingOperations() | ||
| } | ||
| pendingOperationCount = loadPendingOperations().count | ||
| return false | ||
| } | ||
|
|
||
| private func performCommandRequest( | ||
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a live Useful? React with 👍 / 👎. |
||
| if stillLive, isSyncRequestTimeoutError(error) { | ||
| verifyTransportAliveAfterRequestTimeout(error as NSError) | ||
| } | ||
| return ["queued": true] | ||
| } | ||
| throw error | ||
|
|
@@ -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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Whenever a user sends a chat message, this writes the text to Useful? React with 👍 / 👎. |
||
|
|
||
| let initialDeliveryState = (sendWillQueueChatMessage || useSteer) ? "queued" : "sending" | ||
| let echo = WorkLocalEchoMessage( | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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 20Repository: 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
fiRepository: 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 || trueRepository: arul28/ADE Length of output: 6772 🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n "copySubmittedWorkPromptToPasteboard\(" -S . || trueRepository: 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 -nRepository: arul28/ADE Length of output: 4630 Remove automatic clipboard copying of submitted prompts
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 📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| func presentCreateLanePr() { | ||
| createPrPresented = true | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -824,6 +824,7 @@ struct WorkSessionDestinationView: View { | |
| handledOpeningPromptKey = promptKey | ||
| return | ||
| } | ||
| copySubmittedWorkPromptToPasteboard(prompt) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a session opens with any non-empty Useful? React with 👍 / 👎. |
||
| handledOpeningPromptKey = promptKey | ||
|
|
||
| let echo: WorkLocalEchoMessage | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.