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:
M57andM57 authored and GitHub committed 2026-08-18 17:06:51 +03:00
1 parent 53c86d5871
commit 82e2d787af
7 files changed
+103 -26

No files matched your search

@@ -4,9 +4,25 @@ data class SessionTreeItem(
val session: SessionInfo,
val depth: Int,
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,
)
/**
* 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> {
val ids = sessions.mapTo(mutableSetOf()) { it.id }
val children = sessions.groupBy { it.parent_session_id?.takeIf(ids::contains) }
@@ -16,8 +32,8 @@ fun flattenSessionTree(sessions: List<SessionInfo>): List<SessionTreeItem> {
fun append(
session: SessionInfo,
depth: Int,
branchStem: String?,
siblingIndex: Int,
siblingCount: Int,
) {
if (!visited.add(session.id)) return
val isBranch = session.parent_session_id?.takeIf(ids::contains) != null
@@ -29,23 +45,32 @@ fun flattenSessionTree(sessions: List<SessionInfo>): List<SessionTreeItem> {
} else {
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()
descendants.forEachIndexed { index, child ->
append(
session = child,
depth = depth + 1,
branchStem = if (index == descendants.lastIndex) "└─" else "├─",
siblingIndex = index,
siblingCount = descendants.size,
)
}
}
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 ->
append(orphan, depth = 0, branchStem = null, siblingIndex = index)
append(orphan, depth = 0, siblingIndex = index, siblingCount = 1)
}
return result
}