diff --git a/src/MeshWeaver.Layout/ToolCallVisibility.cs b/src/MeshWeaver.Layout/ToolCallVisibility.cs
new file mode 100644
index 000000000..f3b649f55
--- /dev/null
+++ b/src/MeshWeaver.Layout/ToolCallVisibility.cs
@@ -0,0 +1,99 @@
+namespace MeshWeaver.Layout;
+
+///
+/// Decides which of a message's entries the chat
+/// tool-calls section shows up front and which collapse into a "show all"
+/// control. Keeps the bubble compact while a round fans out across many tools.
+///
+/// The window is filled, in order:
+///
+/// - Running first (a delegation actively streaming its sub-thread).
+/// - Pending next (dispatched, result not back yet).
+/// - Completed backfill — when fewer than
+/// are active, the most-recently-finished calls fill the remaining slots so the window stays full.
+///
+///
+/// A just-finished call also lingers in the window for
+/// even when the active set already fills the cap,
+/// so a completion flashes its ✓ before dropping into the collapsed remainder.
+///
+/// Pure + deterministic (the caller passes nowUtc) so it unit-tests
+/// without a clock and renders identically server-side and in the circuit.
+///
+public static class ToolCallVisibility
+{
+ /// Maximum number of active (running + pending) entries kept in the visible window.
+ public const int DefaultVisibleCap = 5;
+
+ /// How long a freshly-completed call stays in the visible window before it may collapse.
+ public static readonly TimeSpan CompletedLinger = TimeSpan.FromSeconds(5);
+
+ /// Actively executing — only a delegation streaming its sub-thread carries this status.
+ public static bool IsRunning(ToolCallEntry call) => call.Status == ToolCallStatus.Streaming;
+
+ ///
+ /// Dispatched but no result yet. defaults to
+ /// via the record initializer even before the result lands,
+ /// so "pending" is "default status, result still null" — a terminal Failed/Cancelled counts as completed.
+ ///
+ public static bool IsPending(ToolCallEntry call) =>
+ call.Status == ToolCallStatus.Success && call.Result is null;
+
+ /// Settled — has a result, or reached a terminal Failed/Cancelled status.
+ public static bool IsCompleted(ToolCallEntry call) => !IsRunning(call) && !IsPending(call);
+
+ /// A completed call within the window of .
+ public static bool IsFreshlyCompleted(ToolCallEntry call, DateTime nowUtc) =>
+ IsCompleted(call) && nowUtc - call.Timestamp < CompletedLinger;
+
+ /// The split of a tool-call list into the visible window and the collapsed remainder.
+ /// Entries shown up front (running → pending → completed backfill / lingering).
+ /// Entries folded into the "show all" control.
+ /// Total running across the whole list.
+ /// Total pending across the whole list.
+ /// Total completed across the whole list.
+ public sealed record View(
+ IReadOnlyList Visible,
+ IReadOnlyList Hidden,
+ int RunningCount,
+ int PendingCount,
+ int CompletedCount)
+ {
+ /// True when there is a collapsed remainder worth a "show all" control.
+ public bool HasHidden => Hidden.Count > 0;
+ }
+
+ ///
+ /// Partitions into the visible window and the collapsed remainder.
+ ///
+ /// The message's tool calls (null/empty yields an empty view).
+ /// Reference time for the linger window — pass DateTime.UtcNow.
+ /// Active-window size (defaults to ).
+ public static View Partition(IReadOnlyList? calls, DateTime nowUtc, int cap = DefaultVisibleCap)
+ {
+ if (calls is null || calls.Count == 0)
+ return new View([], [], 0, 0, 0);
+
+ var running = calls.Where(IsRunning).ToList();
+ var pending = calls.Where(IsPending).ToList();
+ // Most-recently-finished first, so backfill and linger surface the newest completions.
+ var completed = calls.Where(IsCompleted).OrderByDescending(c => c.Timestamp).ToList();
+
+ // Active owns the window: running on top, then pending, capped.
+ var active = running.Concat(pending).ToList();
+ var activeShown = active.Take(cap).ToList();
+
+ // Backfill leftover slots with the newest completed ("when fewer than 5, also show closed");
+ // ALSO keep any just-finished call on screen for the linger window even when the cap is full.
+ var slots = Math.Max(0, cap - activeShown.Count);
+ var freshCount = completed.Count(c => IsFreshlyCompleted(c, nowUtc));
+ var completedShown = completed.Take(Math.Max(slots, freshCount)).ToList();
+
+ var visible = activeShown.Concat(completedShown).ToList();
+ var hidden = active.Skip(activeShown.Count)
+ .Concat(completed.Skip(completedShown.Count))
+ .ToList();
+
+ return new View(visible, hidden, running.Count, pending.Count, completed.Count);
+ }
+}