fix(sessions): show fork icon + order number on nested forked sessions (#934)
* fix(sessions): fork icon + depth number, no indentation
Multiply-nested forked sessions rendered with deep indentation that crushed
the card off-screen and made titles unreadable, and the per-sibling order
number was uninformative (mostly "1").
- SessionTreeItem: replace forkIndex (sibling order) with forkDepth (nesting
level: 1 = child of a root, 2 = fork of a fork, ...); flattenSessionTree
populates it.
- SessionCard: render CallSplit fork icon + the depth number (primary tint).
Removed the depth-based start padding so names stay full-width readable —
the icon+number now carries the structure instead of indentation.
- strings: sessions_fork_indicator (en/zh/ko).
- SessionTreeTest: assert isFork/forkDepth across nested fixtures.
No backend change — backend already returns parent_session_id per SessionInfo.
* test(ws): make testAutoReconnect tolerate transient RECONNECTING state
The test gated on connectionStatus.first { it == RECONNECTING } with a
15s timeout. With the test backoff forced to 0ms the RECONNECTING state is
emitted + consumed before the collector attaches, so the wait missed it and
timed out (kotlinx.coroutines.TimeoutCancellationException) — the flake CI
saw under parallel load.
- Replace the brittle RECONNECTING .first{} wait with a tolerant check that
accepts RECONNECTING-or-later (the reconnect clearly fired).
- Keep connect2Latch (second socket opening) as the definitive proof + add a
final CONNECTED assertion.
- Reset the test backoff in a finally so later tests never inherit 0ms.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
53c86d5871
commit
82e2d787af
7 files changed
+101
-24
No files matched your search
@@ -4,9 +4,25 @@ data class SessionTreeItem(
|
|||||||
val session: SessionInfo,
|
val session: SessionInfo,
|
||||||
val depth: Int,
|
val depth: Int,
|
||||||
val branchStem: String?,
|
val branchStem: String?,
|
||||||
|
// True when this session was forked/branched from another (has a parent in
|
||||||
|
// the loaded set). Drives the fork icon in SessionCard.
|
||||||
|
val isFork: Boolean = false,
|
||||||
|
// How many levels deep this fork sits (1 = direct child of a root, 2 = fork
|
||||||
|
// of a fork, ...). Shown as the number next to the fork icon so a nested
|
||||||
|
// chain reads 🍴1 → 🍴2 → 🍴3 and a branch-off pops out visually. 0 for
|
||||||
|
// non-forked (root) sessions.
|
||||||
|
val forkDepth: Int = 0,
|
||||||
val displayTitle: String,
|
val displayTitle: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten a session list into a display-order tree.
|
||||||
|
*
|
||||||
|
* Branching is expressed by `parent_session_id`. A session is a "fork" when its
|
||||||
|
* parent is present in the set; `forkDepth` is its nesting depth (1 = child of a
|
||||||
|
* root). The caller (SessionCard) renders a fork icon + the depth number so
|
||||||
|
* multiply-nested forks stay readable without deep indentation.
|
||||||
|
*/
|
||||||
fun flattenSessionTree(sessions: List<SessionInfo>): List<SessionTreeItem> {
|
fun flattenSessionTree(sessions: List<SessionInfo>): List<SessionTreeItem> {
|
||||||
val ids = sessions.mapTo(mutableSetOf()) { it.id }
|
val ids = sessions.mapTo(mutableSetOf()) { it.id }
|
||||||
val children = sessions.groupBy { it.parent_session_id?.takeIf(ids::contains) }
|
val children = sessions.groupBy { it.parent_session_id?.takeIf(ids::contains) }
|
||||||
@@ -16,8 +32,8 @@ fun flattenSessionTree(sessions: List<SessionInfo>): List<SessionTreeItem> {
|
|||||||
fun append(
|
fun append(
|
||||||
session: SessionInfo,
|
session: SessionInfo,
|
||||||
depth: Int,
|
depth: Int,
|
||||||
branchStem: String?,
|
|
||||||
siblingIndex: Int,
|
siblingIndex: Int,
|
||||||
|
siblingCount: Int,
|
||||||
) {
|
) {
|
||||||
if (!visited.add(session.id)) return
|
if (!visited.add(session.id)) return
|
||||||
val isBranch = session.parent_session_id?.takeIf(ids::contains) != null
|
val isBranch = session.parent_session_id?.takeIf(ids::contains) != null
|
||||||
@@ -29,23 +45,32 @@ fun flattenSessionTree(sessions: List<SessionInfo>): List<SessionTreeItem> {
|
|||||||
} else {
|
} else {
|
||||||
session.preview?.takeIf(String::isNotBlank)?.take(80) ?: "Untitled"
|
session.preview?.takeIf(String::isNotBlank)?.take(80) ?: "Untitled"
|
||||||
}
|
}
|
||||||
result += SessionTreeItem(session, depth, branchStem, title)
|
val branchStem = if (depth == 0) null else (if (siblingIndex == siblingCount - 1) "└─" else "├─")
|
||||||
|
result +=
|
||||||
|
SessionTreeItem(
|
||||||
|
session = session,
|
||||||
|
depth = depth,
|
||||||
|
branchStem = branchStem,
|
||||||
|
isFork = isBranch,
|
||||||
|
forkDepth = if (isBranch) depth else 0,
|
||||||
|
displayTitle = title,
|
||||||
|
)
|
||||||
val descendants = children[session.id].orEmpty()
|
val descendants = children[session.id].orEmpty()
|
||||||
descendants.forEachIndexed { index, child ->
|
descendants.forEachIndexed { index, child ->
|
||||||
append(
|
append(
|
||||||
session = child,
|
session = child,
|
||||||
depth = depth + 1,
|
depth = depth + 1,
|
||||||
branchStem = if (index == descendants.lastIndex) "└─" else "├─",
|
|
||||||
siblingIndex = index,
|
siblingIndex = index,
|
||||||
|
siblingCount = descendants.size,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
children[null].orEmpty().forEachIndexed { index, root ->
|
children[null].orEmpty().forEachIndexed { index, root ->
|
||||||
append(root, depth = 0, branchStem = null, siblingIndex = index)
|
append(root, depth = 0, siblingIndex = index, siblingCount = children[null].orEmpty().size)
|
||||||
}
|
}
|
||||||
sessions.filterNot { it.id in visited }.forEachIndexed { index, orphan ->
|
sessions.filterNot { it.id in visited }.forEachIndexed { index, orphan ->
|
||||||
append(orphan, depth = 0, branchStem = null, siblingIndex = index)
|
append(orphan, depth = 0, siblingIndex = index, siblingCount = 1)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
|||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.CallSplit
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Code
|
import androidx.compose.material.icons.filled.Code
|
||||||
@@ -791,8 +792,9 @@ fun SessionsScreen(
|
|||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
displayTitle = item.displayTitle,
|
displayTitle = item.displayTitle,
|
||||||
depth = item.depth,
|
|
||||||
branchStem = item.branchStem,
|
branchStem = item.branchStem,
|
||||||
|
isFork = item.isFork,
|
||||||
|
forkDepth = item.forkDepth,
|
||||||
query = state.searchQuery,
|
query = state.searchQuery,
|
||||||
isSelecting = state.isSelecting,
|
isSelecting = state.isSelecting,
|
||||||
isSelected = session.id in state.selectedIds,
|
isSelected = session.id in state.selectedIds,
|
||||||
@@ -958,8 +960,9 @@ fun SessionsScreen(
|
|||||||
private fun SessionCard(
|
private fun SessionCard(
|
||||||
session: com.m57.hermescontrol.data.model.SessionInfo,
|
session: com.m57.hermescontrol.data.model.SessionInfo,
|
||||||
displayTitle: String,
|
displayTitle: String,
|
||||||
depth: Int,
|
|
||||||
branchStem: String?,
|
branchStem: String?,
|
||||||
|
isFork: Boolean = false,
|
||||||
|
forkDepth: Int = 0,
|
||||||
query: String,
|
query: String,
|
||||||
isSelecting: Boolean,
|
isSelecting: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
@@ -984,7 +987,6 @@ private fun SessionCard(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = (depth * 16).dp)
|
|
||||||
.testTag("session_card_${session.id}")
|
.testTag("session_card_${session.id}")
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onCardClick,
|
onClick = onCardClick,
|
||||||
@@ -1022,13 +1024,25 @@ private fun SessionCard(
|
|||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Branch tree stem
|
// Fork marker: a fork icon + its nesting depth, so a chain reads
|
||||||
if (branchStem != null && !isSelecting) {
|
// 🍴1 → 🍴2 → 🍴3 and a branch-off pops out. No indentation — the
|
||||||
Text(
|
// number carries the structure so names never get squished.
|
||||||
text = branchStem,
|
if (isFork && !isSelecting) {
|
||||||
style = MaterialTheme.typography.labelLarge,
|
Icon(
|
||||||
color = MaterialTheme.colorScheme.outline,
|
imageVector = Icons.Filled.CallSplit,
|
||||||
|
contentDescription = stringResource(R.string.sessions_fork_indicator),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
|
if (forkDepth > 0) {
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(
|
||||||
|
text = forkDepth.toString(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -347,6 +347,7 @@
|
|||||||
<string name="sessions_prune_desc">지정된 일수보다 오래된 세션을 삭제합니다.</string>
|
<string name="sessions_prune_desc">지정된 일수보다 오래된 세션을 삭제합니다.</string>
|
||||||
<string name="sessions_prune_days_label">일수 기준</string>
|
<string name="sessions_prune_days_label">일수 기준</string>
|
||||||
<string name="sessions_prune_confirm">정리</string>
|
<string name="sessions_prune_confirm">정리</string>
|
||||||
|
<string name="sessions_fork_indicator">분기된 세션</string>
|
||||||
<string name="sessions_delete_title">세션 삭제?</string>
|
<string name="sessions_delete_title">세션 삭제?</string>
|
||||||
<string name="sessions_delete_message">정말 \"%1$s\"을(를) 삭제하시겠습니까? 되돌릴 수 없습니다.</string>
|
<string name="sessions_delete_message">정말 \"%1$s\"을(를) 삭제하시겠습니까? 되돌릴 수 없습니다.</string>
|
||||||
<string name="sessions_bulk_delete_title">세션 삭제?</string>
|
<string name="sessions_bulk_delete_title">세션 삭제?</string>
|
||||||
|
|||||||
@@ -473,6 +473,7 @@
|
|||||||
<string name="sessions_action_pin">置顶</string>
|
<string name="sessions_action_pin">置顶</string>
|
||||||
<string name="sessions_action_unpin">取消置顶</string>
|
<string name="sessions_action_unpin">取消置顶</string>
|
||||||
<string name="sessions_pinned_indicator">已置顶</string>
|
<string name="sessions_pinned_indicator">已置顶</string>
|
||||||
|
<string name="sessions_fork_indicator">分支会话</string>
|
||||||
<string name="sessions_rename_title">重命名会话</string>
|
<string name="sessions_rename_title">重命名会话</string>
|
||||||
<string name="sessions_rename_hint">会话标题</string>
|
<string name="sessions_rename_hint">会话标题</string>
|
||||||
<string name="toolsets_empty_title">没有工具集数据</string>
|
<string name="toolsets_empty_title">没有工具集数据</string>
|
||||||
|
|||||||
@@ -548,6 +548,7 @@
|
|||||||
<string name="sessions_action_pin">Pin</string>
|
<string name="sessions_action_pin">Pin</string>
|
||||||
<string name="sessions_action_unpin">Unpin</string>
|
<string name="sessions_action_unpin">Unpin</string>
|
||||||
<string name="sessions_pinned_indicator">Pinned</string>
|
<string name="sessions_pinned_indicator">Pinned</string>
|
||||||
|
<string name="sessions_fork_indicator">Forked session</string>
|
||||||
<string name="sessions_rename_title">Rename Session</string>
|
<string name="sessions_rename_title">Rename Session</string>
|
||||||
<string name="sessions_rename_hint">Session title</string>
|
<string name="sessions_rename_hint">Session title</string>
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,29 @@ class SessionTreeTest {
|
|||||||
it.displayTitle
|
it.displayTitle
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assertEquals(listOf(null, null, "├─", "└─", "└─"), tree.map { it.branchStem })
|
// Fork flag + nesting depth (1 = child of root, 2 = fork of a fork).
|
||||||
|
assertEquals(listOf(false, false, true, true, true), tree.map { it.isFork })
|
||||||
|
assertEquals(listOf(0, 0, 1, 1, 2), tree.map { it.forkDepth })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deepNestingMarksEveryForkWithDepth() {
|
||||||
|
// root → a (child 1) → a1 (child 1) ; root → b (child 2, last)
|
||||||
|
// a1 is fork-depth 2 (fork of a fork), b is fork-depth 1.
|
||||||
|
val sessions =
|
||||||
|
listOf(
|
||||||
|
SessionInfo(id = "root", title = "Root"),
|
||||||
|
SessionInfo(id = "a", parent_session_id = "root"),
|
||||||
|
SessionInfo(id = "b", parent_session_id = "root"),
|
||||||
|
SessionInfo(id = "a1", parent_session_id = "a"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val tree = flattenSessionTree(sessions)
|
||||||
|
|
||||||
|
assertEquals(listOf("root", "a", "a1", "b"), tree.map { it.session.id })
|
||||||
|
assertEquals(listOf(0, 1, 2, 1), tree.map { it.depth })
|
||||||
|
assertEquals(listOf(false, true, true, true), tree.map { it.isFork })
|
||||||
|
assertEquals(listOf(0, 1, 2, 1), tree.map { it.forkDepth })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1099,21 +1099,34 @@ class HermesWsClientTest {
|
|||||||
// Force server to close socket 1 to trigger reconnect
|
// Force server to close socket 1 to trigger reconnect
|
||||||
serverSocket1?.close(1001, "Server shutting down")
|
serverSocket1?.close(1001, "Server shutting down")
|
||||||
|
|
||||||
// Wait for status to become RECONNECTING
|
// Wait for the reconnect to trigger. With the test backoff forced to
|
||||||
runBlocking {
|
// 0ms the RECONNECTING state is transient and can be emitted + consumed
|
||||||
withTimeout(
|
// before this collector attaches, so we don't gate on the exact
|
||||||
15_000,
|
// intermediate state — we require it to have progressed (RECONNECTING
|
||||||
) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.RECONNECTING } }
|
// or later). The definitive proof is the second socket opening below.
|
||||||
}
|
val statusNow = HermesWsClient.connectionStatus.value
|
||||||
|
assertTrue(
|
||||||
|
"Expected reconnect to trigger (RECONNECTING or later), was $statusNow",
|
||||||
|
statusNow == ConnectionStatus.RECONNECTING ||
|
||||||
|
statusNow == ConnectionStatus.CONNECTING ||
|
||||||
|
statusNow == ConnectionStatus.CONNECTED,
|
||||||
|
)
|
||||||
|
|
||||||
// The client should now attempt to reconnect after initial backoff (1000ms)
|
// The client should now attempt to reconnect after the (0ms) backoff.
|
||||||
// Wait for the second connection to hit the server. Generous ceiling:
|
// Wait for the second connection to hit the server. Generous ceiling:
|
||||||
// loaded CI runners routinely stretch short wall-clock windows.
|
// loaded CI runners routinely stretch short wall-clock windows.
|
||||||
|
try {
|
||||||
assertTrue("Failed to reconnect", connect2Latch.await(30, TimeUnit.SECONDS))
|
assertTrue("Failed to reconnect", connect2Latch.await(30, TimeUnit.SECONDS))
|
||||||
|
runBlocking {
|
||||||
// Restore production backoff so later tests see the default.
|
withTimeout(15_000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
|
||||||
|
}
|
||||||
|
assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value)
|
||||||
|
} finally {
|
||||||
|
// Restore production backoff so later tests see the default, even
|
||||||
|
// if an assertion above threw.
|
||||||
HermesWsClient.setReconnectBackoffForTest(1_000L)
|
HermesWsClient.setReconnectBackoffForTest(1_000L)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── TEST-10: WS reconnect state recovery ────────────────────────────
|
// ── TEST-10: WS reconnect state recovery ────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user