From 04eaca0ae6df3e61d7bc01845f6dc2c26963f979 Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 18 Aug 2026 21:52:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(sessions):=20drop=20the=20per-row=20presen?= =?UTF-8?q?ce=20dot=20=E2=80=94=20dim=20disconnected=20rows=20instead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hub where most sessions stay connected turns a green online dot into noise: an indicator that is almost always in one state carries no information, and a column of saturated green outshouts the markers that matter (pending approval, unread). Align both natives with the web sidebar semantics: no leading status dot, disconnected rows render at half opacity, and a small green spinner appears after the title only while a turn is in flight. Android's StatusIndicator is removed; iOS keeps StatusDot for the chat header. --- .../feature/sessions/SessionListScreen.kt | 133 +++++++----------- .../Features/Sessions/SessionListView.swift | 43 +++--- 2 files changed, 79 insertions(+), 97 deletions(-) diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt index ddaf9561..540b8fde 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt @@ -1,11 +1,5 @@ package app.hapi.companion.feature.sessions -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -29,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FloatingActionButton @@ -78,9 +73,11 @@ import app.hapi.protocol.wire.TodoProgress * pull-to-refresh, empty state; * - pinned section first (the sort already puts globalPinned/pinned rows on * top; a header + divider make the boundary visible); - * - per row: status dot (active / thinking pulse), flavor brand icon + - * title, machine + worktree labels, summary/path line, relative - * `updatedAt`, pending-request badge, todo-progress chip, unread dot; + * - per row: flavor brand icon + title, spinner while a turn is in flight, + * machine + worktree labels, summary/path line, relative `updatedAt`, + * pending-request badge, todo-progress chip, unread dot; disconnected + * rows are dimmed — connected is the resting state, so no presence dot + * (web parity); * - long-press → actions sheet: pin (none/project/global), rename (dialog), * reopen (inactive rows; navigates into the possibly-superseding id), * archive, delete (confirm; 409 while active) — optimistic store updates; @@ -294,61 +291,64 @@ private fun SessionRow( onLongPress: (SessionRowUi) -> Unit, ) { val summary = row.summary - Row( + Column( modifier = Modifier .fillMaxWidth() .combinedClickable( onClick = { onOpen(row.id) }, onLongClick = { onLongPress(row) }, ) - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.Top, + .padding(horizontal = 16.dp, vertical = 10.dp) + // Dimming expresses "disconnected" (web parity): connected is the + // resting state here, so only the exception gets marked — no + // per-row presence dot. + .alpha(if (summary.active) 1f else 0.5f), ) { - StatusIndicator( - active = summary.active, - thinking = summary.thinking, - modifier = Modifier.padding(top = 5.dp), - ) - Spacer(modifier = Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - AgentFlavorIcon(row.flavor, modifier = Modifier.size(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + AgentFlavorIcon(row.flavor, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + // One weighted element only: the title takes ALL leftover + // width (start-aligned, ellipsis on true overflow). Splitting + // the slack with a weighted trailing spacer truncated even + // short names at ~half the row (device-observed). + Text( + text = row.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (row.unread) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (summary.active && summary.thinking) { Spacer(modifier = Modifier.width(6.dp)) - // One weighted element only: the title takes ALL leftover - // width (start-aligned, ellipsis on true overflow). Splitting - // the slack with a weighted trailing spacer truncated even - // short names at ~half the row (device-observed). - Text( - text = row.title, - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (row.unread) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - if (row.unread) { - Spacer(modifier = Modifier.width(6.dp)) - UnreadDot() - } - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = localizedRelativeAge(summary.updatedAt), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 1.5.dp, + color = Color(0xFF34C759), ) } - MetaLine(row) - row.subtitle?.let { subtitle -> - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (row.unread) { + Spacer(modifier = Modifier.width(6.dp)) + UnreadDot() } - BadgeLine(summary) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = localizedRelativeAge(summary.updatedAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } + MetaLine(row) + row.subtitle?.let { subtitle -> + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + BadgeLine(summary) } } @@ -444,35 +444,6 @@ private fun UnreadDot() { ) } -/** Solid dot for active, pulsing while thinking, hollow-ish gray when idle. */ -@Composable -private fun StatusIndicator(active: Boolean, thinking: Boolean, modifier: Modifier = Modifier) { - val color = when { - active -> Color(0xFF34C759) - else -> MaterialTheme.colorScheme.outlineVariant - } - val alpha = if (thinking) { - val transition = rememberInfiniteTransition(label = "thinking-pulse") - transition.animateFloat( - initialValue = 1f, - targetValue = 0.25f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 700, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "thinking-alpha", - ).value - } else { - 1f - } - Box( - modifier = modifier - .size(10.dp) - .alpha(alpha) - .background(color, CircleShape), - ) -} - // --------------------------------------------------------------- chrome -- @Composable diff --git a/ios/Hapi/Features/Sessions/SessionListView.swift b/ios/Hapi/Features/Sessions/SessionListView.swift index fd795d3f..98db5288 100644 --- a/ios/Hapi/Features/Sessions/SessionListView.swift +++ b/ios/Hapi/Features/Sessions/SessionListView.swift @@ -10,9 +10,11 @@ import SwiftUI /// pull-to-refresh, empty/loading states; /// - pinned section first (the sort already puts globalPinned/pinned rows on /// top; a header makes the boundary visible); -/// - per row: status dot (active / thinking pulse), flavor brand icon + -/// title, machine + worktree meta line, summary/path line, relative -/// `updatedAt`, pending-request badge, todo-progress chip, unread dot; +/// - per row: flavor brand icon + title, spinner while a turn is in flight, +/// machine + worktree meta line, summary/path line, relative `updatedAt`, +/// pending-request badge, todo-progress chip, unread dot; disconnected +/// rows are dimmed — connected is the resting state, so no presence dot +/// (web parity); /// - long-press context menu → pin (none/project/global) + archive with /// optimistic store updates; failures land in an alert. struct SessionListView: View { @@ -203,22 +205,22 @@ struct SessionRowView: View { let now: Date var body: some View { - HStack(alignment: .top, spacing: 10) { - StatusDot(active: row.summary.active, thinking: row.summary.thinking) - .padding(.top, 5) - VStack(alignment: .leading, spacing: 2) { - titleLine - metaLine - if let subtitle = row.subtitle { - Text(subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - badgeLine + VStack(alignment: .leading, spacing: 2) { + titleLine + metaLine + if let subtitle = row.subtitle { + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) } + badgeLine } .padding(.vertical, 2) + // Dimming expresses "disconnected" (web parity): connected is the + // resting state here, so only the exception gets marked — no + // per-row presence dot. + .opacity(row.summary.active ? 1 : 0.5) .accessibilityElement(children: .combine) } @@ -229,6 +231,13 @@ struct SessionRowView: View { .font(.body) .fontWeight(row.unread ? .semibold : .regular) .lineLimit(1) + if row.summary.active && row.summary.thinking { + ProgressView() + .scaleEffect(0.7) + .frame(width: 14, height: 14) + .tint(.green) + .accessibilityLabel("Thinking") + } if row.unread { Circle() .fill(.tint) @@ -284,6 +293,8 @@ struct SessionRowView: View { } /// Solid green for active (pulsing while thinking), muted gray when idle. +/// Chat-header use only — list rows express liveness by dimming instead +/// (web parity: no per-row presence dot). struct StatusDot: View { let active: Bool let thinking: Bool