Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 1 addition & 2 deletions native/Apps/iOS/MobileAppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
3 changes: 1 addition & 2 deletions native/Apps/macOS/DesktopClientModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
12 changes: 12 additions & 0 deletions native/Sources/ExarchFoundation/ConversationProjection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 37 additions & 1 deletion native/Sources/ExarchUI/ConversationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions native/Tests/ExarchFoundationTests/FoundationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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]
Expand Down
28 changes: 28 additions & 0 deletions native/Tests/ExarchUITests/ConversationScrollTests.swift
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
8 changes: 7 additions & 1 deletion services/daemon/src/relay-http-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>(async (input, init) => {
expect(String(input)).toBe("http://127.0.0.1:43120/api/v1/health");
Expand Down
15 changes: 14 additions & 1 deletion services/daemon/src/relay-http-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down