Skip to content

Commit f9147ab

Browse files
authored
style(chat): 过程折叠卡片复用思考卡片样式,思考行可展开 (#16)
The fold card was a 22dp backgroundless outlined capsule that looked nothing like the blocks it replaces, and the folded card swallowed thinking content with no way to read it. - Card container reuses ThinkingBlock's exact visual language: #007AFF 6% background, 0.5dp 15% hairline, 12dp radius, 12×8 padding, 13sp semibold blue header, blue spinner/check icons. Enabling folding is no longer a visual break. - Header copy: running+collapsed is a fixed "AI 过程 · 思考 N · 工具 M · 时长" (duration ticks); running+expanded shows only the live state; settled is "✅ · 思考 N · 工具 M · 时长". The card-layer state machine (auto-expand while running, manual collapse suppresses auto-expand for that card only, next card resets) was already correct and is unchanged. - Thinking rows now carry their content and unfold inline: a running thinking step auto-expands while streaming and settles 500ms after it finishes; a step the user collapsed by hand is never reopened by the card expanding (two independent layers). Tool rows keep the shared detail sheet. Co-authored-by: tall-1997 <278631348+tall-1997@users.noreply.github.com>
1 parent cb59e37 commit f9147ab

3 files changed

Lines changed: 122 additions & 58 deletions

File tree

‎src/android/app/src/main/java/com/openminis/app/ui/chat/ChatAssistantMessageUI.kt‎

Lines changed: 111 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -973,9 +973,14 @@ internal fun processCardHeader(
973973
else -> ""
974974
}
975975
return when {
976+
// Settled (reply text arrived, or a pure-tool/error/timeout round
977+
// ended): always the done summary.
976978
!running -> "✅ · $counts"
977-
expanded && state.isNotEmpty() -> state
978-
state.isNotEmpty() -> "$state · $counts"
979+
// Running + expanded: the steps are on screen, so the header shows
980+
// only the live state; fall back to the generic label when idle.
981+
expanded -> state.ifEmpty { "AI 过程" }
982+
// Running + collapsed: fixed label with ticking duration. The live
983+
// state stays out — that is what the expanded header is for.
979984
else -> "AI 过程 · $counts"
980985
}
981986
}
@@ -999,11 +1004,13 @@ internal fun ProcessSummaryBar(
9991004
// listing every step (expanded). Replaces the centred hairline divider:
10001005
// the reference treats a process run as one tappable object sitting
10011006
// between reply paragraphs, not as a section rule.
1002-
val labelColor = MaterialTheme.colorScheme.onSurface
1003-
val muted = MaterialTheme.colorScheme.onSurfaceVariant
1004-
val outline = MaterialTheme.colorScheme.outlineVariant
1005-
val shape = RoundedCornerShape(22.dp)
1006-
val iconTint = if (hasFailure) MaterialTheme.colorScheme.error else muted
1007+
// [P-process-card] Reuse the thinking card's look so enabling folding is
1008+
// not a visual break: same blue, same 12dp radius, same 6%/15% tint and
1009+
// 0.5dp hairline, same 12×8 padding as ThinkingBlock. The old 22dp
1010+
// backgroundless capsule looked nothing like the blocks it replaced.
1011+
val thinkingBlue = Color(0xFF007AFF)
1012+
val shape = RoundedCornerShape(12.dp)
1013+
val iconTint = if (hasFailure) MaterialTheme.colorScheme.error else thinkingBlue
10071014
// A running card ticks once a second so the duration walks. Bounded by
10081015
// the run: the moment it settles, running flips false and the loop ends.
10091016
var nowMs by remember { mutableStateOf(System.currentTimeMillis()) }
@@ -1025,16 +1032,17 @@ internal fun ProcessSummaryBar(
10251032
Column(
10261033
modifier = Modifier
10271034
.fillMaxWidth()
1028-
.padding(horizontal = 4.dp, vertical = 4.dp)
1035+
.padding(vertical = 2.5.dp)
1036+
.background(thinkingBlue.copy(alpha = 0.06f), shape)
1037+
.border(0.5.dp, thinkingBlue.copy(alpha = 0.15f), shape)
10291038
.clip(shape)
1030-
.border(1.dp, outline, shape)
10311039
.clickable(onClick = onToggle)
1032-
.padding(horizontal = 14.dp, vertical = 11.dp),
1040+
.padding(horizontal = 12.dp, vertical = 8.dp),
10331041
) {
10341042
Row(verticalAlignment = Alignment.CenterVertically) {
10351043
if (running) {
10361044
CircularProgressIndicator(
1037-
modifier = Modifier.size(16.dp),
1045+
modifier = Modifier.size(13.dp),
10381046
strokeWidth = 1.5.dp,
10391047
color = iconTint,
10401048
)
@@ -1043,32 +1051,31 @@ internal fun ProcessSummaryBar(
10431051
imageVector = if (hasFailure) Icons.Default.Error else Icons.Default.CheckCircle,
10441052
contentDescription = null,
10451053
tint = iconTint,
1046-
modifier = Modifier.size(18.dp),
1054+
modifier = Modifier.size(14.dp),
10471055
)
10481056
}
1049-
Spacer(modifier = Modifier.width(8.dp))
1057+
Spacer(modifier = Modifier.width(6.dp))
10501058
Text(
10511059
text = header,
1052-
fontSize = 15.sp,
1053-
fontWeight = FontWeight.Medium,
1054-
color = labelColor,
1060+
fontSize = 13.sp,
1061+
fontWeight = FontWeight.SemiBold,
1062+
color = if (hasFailure) MaterialTheme.colorScheme.error else thinkingBlue,
10551063
modifier = Modifier.weight(1f),
10561064
maxLines = 1,
10571065
overflow = TextOverflow.Ellipsis,
10581066
)
10591067
Icon(
1060-
imageVector = if (expanded) Icons.Default.KeyboardArrowDown
1061-
else Icons.AutoMirrored.Filled.KeyboardArrowRight,
1068+
imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
10621069
contentDescription = if (expanded) "Collapse" else "Expand",
1063-
tint = muted,
1064-
modifier = Modifier.size(20.dp),
1070+
tint = thinkingBlue.copy(alpha = 0.5f),
1071+
modifier = Modifier.size(14.dp),
10651072
)
10661073
}
10671074
if (expanded && steps.isNotEmpty()) {
10681075
HorizontalDivider(
1069-
modifier = Modifier.padding(top = 10.dp, bottom = 4.dp),
1076+
modifier = Modifier.padding(top = 8.dp, bottom = 2.dp),
10701077
thickness = 0.5.dp,
1071-
color = outline,
1078+
color = thinkingBlue.copy(alpha = 0.15f),
10721079
)
10731080
steps.forEach { step ->
10741081
ProcessStepRow(step = step, onOpen = { onOpenStep(step.id) })
@@ -1085,6 +1092,7 @@ internal fun ProcessSummaryBar(
10851092
@Composable
10861093
private fun ProcessStepRow(step: ProcessStepRef, onOpen: () -> Unit) {
10871094
val muted = MaterialTheme.colorScheme.onSurfaceVariant
1095+
val thinkingBlue = Color(0xFF007AFF)
10881096
val isThinking = step.kind == "thinking"
10891097
val duration = formatProcessDuration(step.durationMs)
10901098
val title = when {
@@ -1098,43 +1106,91 @@ private fun ProcessStepRow(step: ProcessStepRef, onOpen: () -> Unit) {
10981106
isThinking -> Icons.Default.Lightbulb
10991107
else -> Icons.Default.Search
11001108
}
1101-
Row(
1102-
verticalAlignment = Alignment.CenterVertically,
1103-
modifier = Modifier
1104-
.fillMaxWidth()
1105-
.then(if (isThinking) Modifier else Modifier.clickable(onClick = onOpen))
1106-
.padding(vertical = 7.dp),
1107-
) {
1108-
Icon(icon, contentDescription = null, tint = muted, modifier = Modifier.size(16.dp))
1109-
Spacer(modifier = Modifier.width(10.dp))
1110-
Text(
1111-
text = title,
1112-
fontSize = 14.sp,
1113-
color = MaterialTheme.colorScheme.onSurface,
1114-
modifier = Modifier.weight(1f),
1115-
maxLines = 1,
1116-
overflow = TextOverflow.Ellipsis,
1117-
)
1118-
if (!isThinking) {
1119-
Box(
1120-
modifier = Modifier
1121-
.size(7.dp)
1122-
.background(Color(0xFF34C759), CircleShape),
1109+
// [P-process-card] Thinking layer, independent of the card layer: a
1110+
// running thinking step auto-expands while its text streams, and settles
1111+
// 500ms after it finishes. A step the user collapsed by hand
1112+
// (userTouched) is never reopened by the card expanding, and never
1113+
// auto-collapsed over their choice — same rule as ThinkingBlock. State
1114+
// is keyed on step.id so one step's toggle never leaks to the next.
1115+
var stepExpanded by remember(step.id) { mutableStateOf(step.running) }
1116+
var userTouched by remember(step.id) { mutableStateOf(false) }
1117+
LaunchedEffect(step.id, step.running) {
1118+
if (!step.running && !userTouched) {
1119+
delay(500)
1120+
stepExpanded = false
1121+
}
1122+
}
1123+
Column(modifier = Modifier.fillMaxWidth()) {
1124+
Row(
1125+
verticalAlignment = Alignment.CenterVertically,
1126+
modifier = Modifier
1127+
.fillMaxWidth()
1128+
.clickable {
1129+
if (isThinking) {
1130+
userTouched = true
1131+
stepExpanded = !stepExpanded
1132+
} else {
1133+
onOpen()
1134+
}
1135+
}
1136+
.padding(vertical = 7.dp),
1137+
) {
1138+
Icon(
1139+
icon,
1140+
contentDescription = null,
1141+
tint = if (isThinking) thinkingBlue else muted,
1142+
modifier = Modifier.size(16.dp),
11231143
)
1124-
Spacer(modifier = Modifier.width(6.dp))
1144+
Spacer(modifier = Modifier.width(10.dp))
11251145
Text(
1126-
text = stringResource(R.string.chat_process_step_view),
1127-
fontSize = 13.sp,
1128-
color = Color(0xFF8E8EFF),
1146+
text = title,
1147+
fontSize = 14.sp,
1148+
color = MaterialTheme.colorScheme.onSurface,
1149+
modifier = Modifier.weight(1f),
1150+
maxLines = 1,
1151+
overflow = TextOverflow.Ellipsis,
1152+
)
1153+
if (!isThinking) {
1154+
Box(
1155+
modifier = Modifier
1156+
.size(7.dp)
1157+
.background(Color(0xFF34C759), CircleShape),
1158+
)
1159+
Spacer(modifier = Modifier.width(6.dp))
1160+
Text(
1161+
text = stringResource(R.string.chat_process_step_view),
1162+
fontSize = 13.sp,
1163+
color = Color(0xFF8E8EFF),
1164+
)
1165+
Spacer(modifier = Modifier.width(2.dp))
1166+
}
1167+
Icon(
1168+
imageVector = if (isThinking) {
1169+
if (stepExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown
1170+
} else {
1171+
Icons.AutoMirrored.Filled.KeyboardArrowRight
1172+
},
1173+
contentDescription = null,
1174+
tint = if (isThinking) thinkingBlue.copy(alpha = 0.5f) else muted,
1175+
modifier = Modifier.size(16.dp),
11291176
)
1130-
Spacer(modifier = Modifier.width(2.dp))
11311177
}
1132-
Icon(
1133-
Icons.AutoMirrored.Filled.KeyboardArrowRight,
1134-
contentDescription = null,
1135-
tint = muted,
1136-
modifier = Modifier.size(16.dp),
1137-
)
1178+
if (isThinking) {
1179+
AnimatedVisibility(visible = stepExpanded && step.content.isNotEmpty()) {
1180+
val scrollState = rememberScrollState()
1181+
Text(
1182+
text = step.content,
1183+
fontSize = 12.sp,
1184+
lineHeight = 17.sp,
1185+
color = MaterialTheme.colorScheme.onSurfaceVariant,
1186+
modifier = Modifier
1187+
.fillMaxWidth()
1188+
.padding(start = 26.dp, top = 2.dp, bottom = 6.dp)
1189+
.heightIn(max = 300.dp)
1190+
.verticalScroll(scrollState),
1191+
)
1192+
}
1193+
}
11381194
}
11391195
}
11401196

‎src/android/app/src/main/java/com/openminis/app/ui/chat/ChatFlatItems.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,12 @@ internal data class ProcessStepRef(
438438
val status: ToolBlockStatus?,
439439
val durationMs: Long,
440440
val running: Boolean,
441+
/**
442+
* [P-process-card] Thinking rows carry their reasoning text so the
443+
* expanded card can unfold them inline (the folded card swallowed it).
444+
* Empty for tool rows — they open the shared detail sheet instead.
445+
*/
446+
val content: String = "",
441447
)
442448

443449
/** Rounds with fewer foldable steps than this never fold (rule 1). */
@@ -661,6 +667,7 @@ private fun splitRounds(messages: List<ChatMessage>, activeSubAgentToolIds: Set<
661667
status = block.toolStatus,
662668
durationMs = block.durationMs,
663669
running = stepRunning,
670+
content = if (block.kind == "thinking") block.content else "",
664671
)
665672
}
666673
}

‎src/android/app/src/test/java/com/openminis/app/ui/chat/ChatFlatItemsProcessFoldTest.kt‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -568,13 +568,14 @@ class ChatFlatItemsProcessFoldTest {
568568

569569
@Test
570570
fun `card header switches between live collapsed live expanded and settled`() {
571-
// Running, collapsed: current state leads, counts and duration follow.
571+
// Running, collapsed: fixed label — the live state belongs to the
572+
// expanded header, not the collapsed one.
572573
assertEquals(
573-
"思考中 · 思考 2 · 工具 3 · 12s",
574+
"AI 过程 · 思考 2 · 工具 3 · 12s",
574575
processCardHeader(2, 3, running = true, expanded = false, activity = "thinking", activityTitle = "", duration = "12s"),
575576
)
576577
assertEquals(
577-
"调用工具 搜索代码 · 思考 1 · 工具 4 · 8s",
578+
"AI 过程 · 思考 1 · 工具 4 · 8s",
578579
processCardHeader(1, 4, running = true, expanded = false, activity = "tool", activityTitle = "搜索代码", duration = "8s"),
579580
)
580581
// Running, expanded: the steps are on screen, so only the state remains.

0 commit comments

Comments
 (0)