feat(sessions): drop the per-row presence dot — dim disconnected rows instead

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.
This commit is contained in:
weishu
2026-08-18 21:52:02 +08:00
parent 8ae402d5b4
commit 04eaca0ae6
2 changed files with 79 additions and 97 deletions
@@ -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
@@ -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