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); + } +}