Skip to content

Conversation

@compulim
Copy link
Contributor

@compulim compulim commented Nov 19, 2025

Related to #5622.

Changelog Entry

Fixed

  • Fixed activity sorting introduced in PR #5622, part grouping, and livestreaming, by @compulim in PR #5635

Description

When receiving activities, they are sorted with an insertion sort algorithm. However, the algorithm did not handle grouping. Thus, activities in the same group could be separated as a new activity inserted in-between them.

This pull request will treat activity group as a unit.

Design

Logical timestamp

The technical definition of logical timestamp:

logicalTimestamp = activity.channelData['webchat:sequence-id'] ?? (+new Date(activity.timestamp) || +new Date(activity.localTimestamp))

The order is based on reliability of the value:

  1. Sequence ID: sorting hints from chat service
  2. Server-assigned timestamp: timestamp agreed by service and shared amongst clients
  3. Local timestamp: timestamp based on current client

Grouping

Currently, an activity can be grouped or ungrouped. There are 2 types of grouping for grouped activities:

  • Livestream session
    • "Livestream sequence" indicates its position in the session
  • Part grouping, such as HowTo/HowToStep (a.k.a. chain of thoughts)
    • Parts in this grouping can be livestream session
    • "Part position" indicates its position in the grouping

The order of grouping should be:

  1. Livestream session
  2. Part grouping
  3. Ungrouped

For example, given a part grouping contains 1 livestream session and 1 ungrouped, when sorted:

  1. Activity with "part grouping ID = 1, part position = 1, livestream ID = 1, livestream sequence = 1"
  2. Activity with "part grouping ID = 1, part position = 1, livestream ID = 1, livestream sequence = 2"
  3. Activity with "part grouping ID = 1, part position = 2, ungrouped"

Activities should be group-sorted by livestream before part grouping.

Sorting algorithm

Grouping and ungrouped activities are of their own unit. For example,

  • "Activity with part grouping ID = 1" is a single unit
    • Livestream session inside part grouping is sorted as well
  • "Activity with livestream ID = 1" is a single unit
  • "Ungrouped activity" is a single unit

Then, the units are sorted by their corresponding logical timestamp.

For ungrouped activities, their logical timestamp is of their own.

For grouped activities, their logical timestamp is:

  • For livestream session, the logical timestamp is
    • If the session is finalized, the logical timestamp of the final activity
    • Otherwise, the logical timestamp of the first activity
  • For part grouping, the logical timestamp of this unit is largest logical timestamp of all its parts
    • If the part grouping contains a livestream session, the logical timestamp of the livestream session will be used to compute the largest logical timestamp, and not the logical timestamp of individual activity in the livestream session

For example, for part grouping with livestream:

  1. Part grouping ID = 1, part position = 1, livestream ID = 1, livestream sequence = 1, timestamp = 1_000
  2. Part grouping ID = 1, part position = 1, livestream ID = 1, livestream sequence = 2, timestamp = 2_000, livestream not finalized
  3. Part grouping ID = 1, part position = 2, ungrouped, timestamp = 1_500

The logical timestamp of this part grouping will be 1_500.

  • The logical timestamp of part position = 1 is 1_000 (because livestream sequence = 2 is not a finalizing activity)
  • The logical timestamp of part position = 2 is 1_500
  • The largest logical timestamp is 1_500

What if 2 activities share the same timestamp?

As activities are sorted when inserted, their position will be fixed once they are inserted.

Technically, the sorting comparer is as follows:

function comparer(xLogicalTimestamp: number | undefined, yLogicalTimestamp: number | undefined): number {
  return (typeof xLogicalTimestamp === 'undefined' || typeof yLogicalTimestamp === 'undefined') ? -1 : xLogicalTimestamp - yLogicalTimestamp;
}

In plain English, "the inserting activity will be inserted right before the activity with a larger logical timestamp."

Why livestream session only look at logical timestamp of 1th and Nth revision but not 2nd...N-1th?

This is because livestream session are updated very frequently. If we look at 2nd...N-1th, the activity could be constantly move to the bottom of chat history.

In a simultaneous scenario where 2 livestream sessions are continuously updating, both sessions will "race" their position to the bottom and cause a lot of "jumps". This is not desirable in UX term.

Therefore, to reduce jumpiness, we are moving livestream sessions only when they are first opened or finalized.

New Redux reducer groupedActivities

New groupedActivities reducer is introduced to add internal states that are required outside of what current activities reducer provides.

For activities reducer, we kept the interface the same by copying part of the result from groupedActivities. This is done by a custom combineReducers function. New hooks or selectors need to be changed.

Specific Changes

  • Rewrote the sorting algorithm so grouped activities are sorted as a whole unit
  • Added a new groupedActivities reducer
    • The old activities reducer will reuse the result from groupedActivities reducer thru a custom combineReducers function
  • I have added tests and executed them locally
  • I have updated CHANGELOG.md
  • I have updated documentation

Review Checklist

This section is for contributors to review your work.

  • Accessibility reviewed (tab order, content readability, alt text, color contrast)
  • Browser and platform compatibilities reviewed
  • CSS styles reviewed (minimal rules, no z-index)
  • Documents reviewed (docs, samples, live demo)
  • Internationalization reviewed (strings, unit formatting)
  • package.json and package-lock.json reviewed
  • Security reviewed (no data URIs, check for nonce leak)
  • Tests reviewed (coverage, legitimacy)

Copy link
Collaborator

@OEvgeny OEvgeny Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this order?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't use livestreaming so the question remains

OEvgeny
OEvgeny previously approved these changes Nov 25, 2025
directLine.emulateIncomingActivity(withPosition(activities.at(2), 3, 'one updated to three'));

// Then: show all activities repositioned according to new positions
// Then: show the "one" activity moved before "foru"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Then: show the "one" activity moved before "foru"
// Then: show the "one" activity moved before "four"

@compulim compulim merged commit 3384b30 into main Nov 25, 2025
77 of 79 checks passed
@compulim compulim deleted the fix-sort branch November 25, 2025 03:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants