Skip to content

AI scenarios

Long conversations

Bit.BlazorUI.AI Scripted transport

Five hundred messages with code, tables, tool calls, and citations, rendered through a virtualized list.

Live demo

Scroll to the top and back, then send something: new content follows the view only while you are already at the bottom.

\n**Five hundred messages**: ```razor @implements IAsyncDisposable
``` ```csharp private readonly BitAiUiOptions options = BitAiUiOptions.Default with { ShowTimestamps = true, VirtualizeThreshold = 60, }; private BitAiChatSession? session; [Inject] private BitAiChatSessionFactory SessionFactory { get; set; } = default!; // A session over a conversation that already has five hundred messages. protected override void OnInitialized() => session = SessionFactory.Create(AiDemoConversations.Long(500)); public ValueTask DisposeAsync() => session?.DisposeAsync() ?? ValueTask.CompletedTask; ``` `AiDemoConversations.cs`: ```csharp public static BitAiConversation Long(int messageCount) { var conversation = new BitAiConversation(); conversation.SetTitle($"{messageCount} message conversation"); var source = new BitAiCitationSource("s1", "Performance notes") { Kind = BitAiSourceKind.Document, SiteName = "Internal wiki", Url = "https://example.com/perf", }; for (var i = 0; i < messageCount; i += 2) { conversation.AddMessage(BitAiRole.User, $"Question {i / 2 + 1}: what happens at scale here?"); var assistant = new BitAiMessage(BitAiRole.Assistant); // Prose, code, a tool call with its result, and a table with a citation, in turn. switch ((i / 2) % 4) { case 0: assistant.AddPart(new BitAiTextPart("The cost is dominated by rendering, not by parsing.")); break; case 1: assistant.AddPart(new BitAiMarkdownPart("```csharp\nscheduler.Request();\n```")); break; case 2: var call = assistant.AddPart(new BitAiToolCallPart(BitAiId.NewToolCallId(), "search_docs")); call.CompleteArguments(AiScript.Json($$"""{"query":"scale {{i}}"}""")); call.SetResult(BitAiToolOutcome.Success(AiDemoCatalog.SearchHits($"scale {i}"), AiDemoJsonContext.Default.SearchResult)); break; default: assistant.AddPart(new BitAiMarkdownPart("| Messages | Rendered |\n| --- | ---: |\n| 500 | visible only |\n")); assistant.AddCitation(new BitAiCitation(BitAiId.New("cit"), source)); break; } // What a transport would have reported: the model, and the tokens the answer took. assistant.MergeMetadata(new BitAiGenerationMetadata { ModelId = AiDemoCatalog.DefaultModelId, ContextWindow = 200_000, Usage = new BitAiTokenUsage { InputTokens = 120 + i, OutputTokens = 60 + (i % 90) }, }); conversation.AddMessage(assistant); } return conversation; } ```

How it works

Up to a threshold every message is rendered, which is both simpler and faster. Past it (BitAiUiOptions.VirtualizeThreshold, sixty messages here) only the newest sixty are: scroll up and the older ones come in thirty at a time above what you are reading, which stays where it is, and once you are back at the newest message they are let go of again. Nothing is estimated - every message drawn is drawn at its own height - so the content never jumps and the view never goes blank. The conversation is deliberately varied - a thousand identical paragraphs would prove nothing; the hard case is mixed content with wildly different heights.

The five hundred messages are built in code (AiDemoConversations.Long) - a BitAiConversation filled with AddMessage, each answer a BitAiMessage of parts: text, Markdown, a tool call with its result, a table with a citation - and handed to the session factory (SessionFactory.Create(conversation)), so new messages go to the transport registered for the site, which echoes them. A conversation restored from storage is handed over the same way (see Conversations).

Where the render budget goes:

  • A part subscribes to itself, so a token re-renders the part it went into - not its message, nor the transcript. The chat around it renders as a turn starts and as it ends.
  • Each of those views renders at most once per 50 ms (BitAiUiOptions.RenderInterval), however many tokens arrive in that time - coalesced by a BitAiRenderScheduler, which an app's own component listening to a stream can use too (see Streaming).
  • Bursts of text are evened out before they reach the conversation: the session paces the deltas (BitAiChatSessionOptions.Pacing), so a paragraph arriving at once is drawn over a few frames rather than in one.
  • Text accumulates in a builder, so appending stays linear.
  • Markdown before the last block boundary is parsed once and reused.

Components used

  • BitAiMessageList - the transcript, drawing only its newest messages past the threshold, with auto-scroll that follows only a reader at the newest message.
  • BitAiScrollToLatest - the way back down once the reader has scrolled away.
  • BitAiMessagePartView - one part, re-rendered only when it changes.

Feedback

Found a mistake, a gap, or something that could be clearer? Every page and every component is one click from its source.