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
57 changes: 46 additions & 11 deletions apps/desktop/src/core/mobile/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ import { upScoutAgent } from "../agents/service.ts";
import { queryFleet } from "../../server/db-queries.ts";
import {
loadScoutBrokerContext,
loadScoutActivityItems,
readScoutBrokerHome,
openScoutPeerSession,
registerScoutLocalAgentBinding,
replyToScoutMessage,
sendScoutDirectMessage,
sendScoutMessage,
type ScoutActivityItem,
type ScoutBrokerConversationRecord,
type ScoutBrokerHomeActivityRecord,
type ScoutBrokerSnapshot,
type ScoutDirectMessageResult,
} from "../broker/service.ts";
Expand Down Expand Up @@ -613,6 +614,40 @@ export async function getScoutFleet(
return queryFleet(options);
}

/**
* Resolve whatever id the phone routed with onto a real broker conversation.
* The phone may send a conversation id directly (`c.…` from the activity feed, or
* a `dm.…` direct id) or a bare agent id (from the Agents tab). Not every agent
* has an `operator` DM — many only have ask/consult conversations keyed `c.…` —
* so when there's no direct hit and no `dm.operator.{agentId}`, fall back to the
* most-recent conversation the agent actually participates in.
*/
function resolveMobileConversation(
snapshot: ScoutBrokerSnapshot,
rawId: string,
): ScoutBrokerConversationRecord | null {
const direct = snapshot.conversations[rawId];
if (direct) return direct;

const operatorDm = snapshot.conversations[`dm.operator.${rawId}`];
if (operatorDm) return operatorDm;

const participating = Object.values(snapshot.conversations).filter(
(conversation) => conversation.participantIds?.includes(rawId),
);
if (participating.length === 0) return null;

const lastActivityMs = (conversationId: string): number =>
Object.values(snapshot.messages).reduce((latest, message) => {
if (message.conversationId !== conversationId) return latest;
return Math.max(latest, normalizeTimestampMs(message.createdAt) ?? 0);
}, 0);

return participating
.slice()
.sort((a, b) => lastActivityMs(b.id) - lastActivityMs(a.id))[0] ?? null;
}

export async function getScoutMobileSessionSnapshot(
conversationId: string,
options: {
Expand All @@ -624,7 +659,7 @@ export async function getScoutMobileSessionSnapshot(
void currentDirectory;
const broker = await requireMobileRelayContext();
const { snapshot } = broker;
const conversation = snapshot.conversations[conversationId];
const conversation = resolveMobileConversation(snapshot, conversationId);

// The conversation may not exist yet — the iOS app navigates to
// dm.operator.{agentId} before any messages are sent. Return an
Expand Down Expand Up @@ -677,7 +712,7 @@ export async function getScoutMobileSessionSnapshot(
: null;
const endpoint = directAgentId ? endpointForAgent(snapshot, directAgentId) : null;
const agent = directAgentId ? snapshot.agents[directAgentId] : null;
const messagePage = pageMessagesForConversation(snapshot, conversationId, options);
const messagePage = pageMessagesForConversation(snapshot, conversation.id, options);
const messages = messagePage.messages;
const activeFlight = latestActiveFlightForAgent(snapshot, directAgentId);
const lastAgentMessageAt = messages
Expand Down Expand Up @@ -1131,13 +1166,13 @@ export type ScoutMobileActivityFilters = {

export async function getScoutMobileActivity(
filters: ScoutMobileActivityFilters = {},
): Promise<ScoutActivityItem[]> {
return loadScoutActivityItems({
agentId: filters.agentId,
actorId: filters.actorId,
conversationId: filters.conversationId,
limit: filters.limit ?? 100,
});
): Promise<ScoutBrokerHomeActivityRecord[]> {
// Home is an orientation surface, so it reads the broker's *curated* home
// activity — one row per message, named actors, always thread-linked — not the
// raw `/v1/activity` lifecycle firehose (ask_opened / flight_updated / …),
// which is an ops feed and stays on the Tail tab. See project_home_purpose.
const home = await readScoutBrokerHome();
return (home?.activity ?? []).slice(0, filters.limit ?? 100);
}

// -- Comms (channels + DMs) ----------------------------------------------
Expand Down
12 changes: 5 additions & 7 deletions apps/desktop/src/core/pairing/runtime/bridge/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import {
import { provisionMobileTerminalAccess } from "./mobile-terminal-provision.ts";
import { syncMobilePushRegistrationWithRelay } from "@openscout/runtime/mobile-push";
import {
conversationIdForAgent,
queryMobileAgentDetail,
queryMobileAgents,
queryMobileSessions,
Expand Down Expand Up @@ -877,13 +876,12 @@ const mobileRouter = t.router({
message: "conversationId is required",
});
}
// Accept conversation IDs directly, or resolve agent IDs →
// dm.operator.{agentId} (the broker's deterministic convention).
const conversationId = rawId.startsWith("dm.")
? rawId
: conversationIdForAgent(rawId);
// Pass the routed id straight through — the snapshot service resolves it
// against the live broker snapshot (a `c.…`/`dm.…` conversation id, or a
// bare agent id → its actual conversation). The old `dm.operator.{agentId}`
// wrap was wrong for agents whose conversation is keyed `c.…`.
return getScoutMobileSessionSnapshot(
conversationId,
rawId,
{
beforeTurnId: input.beforeTurnId ?? null,
limit: typeof input.limit === "number" ? input.limit : null,
Expand Down
14 changes: 8 additions & 6 deletions apps/ios/ScoutNext/AgentsSurface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,14 @@ struct AgentsSurface: View {
)
}
} else {
// Most-recent: a flat list, newest first, every row self-describing
// (project shown inline since there's no header to carry it).
// Most-recent: a flat list, newest first. The name + harness + age is
// the identity here; we don't repeat the project (that's PROJECT mode's
// job) — the second line only appears when the agent is on a branch.
ForEach(Array(recents.enumerated()), id: \.element.id) { idx, agent in
AgentRow(
agent: agent,
connector: nil,
showProject: true,
showProject: false,
onTap: { tapAgent(agent) }
)
if idx < recents.count - 1 { rowDivider }
Expand Down Expand Up @@ -334,7 +335,8 @@ private struct AgentRow: View {
let agent: AgentSummary
/// Non-nil ⇒ a leaf under a multi-agent project (tree rail + indent).
let connector: Connector?
/// Recent/flat mode prepends the project to the session line for context.
/// When set, prepends the project to the session line — only useful where no
/// header carries it. Recent mode leaves this off (name + age is enough).
var showProject: Bool = false
let onTap: () -> Void

Expand Down Expand Up @@ -385,8 +387,8 @@ private struct AgentRow: View {

/// The session coordinate beneath the name: the working branch when the agent
/// is on one (recency is already shown as the age on the right — no point
/// repeating the idle "Available" status). Recent mode prefixes the project so
/// a flat row is self-describing.
/// repeating the idle "Available" status). With `showProject`, the project is
/// prefixed for rows that have no header to carry it.
private var sessionLine: String? {
let branch = agent.branch.flatMap { $0.isEmpty ? nil : $0 }
let parts = [showProject ? displayProjectName(agent.projectName) : nil, branch].compactMap { $0 }
Expand Down
44 changes: 39 additions & 5 deletions apps/ios/ScoutNext/CommsSurface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,27 @@ struct CommsSurface: View {
.refreshable { await load() }
.task(id: reloadToken) { await load() }
.navigationDestination(item: $route) { convo in
CommsThreadView(client: client, conversation: convo, onClose: { route = nil })
CommsThreadView(
client: client,
conversation: convo,
onClose: { route = nil },
onRead: { await markRead(convo.id) }
)
}
}

/// Opening a thread clears its unread badge: drop the count locally so the row
/// is already caught up when the operator pops back, then tell the broker to
/// advance the operator's read cursor. Best-effort — a failed write just means
/// the badge returns on the next list pull.
private func markRead(_ conversationId: String) async {
if let idx = conversations.firstIndex(where: { $0.id == conversationId }),
conversations[idx].unreadCount != 0 {
conversations[idx].unreadCount = 0
}
_ = try? await client.markConversationRead(conversationId: conversationId)
}

// MARK: - Filtering

private var filtered: [CommsConversation] {
Expand Down Expand Up @@ -106,13 +123,27 @@ private struct CommsRow: View {

private var unread: Bool { conversation.unreadCount > 0 }

/// Only channels/threads/groups/system carry a leading type glyph. DMs (the
/// vast majority) used to reserve a blank slot here, which just shoved every
/// title ~one glyph off the content margin for a column that drew nothing —
/// so they now render with no leading element and the title sits flush left.
private var showsTypeGlyph: Bool {
switch conversation.kind {
case .direct, .unknown: return false
default: return true
}
}

var body: some View {
Button(action: onTap) {
VStack(spacing: 0) {
HStack(spacing: HudSpacing.md) {
// Left: conversation TYPE — a hand-drawn glyph, the list's rhythm.
CommsTypeGlyph(kind: conversation.kind)
.foregroundStyle(HudPalette.muted)
// Left: conversation TYPE — a hand-drawn glyph, the list's
// rhythm — but only when it actually marks something.
if showsTypeGlyph {
CommsTypeGlyph(kind: conversation.kind)
.foregroundStyle(HudPalette.muted)
}

Text(displayTitle)
.font(HudFont.ui(HudTextSize.md, weight: unread ? .semibold : .medium))
Expand Down Expand Up @@ -476,6 +507,9 @@ struct CommsThreadView: View {
let client: any ScoutBrokerClient
let conversation: CommsConversation
let onClose: () -> Void
/// Called once the thread is on screen so the list can clear the unread badge
/// and the broker can advance the operator's read cursor. Defaults to a no-op.
var onRead: () async -> Void = {}

@State private var messages: [CommsMessage] = []
@State private var isLoading = true
Expand All @@ -497,7 +531,7 @@ struct CommsThreadView: View {
// optional, not the only way out of a thread.
.background(InteractivePopGestureEnabler())
.safeAreaInset(edge: .bottom) { composer }
.task { await load() }
.task { await load(); await onRead() }
.onAppear { voice.prepare() }
.onDisappear { if voice.isListening { voice.cancel() } }
}
Expand Down
58 changes: 57 additions & 1 deletion apps/ios/ScoutNext/ConversationSurface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ struct ConversationSurface: View {

@State private var projection = ConversationProjection()
@State private var isStreaming = false
@State private var loadPhase: LoadPhase = .loading
@State private var composerText = ""
@State private var isSending = false
@State private var showSettings = false
Expand All @@ -28,6 +29,11 @@ struct ConversationSurface: View {

private var turns: [TurnState] { projection.state?.turns ?? [] }

/// Distinguishes the three reasons a transcript can be empty so the surface
/// never renders an unexplained void: still fetching, loaded-but-no-history,
/// or the snapshot RPC failed.
private enum LoadPhase { case loading, loaded, failed }

var body: some View {
VStack(spacing: 0) {
header
Expand Down Expand Up @@ -241,7 +247,50 @@ struct ConversationSurface: View {

// MARK: - Transcript

@ViewBuilder
private var transcript: some View {
if turns.isEmpty {
emptyState
} else {
transcriptScroll
}
}

/// Shown when there's nothing to render — explains *why* rather than leaving a
/// black void: a card-created or never-run agent legitimately has no history,
/// which reads as "no messages yet" + the composer below; a failed fetch reads
/// as an error you can retry.
@ViewBuilder
private var emptyState: some View {
VStack {
Spacer(minLength: 0)
switch loadPhase {
case .loading:
HudEmptyState(title: "Loading conversation", icon: "ellipsis.bubble")
case .failed:
VStack(spacing: HudSpacing.lg) {
HudEmptyState(
title: "Couldn’t load conversation",
subtitle: "The bridge didn’t return a transcript for this session.",
icon: "exclamationmark.bubble"
)
HudButton("Retry", icon: "arrow.clockwise", style: .secondary) {
Task { await run() }
}
}
case .loaded:
HudEmptyState(
title: "No messages yet",
subtitle: "Steer the agent below to begin.",
icon: "bubble.left.and.bubble.right"
)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private var transcriptScroll: some View {
GeometryReader { geo in
ScrollViewReader { proxy in
ScrollView {
Expand Down Expand Up @@ -277,12 +326,19 @@ struct ConversationSurface: View {
// MARK: - Lifecycle

private func run() async {
loadPhase = .loading
// Recover authoritative state, then fold live events on top — exactly
// the snapshot-then-stream contract the projection is built around.
if let snapshot = try? await client.snapshot(conversationId: conversationId) {
do {
let snapshot = try await client.snapshot(conversationId: conversationId)
var p = ConversationProjection()
p.applySnapshot(snapshot)
projection = p
loadPhase = .loaded
} catch {
// No authoritative snapshot. Surface the failure, but still attach to
// the live stream so a session that's actively producing can populate.
loadPhase = .failed
}
// Live events flip the badge on only when they actually arrive — a
// static (already-settled) conversation stays "idle".
Expand Down
Loading