package com.sptmobile.endpoint import android.app.Application import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.sptmobile.render.MarkdownText import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter // [impl->REQ-ENDPOINT-VIEW-INTERLACE] /** * The interlaced endpoint view (APP-PLAN W4): one timeline of history * messages + digest rows, live via the follow stream, with compose + send. */ @Composable fun EndpointScreen( endpointId: String, onBack: () -> Unit, scrollToMsgId: String? = null, ) { val app = LocalContext.current.applicationContext as Application val vm: EndpointViewModel = viewModel( key = "endpoint:$endpointId", factory = EndpointViewModel.factory(app, endpointId), ) LaunchedEffect(endpointId) { vm.start() } BackHandler(onBack = onBack) val rows by vm.rows.collectAsState() val status by vm.status.collectAsState() val route by vm.route.collectAsState() val sending by vm.sending.collectAsState() val sendError by vm.sendError.collectAsState() val expanded by vm.expanded.collectAsState() Column( modifier = Modifier .fillMaxSize() .imePadding() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { TextButton(onClick = onBack) { Text("← Back") } Column(modifier = Modifier.padding(start = 4.dp)) { Text(text = endpointId, style = MaterialTheme.typography.titleMedium) Text(text = statusLine(status, route), style = MaterialTheme.typography.bodySmall) } } (route as? DigestRoute.PendingCrossNode)?.let { PendingCrossNodeCard(it) } val listState = rememberLazyListState() // [impl->REQ-GATEWAY-THREAD] Deep-link anchor (Messages row tap): once // the target msg-id's row is composed, scroll to it ONCE. Reset only // when the anchor id itself changes (a fresh deep link into this view). var anchored by rememberSaveable(scrollToMsgId) { mutableStateOf(false) } LaunchedEffect(rows, scrollToMsgId, anchored) { if (!anchored && scrollToMsgId != null) { val idx = rows.indexOfFirst { it.stableKey == "msg:$scrollToMsgId" } if (idx >= 0) { listState.scrollToItem(idx) anchored = true } } } // Auto-follow the tail ONLY when the user is already parked at the // bottom. A republish (idle liveness belt / new digest tick) must never // yank a user who scrolled up to read — that was the "everything jumps // on reload" churn. derivedStateOf tracks the live scroll position; the // 2-item tolerance absorbs the just-appended row. A deep-linked open // (scrollToMsgId set) never auto-tails — the user jumped to a message. val atBottom by remember { derivedStateOf { val info = listState.layoutInfo val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 info.totalItemsCount == 0 || lastVisible >= info.totalItemsCount - 3 } } // The user has taken control of the scroll (a real drag/fling — our own // instant scrollToItem never sets isScrollInProgress). Until then we keep // the view pinned to the bottom as content streams in (history first, // then the digest), so the endpoint always OPENS on the latest. var userScrolled by remember { mutableStateOf(false) } LaunchedEffect(listState) { snapshotFlow { listState.isScrollInProgress } .collect { if (it) userScrolled = true } } val lastKey = rows.lastOrNull()?.stableKey LaunchedEffect(lastKey) { if (scrollToMsgId != null || rows.isEmpty()) return@LaunchedEffect // NEVER fight an in-flight scroll: a row landing mid-drag must not // yank the position (this is the "flies far away while scrolling" // bug — the userScrolled latch can lag the very first drag frame, so // gate on the live flag too). if (listState.isScrollInProgress) return@LaunchedEffect // Otherwise stick to the bottom while the user hasn't scrolled away, // or is already parked there. Instant (no lerp) so a republish can't // animate the view around. if (!userScrolled || atBottom) { listState.scrollToItem(rows.size - 1) } } // [impl->REQ-ENDPOINT-VIEW-INTERLACE] arbitrary text selection across the // timeline: long-press to select, drag to extend, copy. Card expand taps // still fire (tap vs long-press don't collide). Selection spans the // currently-composed rows. The Box owns the weight so the list is height- // bounded and scrolls INSIDE it — it can never grow and shove the compose // box off-screen as the digest fills in. Box(modifier = Modifier.weight(1f).fillMaxWidth()) { if (rows.isEmpty()) { Text( text = "loading…", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.align(Alignment.Center), ) } SelectionContainer { LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp), ) { items(rows, key = { it.stableKey }) { row -> TimelineRowCard( row = row, expanded = row.stableKey in expanded, onToggleExpand = { vm.toggleExpanded(row.stableKey) }, ) } } } } if (sendError != null) { Text( text = "send failed: $sendError", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } ComposeBox(sending = sending, onSend = vm::send) } } private fun statusLine(status: String, route: DigestRoute): String = when (route) { is DigestRoute.Direct -> "$status · digest via ${route.hostEndpoint}" else -> status } // [impl->REQ-DIGEST-DIRECT-ROUTE] // [impl->REQ-ENDPOINT-INSTANCES] /** * The ruling-4 fallback: no linked host is co-located with this endpoint, so * instead of a digest the screen carries the registry card — the endpoint's * Instance rows (ruling 9) from the merged listings — until a co-located * host links up (the view model re-checks on a timer). */ @Composable private fun PendingCrossNodeCard(route: DigestRoute.PendingCrossNode) { Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(10.dp)) { Text( text = "Digest pending cross-node", style = MaterialTheme.typography.titleSmall, ) Text( text = "No paired host runs on this endpoint's node yet — " + "live digest arrives when one does. Messages and history " + "still work.", style = MaterialTheme.typography.bodySmall, ) route.instances.forEach { inst -> val label = inst.nodeLabel ?: inst.node.take(8) val extras = listOfNotNull( inst.status, inst.endpointType, inst.project?.let { "project $it" }, "via ${inst.via.size} host(s)", ) Text( text = "@$label — ${extras.joinToString(" · ")}", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp), ) } } } } @Composable private fun ComposeBox(sending: Boolean, onSend: (String) -> Unit) { var draft by rememberSaveable { mutableStateOf("") } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Bottom, ) { OutlinedTextField( value = draft, onValueChange = { draft = it }, modifier = Modifier.weight(1f), label = { Text("Message") }, enabled = !sending, ) Button( onClick = { onSend(draft) draft = "" }, enabled = !sending && draft.isNotBlank(), ) { Text(if (sending) "…" else "Send") } } } @Composable private fun TimelineRowCard( row: TimelineRow, expanded: Boolean, onToggleExpand: () -> Unit, ) { when (row) { is TimelineRow.Message -> Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(10.dp)) { Text( text = (if (row.outbound) "→ " else "← ") + row.from + " · " + clock(row.tsMs), style = MaterialTheme.typography.labelSmall, ) MarkdownText(text = row.body, style = MaterialTheme.typography.bodyMedium) } } is TimelineRow.DigestInput -> DigestInputRow(row, expanded, onToggleExpand) is TimelineRow.DigestEntry -> if (row.kind == "Agent") AgentBubble(row) else Text( text = digestLine(row), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 12.dp), ) } } /** * A digest turn's opening user input. An input that IS an `` * envelope (an agent-to-agent frame) gets its own framed, collapsed-default * card — the header names the sender and type; tapping expands the body. */ @Composable private fun DigestInputRow( row: TimelineRow.DigestInput, expanded: Boolean, onToggleExpand: () -> Unit, ) { val frame = remember(row.text) { EventFrame.parse(row.text) } if (frame == null) { Text( text = "▸ ${row.text}" + if (row.partial) " (working…)" else "", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 6.dp), ) return } OutlinedCard( modifier = Modifier .fillMaxWidth() .padding(top = 6.dp), onClick = onToggleExpand, ) { Column(modifier = Modifier.padding(10.dp)) { val from = frame.attr("from") ?: "?" val type = frame.attr("type") ?: "msg" Text( text = (if (expanded) "▾" else "▸") + " ✉ $type from $from" + (if (row.partial) " (working…)" else ""), style = MaterialTheme.typography.titleSmall, ) if (expanded) { MarkdownText( text = frame.body, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 6.dp), ) } } } } /** One agent output entry = one chat bubble (the agent's side of the chat). */ @Composable private fun AgentBubble(row: TimelineRow.DigestEntry) { Card( modifier = Modifier .fillMaxWidth() .padding(start = 12.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, ), ) { MarkdownText( text = row.text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(10.dp), ) } } private fun digestLine(row: TimelineRow.DigestEntry): String = when (row.kind) { "ToolSprint" -> "⚙ ${row.text}" "Boundary" -> "— ${row.text} —" "Context" -> "[${row.text.take(120)}]" else -> row.text } private val clockFormat = DateTimeFormatter.ofPattern("HH:mm") private fun clock(tsMs: Long?): String = tsMs?.let { clockFormat.format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } ?: ""