mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-29 23:11:42 +00:00
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:
+52
-81
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user