diff --git a/native/Apps/iOS/MobileAppModel.swift b/native/Apps/iOS/MobileAppModel.swift index af66d32..8f04a25 100644 --- a/native/Apps/iOS/MobileAppModel.swift +++ b/native/Apps/iOS/MobileAppModel.swift @@ -139,8 +139,7 @@ final class MobileAppModel: ObservableObject { } var displayedMessages: [ChatMessage] { - guard let pendingOutgoingMessage else { return messages } - return messages + [pendingOutgoingMessage] + ConversationProjection.displayedMessages(messages, pending: pendingOutgoingMessage) } var capacityAlternatives: [Provider] { diff --git a/native/Apps/macOS/DesktopClientModel.swift b/native/Apps/macOS/DesktopClientModel.swift index 80d6048..cf19901 100644 --- a/native/Apps/macOS/DesktopClientModel.swift +++ b/native/Apps/macOS/DesktopClientModel.swift @@ -146,8 +146,7 @@ final class DesktopClientModel: ObservableObject { } var displayedMessages: [ChatMessage] { - guard let pendingOutgoingMessage else { return messages } - return messages + [pendingOutgoingMessage] + ConversationProjection.displayedMessages(messages, pending: pendingOutgoingMessage) } var capacityAlternatives: [Provider] { diff --git a/native/Sources/ExarchFoundation/ConversationProjection.swift b/native/Sources/ExarchFoundation/ConversationProjection.swift index 3b9445d..54b95bd 100644 --- a/native/Sources/ExarchFoundation/ConversationProjection.swift +++ b/native/Sources/ExarchFoundation/ConversationProjection.swift @@ -46,6 +46,18 @@ public struct ConversationTurnStatus: Equatable, Sendable { } public enum ConversationProjection { + public static func displayedMessages( + _ messages: [ChatMessage], + pending pendingMessage: ChatMessage? + ) -> [ChatMessage] { + guard let pendingMessage else { return messages } + if let clientMessageID = pendingMessage.clientMessageID, + messages.contains(where: { $0.clientMessageID == clientMessageID }) { + return messages + } + return messages + [pendingMessage] + } + public static func pendingUserMessage( clientMessageID: String, text: String, diff --git a/native/Sources/ExarchUI/ConversationView.swift b/native/Sources/ExarchUI/ConversationView.swift index d767645..b2c8e58 100644 --- a/native/Sources/ExarchUI/ConversationView.swift +++ b/native/Sources/ExarchUI/ConversationView.swift @@ -15,6 +15,17 @@ func shouldFollowLatestMessage( return followingLatest || latestMessageID?.hasPrefix("pending:") == true } +func shouldShowPersistentWorkingIndicator( + messages: [ChatMessage], + turnStatus: ConversationTurnStatus? +) -> Bool { + guard let turnStatus, case .working = turnStatus.phase else { return false } + guard let submittedIndex = messages.lastIndex(where: { + $0.role == .user && $0.clientMessageID == turnStatus.clientMessageID + }) else { return true } + return submittedIndex < messages.index(before: messages.endIndex) +} + public struct FocusFlowConversationView: View { @Binding private var provider: Provider @Binding private var modelName: String @@ -138,6 +149,9 @@ public struct FocusFlowConversationView: View { public var body: some View { return VStack(spacing: 0) { transcriptRegion + if shouldShowPersistentWorkingIndicator(messages: messages, turnStatus: turnStatus) { + persistentWorkingIndicator + } composer } .background(FocusFlowTheme.canvas.ignoresSafeArea()) @@ -337,6 +351,10 @@ public struct FocusFlowConversationView: View { // computed property inside every ForEach row would rescan the full // transcript for every message as lazy-loaded history grows. let attributedMessageIDs = harnessAttributionPoints(in: messages) + let pinsWorkingIndicator = shouldShowPersistentWorkingIndicator( + messages: messages, + turnStatus: turnStatus + ) ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 18) { @@ -345,7 +363,8 @@ public struct FocusFlowConversationView: View { VStack(spacing: 8) { messageRow(message, attributed: attributedMessageIDs.contains(message.id)) if message.role == .user, - message.clientMessageID == turnStatus?.clientMessageID { + message.clientMessageID == turnStatus?.clientMessageID, + !pinsWorkingIndicator { turnStatusRow } } @@ -631,6 +650,23 @@ public struct FocusFlowConversationView: View { .accessibilityElement(children: .combine) } + private var persistentWorkingIndicator: some View { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Working on your laptop…") + Spacer() + } + .font(.footnote) + .foregroundStyle(FocusFlowTheme.secondaryInk) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background(FocusFlowTheme.canvas) + .overlay(alignment: .top) { + Divider().opacity(0.35) + } + .accessibilityElement(children: .combine) + } + private func formattedDuration(_ duration: TimeInterval) -> String { let seconds = max(0, Int(duration.rounded())) let minutes = seconds / 60 diff --git a/native/Tests/ExarchFoundationTests/FoundationTests.swift b/native/Tests/ExarchFoundationTests/FoundationTests.swift index 5b88fde..268a14e 100644 --- a/native/Tests/ExarchFoundationTests/FoundationTests.swift +++ b/native/Tests/ExarchFoundationTests/FoundationTests.swift @@ -192,6 +192,8 @@ struct FoundationTests { #expect(pending.sequence == .max) #expect(pending.clientMessageID == "message_local_1") + #expect(ConversationProjection.displayedMessages([], pending: pending) == [pending]) + let canonical = CanonicalEvent( id: "event_1", conversationId: "conversation_1", @@ -211,6 +213,11 @@ struct FoundationTests { clientMessageID: "message_local_1", in: [canonical] )) + let canonicalMessage = ConversationProjection.messages(from: [canonical])[0] + #expect(ConversationProjection.displayedMessages( + [canonicalMessage], + pending: pending + ) == [canonicalMessage]) #expect(!ConversationProjection.containsUserMessage( clientMessageID: "message_other", in: [canonical] diff --git a/native/Tests/ExarchUITests/ConversationScrollTests.swift b/native/Tests/ExarchUITests/ConversationScrollTests.swift index d4a83ba..f3d57dd 100644 --- a/native/Tests/ExarchUITests/ConversationScrollTests.swift +++ b/native/Tests/ExarchUITests/ConversationScrollTests.swift @@ -1,8 +1,36 @@ import Testing +import ExarchFoundation @testable import ExarchUI @Suite("Conversation scrolling") struct ConversationScrollTests { + @Test("working status stays below the sent message until later output arrives") + func workingIndicatorPlacement() { + let status = ConversationTurnStatus(clientMessageID: "message_local_1", phase: .working) + let user = ChatMessage( + id: "user_1", + role: .user, + text: "Do the work", + provider: .codex, + sequence: 1, + clientMessageID: "message_local_1" + ) + let assistant = ChatMessage( + id: "assistant_1", + role: .assistant, + text: "First update", + provider: .codex, + sequence: 2 + ) + + #expect(!shouldShowPersistentWorkingIndicator(messages: [user], turnStatus: status)) + #expect(shouldShowPersistentWorkingIndicator(messages: [user, assistant], turnStatus: status)) + #expect(!shouldShowPersistentWorkingIndicator( + messages: [user, assistant], + turnStatus: ConversationTurnStatus(clientMessageID: "message_local_1", phase: .completed(3)) + )) + } + @Test("initial cache and laptop reconciliation never issue competing scrolls") func loadingOwnsItsBottomAnchor() { #expect(!shouldFollowLatestMessage( diff --git a/services/daemon/src/relay-http-bridge.test.ts b/services/daemon/src/relay-http-bridge.test.ts index fb64234..195897f 100644 --- a/services/daemon/src/relay-http-bridge.test.ts +++ b/services/daemon/src/relay-http-bridge.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it, vi } from "vitest"; -import { RelayHttpBridge } from "./relay-http-bridge.js"; +import { RelayHttpBridge, relayBridgeTimeoutMs } from "./relay-http-bridge.js"; describe("RelayHttpBridge", () => { + it("preserves long-running harness turns without weakening ordinary request bounds", () => { + expect(relayBridgeTimeoutMs("/api/v1/conversations/conv_1/messages")).toBe(24 * 60 * 60_000); + expect(relayBridgeTimeoutMs("/api/v1/providers")).toBe(30_000); + expect(relayBridgeTimeoutMs("/api/v1/conversations/conv_1/messages/extra")).toBe(30_000); + }); + it("forwards only the validated relative request to loopback", async () => { const request = vi.fn(async (input, init) => { expect(String(input)).toBe("http://127.0.0.1:43120/api/v1/health"); diff --git a/services/daemon/src/relay-http-bridge.ts b/services/daemon/src/relay-http-bridge.ts index c195b53..624698e 100644 --- a/services/daemon/src/relay-http-bridge.ts +++ b/services/daemon/src/relay-http-bridge.ts @@ -4,6 +4,15 @@ import { type RelayHttpResponse } from "../../../packages/relay/src/index.js"; +const ORDINARY_REQUEST_TIMEOUT_MS = 30_000; +const LONG_RUNNING_REQUEST_TIMEOUT_MS = 24 * 60 * 60_000; + +export function relayBridgeTimeoutMs(path: string): number { + return /^\/api\/v1\/conversations\/[^/]+\/messages$/.test(path) + ? LONG_RUNNING_REQUEST_TIMEOUT_MS + : ORDINARY_REQUEST_TIMEOUT_MS; +} + export class RelayHttpBridge { private readonly baseUrl: URL; @@ -35,7 +44,11 @@ export class RelayHttpBridge { ? { body: Buffer.from(input.body ?? Buffer.alloc(0)) as unknown as BodyInit } : {}), redirect: "error", - signal: AbortSignal.timeout(30_000) + // Message submission currently stays open until the harness finishes. + // Keep that request alive just as the native loopback client does; + // otherwise a healthy Codex/Claude/Hermes turn is misreported as a 502 + // after 30 seconds even though the laptop continues doing the work. + signal: AbortSignal.timeout(relayBridgeTimeoutMs(input.path)) }); return { status: response.status,