fix(ui): stabilize workplace chat scrolling

This commit is contained in:
LZH-YS1998
2026-07-13 23:02:39 +08:00
parent 4e7aa75ba5
commit 4bc18dcd27
46 changed files with 5937 additions and 1258 deletions
+97
View File
@@ -0,0 +1,97 @@
"""Shared visibility rules for persisted session transcript messages.
The database pager and the Office UI renderer must agree on this boundary.
If either side independently classifies transcript kinds, a summary request can
page over rows that the renderer later drops and leave the caller with a cursor
that never advances through the visible timeline.
"""
from __future__ import annotations
from typing import Any, Literal, Mapping
TranscriptDetailLevel = Literal["summary", "full"]
FULL_DETAIL_ONLY_TRANSCRIPT_KINDS: frozenset[str] = frozenset({
"runtime_v2_user_turn",
"runtime_v2_intermediate_assistant",
"runtime_v2_company_assistant",
"runtime_v2_tool_output",
})
def normalize_transcript_detail_level(value: Any) -> TranscriptDetailLevel:
return "full" if str(value or "").strip().lower() == "full" else "summary"
def transcript_metadata_visible(
metadata: Mapping[str, Any] | None,
*,
detail_level: TranscriptDetailLevel | str = "summary",
) -> bool:
"""Return whether a persisted transcript row belongs to a detail view.
``company_final_turn`` deliberately overrides the kind classification: a
company role's final reply is the durable user-visible result even when its
transport kind is normally reserved for the full execution transcript.
"""
if normalize_transcript_detail_level(detail_level) == "full":
return True
normalized_metadata = dict(metadata or {})
if normalized_metadata.get("company_final_turn") is True:
return True
kind = str(normalized_metadata.get("kind", "") or "").strip()
return kind not in FULL_DETAIL_ONLY_TRANSCRIPT_KINDS
def transcript_visibility_sql(
*,
detail_level: TranscriptDetailLevel | str,
metadata_column: str = "metadata",
) -> tuple[str, tuple[str, ...]]:
"""Build the SQLite predicate equivalent of ``transcript_metadata_visible``.
``metadata_column`` is supplied only by internal, static query construction;
callers must not pass user-controlled identifiers.
"""
if normalize_transcript_detail_level(detail_level) == "full":
return "", ()
placeholders = ",".join("?" for _ in FULL_DETAIL_ONLY_TRANSCRIPT_KINDS)
predicate = (
"AND (COALESCE(json_extract("
f"{metadata_column}, '$.company_final_turn'), 0) = 1 "
"OR COALESCE(json_extract("
f"{metadata_column}, '$.kind'), '') NOT IN ({placeholders})) "
)
return predicate, tuple(sorted(FULL_DETAIL_ONLY_TRANSCRIPT_KINDS))
def rendered_transcript_metadata_visible(
metadata: Mapping[str, Any] | None,
*,
detail_level: TranscriptDetailLevel | str = "summary",
) -> bool:
"""Apply the visibility marker written by the transcript renderer."""
if normalize_transcript_detail_level(detail_level) == "full":
return True
visibility = str(dict(metadata or {}).get("detail_visibility", "summary") or "summary")
return visibility.strip().lower() != "full"
def rendered_transcript_visibility_sql(
*,
detail_level: TranscriptDetailLevel | str,
metadata_column: str = "metadata",
) -> str:
"""SQLite predicate equivalent of ``rendered_transcript_metadata_visible``."""
if normalize_transcript_detail_level(detail_level) == "full":
return ""
return (
" AND lower(COALESCE(json_extract("
f"{metadata_column}, '$.detail_visibility'), 'summary')) != 'full'"
)
+11 -12
View File
@@ -65,6 +65,10 @@ from opc.core.models import (
normalize_role_runtime_status,
)
from opc.core.models import Phase
from opc.core.transcript_visibility import (
normalize_transcript_detail_level,
transcript_visibility_sql,
)
from opc.layer2_organization.phase import (
DONE_PHASES,
IN_PROGRESS_PHASES,
@@ -6192,20 +6196,17 @@ class OPCStore:
) -> dict[str, Any]:
assert self._db
normalized_limit = max(1, min(int(limit), 500))
normalized_detail_level = str(detail_level or "summary").strip().lower()
hidden_kinds = () if normalized_detail_level == "full" else (
"runtime_v2_user_turn",
"runtime_v2_assistant",
normalized_detail_level = normalize_transcript_detail_level(detail_level)
visibility_sql, visibility_params = transcript_visibility_sql(
detail_level=normalized_detail_level,
)
query = (
"SELECT * FROM session_messages "
"WHERE session_id = ? AND summary_flag = 0 "
)
params: list[Any] = [session_id]
if hidden_kinds:
placeholders = ",".join("?" for _ in hidden_kinds)
query += f"AND COALESCE(json_extract(metadata, '$.kind'), '') NOT IN ({placeholders}) "
params.extend(hidden_kinds)
query += visibility_sql
params.extend(visibility_params)
normalized_before_id = str(before_message_id or "").strip()
if before_created_at is not None:
before_iso = before_created_at.isoformat()
@@ -6246,10 +6247,8 @@ class OPCStore:
"WHERE session_id = ? AND summary_flag = 0 "
)
count_params: list[Any] = [session_id]
if hidden_kinds:
placeholders = ",".join("?" for _ in hidden_kinds)
count_query += f"AND COALESCE(json_extract(metadata, '$.kind'), '') NOT IN ({placeholders})"
count_params.extend(hidden_kinds)
count_query += visibility_sql
count_params.extend(visibility_params)
async with self._db.execute(count_query, count_params) as cursor:
row = await cursor.fetchone()
total_count = int(row[0] or 0) if row else 0
+657 -86
View File
@@ -7,14 +7,19 @@ Channel/message format uses snake_case to match what collabSync.ts expects.
from __future__ import annotations
import asyncio
import heapq
import json
import math
import re
import sqlite3
import struct
import time
import uuid
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
import aiosqlite
from opc.core.transcript_visibility import rendered_transcript_visibility_sql
from opc.layer3_agent.adapters.codex_adapter import CodexAdapter
_LOCKED_ERROR_MARKERS = ("database is locked", "database table is locked")
@@ -28,6 +33,505 @@ def _is_locked_error(exc: BaseException) -> bool:
)
@dataclass(frozen=True)
class _MessageMatchState:
"""Prepared fields used by semantic message de-duplication.
Transcript rows can contain several kilobytes of Markdown. Preparing the
normalized content once prevents every candidate comparison from repeating
that work.
"""
channel_id: str
identity_keys: frozenset[str]
role_bucket: str
normalized_content: str
reply_to_id: str
is_result_surface: bool
has_engine_source: bool
timestamp: float
@classmethod
def from_message(
cls,
owner: Any,
message: dict[str, Any],
) -> _MessageMatchState:
return cls(
channel_id=str(message.get("channel_id", "") or ""),
identity_keys=frozenset(owner._message_identity_keys(message)),
role_bucket=owner._message_role_bucket(message),
normalized_content=owner._normalize_duplicate_content(message.get("content", "")),
reply_to_id=str(message.get("reply_to_id", "") or ""),
is_result_surface=owner._message_is_result_surface(message),
has_engine_source=owner._message_has_engine_source(message),
timestamp=owner._message_timestamp(message),
)
def matches(self, candidate: _MessageMatchState, *, duplicate_window: float) -> bool:
if self.channel_id != candidate.channel_id:
return False
if self.identity_keys & candidate.identity_keys:
return True
if self.role_bucket != candidate.role_bucket:
return False
if self.normalized_content != candidate.normalized_content:
return False
both_result_surfaces = self.is_result_surface and candidate.is_result_surface
if not both_result_surfaces and self.reply_to_id != candidate.reply_to_id:
return False
if not (self.has_engine_source or candidate.has_engine_source):
return False
if (
not both_result_surfaces
and self.timestamp
and candidate.timestamp
and abs(self.timestamp - candidate.timestamp) > duplicate_window
):
return False
return True
@dataclass
class _TimestampRangeNode:
"""Treap node augmented with the greatest timeline index below it."""
key: tuple[float, int]
priority: int
left: _TimestampRangeNode | None = None
right: _TimestampRangeNode | None = None
max_index: int = -1
def __post_init__(self) -> None:
self.max_index = self.key[1]
class _TimestampRangeTree:
"""Dynamic timestamp range -> latest timeline index map.
A deterministic treap avoids depending on insertion order while supporting
insert, delete, and inclusive range maximum in expected O(log n).
"""
_MASK_64 = (1 << 64) - 1
def __init__(self) -> None:
self._root: _TimestampRangeNode | None = None
@classmethod
def _priority(cls, index: int) -> int:
# SplitMix64 is a bijective mixer for the practical index range, giving
# deterministic pseudo-random treap priorities without global RNG state.
value = (index + 0x9E3779B97F4A7C15) & cls._MASK_64
value = ((value ^ (value >> 30)) * 0xBF58476D1CE4E5B9) & cls._MASK_64
value = ((value ^ (value >> 27)) * 0x94D049BB133111EB) & cls._MASK_64
return value ^ (value >> 31)
@staticmethod
def _refresh(node: _TimestampRangeNode | None) -> None:
if node is None:
return
node.max_index = max(
node.key[1],
node.left.max_index if node.left is not None else -1,
node.right.max_index if node.right is not None else -1,
)
@classmethod
def _split(
cls,
node: _TimestampRangeNode | None,
key: tuple[float, float | int],
) -> tuple[_TimestampRangeNode | None, _TimestampRangeNode | None]:
if node is None:
return None, None
if node.key < key:
node.right, right = cls._split(node.right, key)
cls._refresh(node)
return node, right
left, node.left = cls._split(node.left, key)
cls._refresh(node)
return left, node
@classmethod
def _merge(
cls,
left: _TimestampRangeNode | None,
right: _TimestampRangeNode | None,
) -> _TimestampRangeNode | None:
if left is None:
return right
if right is None:
return left
if left.priority > right.priority:
left.right = cls._merge(left.right, right)
cls._refresh(left)
return left
right.left = cls._merge(left, right.left)
cls._refresh(right)
return right
@classmethod
def _insert(
cls,
root: _TimestampRangeNode | None,
node: _TimestampRangeNode,
) -> _TimestampRangeNode:
if root is None:
return node
if node.key == root.key:
return root
if node.priority > root.priority:
node.left, node.right = cls._split(root, node.key)
cls._refresh(node)
return node
if node.key < root.key:
root.left = cls._insert(root.left, node)
else:
root.right = cls._insert(root.right, node)
cls._refresh(root)
return root
@classmethod
def _remove(
cls,
root: _TimestampRangeNode | None,
key: tuple[float, int],
) -> _TimestampRangeNode | None:
if root is None:
return None
if key == root.key:
return cls._merge(root.left, root.right)
if key < root.key:
root.left = cls._remove(root.left, key)
else:
root.right = cls._remove(root.right, key)
cls._refresh(root)
return root
def add(self, timestamp: float, index: int) -> None:
self._root = self._insert(
self._root,
_TimestampRangeNode(
key=(timestamp, index),
priority=self._priority(index),
),
)
def remove(self, timestamp: float, index: int) -> None:
self._root = self._remove(self._root, (timestamp, index))
def latest(self) -> int | None:
return self._root.max_index if self._root is not None else None
def latest_in_range(self, lower: float, upper: float) -> int | None:
left, middle_and_right = self._split(self._root, (lower, -1))
middle, right = self._split(middle_and_right, (upper, math.inf))
result = middle.max_index if middle is not None else None
self._root = self._merge(left, self._merge(middle, right))
return result
class _MessageMatchIndex:
"""Versioned indexes for finding the latest semantic duplicate.
The legacy implementation scanned every previously emitted row backwards.
Identity and result-surface matches use max-heaps. Ordinary matches use an
exact timestamp range tree, so backfilling old history cannot repeatedly
scan newer same-content rows outside the duplicate window. Replacing a
merged row bumps its heap version and moves its timestamp-tree entry.
"""
def __init__(
self,
owner: Any,
messages: list[dict[str, Any]],
) -> None:
self._owner = owner
self._messages = messages
self._versions: list[int] = [0 for _ in messages]
self._states = [
_MessageMatchState.from_message(owner, message)
for message in messages
]
self._identity_heaps: dict[tuple[str, str], list[tuple[int, int]]] = {}
self._timed_trees: dict[tuple[str, str, str, str], _TimestampRangeTree] = {}
self._timed_engine_trees: dict[tuple[str, str, str, str], _TimestampRangeTree] = {}
self._timed_unbounded_heaps: dict[tuple[str, str, str, str], list[tuple[int, int]]] = {}
self._timed_engine_unbounded_heaps: dict[tuple[str, str, str, str], list[tuple[int, int]]] = {}
self._result_heaps: dict[tuple[str, str, str], list[tuple[int, int]]] = {}
self._result_engine_heaps: dict[tuple[str, str, str], list[tuple[int, int]]] = {}
for index in range(len(messages)):
self._push(index)
@staticmethod
def _push_heap(
heaps: dict[Any, list[tuple[int, int]]],
key: Any,
index: int,
version: int,
) -> None:
heapq.heappush(heaps.setdefault(key, []), (-index, version))
@staticmethod
def _semantic_key(state: _MessageMatchState) -> tuple[str, str, str]:
return (state.channel_id, state.role_bucket, state.normalized_content)
@classmethod
def _timed_key(cls, state: _MessageMatchState) -> tuple[str, str, str, str]:
return (*cls._semantic_key(state), state.reply_to_id)
@staticmethod
def _timestamp_is_unbounded(timestamp: float) -> bool:
# The legacy predicate deliberately skipped its window check for zero;
# NaN also made ``abs(delta) > window`` false and must stay equivalent.
return timestamp == 0 or math.isnan(timestamp)
@staticmethod
def _float_to_ordered_int(value: float) -> int:
"""Map an IEEE-754 double to an integer with numeric sort order."""
bits = struct.unpack(">Q", struct.pack(">d", value))[0]
if bits & (1 << 63):
return (~bits) & ((1 << 64) - 1)
return bits | (1 << 63)
@staticmethod
def _ordered_int_to_float(value: int) -> float:
if value & (1 << 63):
bits = value & ((1 << 63) - 1)
else:
bits = (~value) & ((1 << 64) - 1)
return struct.unpack(">d", struct.pack(">Q", bits))[0]
@classmethod
def _finite_timestamp_match_bounds(
cls,
candidate_timestamp: float,
window: float,
) -> tuple[float, float]:
"""Exact finite-float bounds accepted by the legacy delta predicate.
Computing ``candidate +/- window`` is subtly insufficient around a
rounding boundary (for example ``2.0 - (-1e-300)`` rounds to exactly
``2.0``). Binary searching the ordered double domain preserves the
old IEEE-754 comparison exactly. The domain is fixed at 64 bits, so
this adds constant work before the tree's O(log n) range query.
"""
candidate_order = cls._float_to_ordered_int(candidate_timestamp)
lower_rejected = cls._float_to_ordered_int(-math.inf)
lower_accepted = candidate_order
while lower_rejected + 1 < lower_accepted:
middle = (lower_rejected + lower_accepted) // 2
value = cls._ordered_int_to_float(middle)
if abs(value - candidate_timestamp) <= window:
lower_accepted = middle
else:
lower_rejected = middle
upper_accepted = candidate_order
upper_rejected = cls._float_to_ordered_int(math.inf)
while upper_accepted + 1 < upper_rejected:
middle = (upper_accepted + upper_rejected) // 2
value = cls._ordered_int_to_float(middle)
if abs(value - candidate_timestamp) <= window:
upper_accepted = middle
else:
upper_rejected = middle
return (
cls._ordered_int_to_float(lower_accepted),
cls._ordered_int_to_float(upper_accepted),
)
@staticmethod
def _add_to_tree(
trees: dict[tuple[str, str, str, str], _TimestampRangeTree],
key: tuple[str, str, str, str],
timestamp: float,
index: int,
) -> None:
trees.setdefault(key, _TimestampRangeTree()).add(timestamp, index)
def _push(self, index: int) -> None:
state = self._states[index]
version = self._versions[index]
for identity_key in state.identity_keys:
self._push_heap(
self._identity_heaps,
(state.channel_id, identity_key),
index,
version,
)
semantic_key = self._semantic_key(state)
timed_key = self._timed_key(state)
if self._timestamp_is_unbounded(state.timestamp):
self._push_heap(
self._timed_unbounded_heaps,
timed_key,
index,
version,
)
if state.has_engine_source:
self._push_heap(
self._timed_engine_unbounded_heaps,
timed_key,
index,
version,
)
else:
self._add_to_tree(
self._timed_trees,
timed_key,
state.timestamp,
index,
)
if state.has_engine_source:
self._add_to_tree(
self._timed_engine_trees,
timed_key,
state.timestamp,
index,
)
if state.is_result_surface:
self._push_heap(self._result_heaps, semantic_key, index, version)
if state.has_engine_source:
self._push_heap(self._result_engine_heaps, semantic_key, index, version)
def prepare(self, message: dict[str, Any]) -> _MessageMatchState:
return _MessageMatchState.from_message(self._owner, message)
def append(
self,
message: dict[str, Any],
*,
prepared_state: _MessageMatchState | None = None,
) -> int:
index = len(self._messages)
self._messages.append(message)
self._versions.append(0)
self._states.append(prepared_state or self.prepare(message))
self._push(index)
return index
def replace(self, index: int, message: dict[str, Any]) -> None:
old_state = self._states[index]
if not self._timestamp_is_unbounded(old_state.timestamp):
timed_key = self._timed_key(old_state)
tree = self._timed_trees.get(timed_key)
if tree is not None:
tree.remove(old_state.timestamp, index)
if old_state.has_engine_source:
engine_tree = self._timed_engine_trees.get(timed_key)
if engine_tree is not None:
engine_tree.remove(old_state.timestamp, index)
self._messages[index] = message
self._versions[index] += 1
self._states[index] = _MessageMatchState.from_message(self._owner, message)
self._push(index)
def _latest_from_heap(
self,
heap: list[tuple[int, int]] | None,
candidate: _MessageMatchState,
excluded_message_ids: set[str],
) -> int | None:
if not heap:
return None
while heap:
negative_index, version = heap[0]
index = -negative_index
if version != self._versions[index]:
heapq.heappop(heap)
continue
message_id = str(self._messages[index].get("message_id", "") or "")
if message_id in excluded_message_ids:
heapq.heappop(heap)
continue
existing = self._states[index]
if existing.matches(
candidate,
duplicate_window=self._owner._DUPLICATE_WINDOW_SECONDS,
):
return index
# Identity/result/unbounded timed buckets are exact. A current
# non-match therefore cannot become valid for this bucket later.
heapq.heappop(heap)
return None
def _latest_from_time_tree(
self,
tree: _TimestampRangeTree | None,
candidate: _MessageMatchState,
excluded_message_ids: set[str],
) -> int | None:
if tree is None:
return None
while True:
if self._timestamp_is_unbounded(candidate.timestamp):
index = tree.latest()
else:
window = self._owner._DUPLICATE_WINDOW_SECONDS
if math.isfinite(candidate.timestamp):
lower, upper = self._finite_timestamp_match_bounds(
candidate.timestamp,
window,
)
else:
lower = upper = candidate.timestamp
index = tree.latest_in_range(lower, upper)
if index is None:
return None
existing = self._states[index]
message_id = str(self._messages[index].get("message_id", "") or "")
if message_id not in excluded_message_ids and existing.matches(
candidate,
duplicate_window=self._owner._DUPLICATE_WINDOW_SECONDS,
):
return index
# Consumed entries never become eligible again during this index's
# lifetime. Signature changes use ``replace`` and reinsert exactly.
tree.remove(existing.timestamp, index)
def latest_match(
self,
candidate_message: dict[str, Any],
*,
excluded_message_ids: set[str] | None = None,
prepared_state: _MessageMatchState | None = None,
) -> int | None:
candidate = prepared_state or self.prepare(candidate_message)
excluded = excluded_message_ids or set()
heaps: list[list[tuple[int, int]] | None] = []
for identity_key in candidate.identity_keys:
heaps.append(self._identity_heaps.get((candidate.channel_id, identity_key)))
semantic_key = self._semantic_key(candidate)
timed_key = self._timed_key(candidate)
if candidate.has_engine_source:
timed_tree = self._timed_trees.get(timed_key)
heaps.append(self._timed_unbounded_heaps.get(timed_key))
else:
timed_tree = self._timed_engine_trees.get(timed_key)
heaps.append(self._timed_engine_unbounded_heaps.get(timed_key))
if candidate.is_result_surface:
if candidate.has_engine_source:
heaps.append(self._result_heaps.get(semantic_key))
else:
heaps.append(self._result_engine_heaps.get(semantic_key))
matches: list[int] = [
index
for heap in heaps
if (index := self._latest_from_heap(heap, candidate, excluded)) is not None
]
timed_index = self._latest_from_time_tree(timed_tree, candidate, excluded)
if timed_index is not None:
matches.append(timed_index)
return max(matches) if matches else None
class ChatStore:
"""Chat channels + messages in ui_state.db.
@@ -167,29 +671,10 @@ class ChatStore:
existing: dict[str, Any],
candidate: dict[str, Any],
) -> bool:
if str(existing.get("channel_id", "") or "") != str(candidate.get("channel_id", "") or ""):
return False
if cls._message_identity_keys(existing) & cls._message_identity_keys(candidate):
return True
if cls._message_role_bucket(existing) != cls._message_role_bucket(candidate):
return False
if cls._normalize_duplicate_content(existing.get("content", "")) != cls._normalize_duplicate_content(candidate.get("content", "")):
return False
both_result_surfaces = cls._message_is_result_surface(existing) and cls._message_is_result_surface(candidate)
if not both_result_surfaces and str(existing.get("reply_to_id", "") or "") != str(candidate.get("reply_to_id", "") or ""):
return False
if not (cls._message_has_engine_source(existing) or cls._message_has_engine_source(candidate)):
return False
existing_ts = cls._message_timestamp(existing)
candidate_ts = cls._message_timestamp(candidate)
if (
not both_result_surfaces
and existing_ts
and candidate_ts
and abs(existing_ts - candidate_ts) > cls._DUPLICATE_WINDOW_SECONDS
):
return False
return True
return _MessageMatchState.from_message(cls, existing).matches(
_MessageMatchState.from_message(cls, candidate),
duplicate_window=cls._DUPLICATE_WINDOW_SECONDS,
)
@classmethod
def _merge_duplicate_messages(
@@ -266,16 +751,23 @@ class ChatStore:
def _dedupe_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
deduped: list[dict[str, Any]] = []
match_index = _MessageMatchIndex(
self,
deduped,
)
for message in sorted(messages, key=self._message_timestamp):
match_index: int | None = None
for index in range(len(deduped) - 1, -1, -1):
if self._messages_semantically_match(deduped[index], message):
match_index = index
break
if match_index is None:
deduped.append(message)
prepared_state = match_index.prepare(message)
duplicate_index = match_index.latest_match(
message,
prepared_state=prepared_state,
)
if duplicate_index is None:
match_index.append(message, prepared_state=prepared_state)
continue
deduped[match_index] = self._merge_duplicate_messages(deduped[match_index], message)
match_index.replace(
duplicate_index,
self._merge_duplicate_messages(deduped[duplicate_index], message),
)
return deduped
async def _message_scope(self, message_id: str) -> tuple[str, str] | None:
@@ -1060,6 +1552,14 @@ class ChatStore:
existing_rows = await cursor.fetchall()
existing_messages = [self._row_to_message_dict(row) for row in existing_rows]
existing_ids = {message["message_id"] for message in existing_messages}
existing_positions = {
message["message_id"]: index
for index, message in enumerate(existing_messages)
}
semantic_index = _MessageMatchIndex(
self,
existing_messages,
)
consumed_existing_ids: set[str] = set()
inserted_messages: list[dict[str, Any]] = []
changed_existing = False
@@ -1078,11 +1578,9 @@ class ChatStore:
}
mid = normalized_message["message_id"]
if mid in existing_ids:
existing_match = next(
(existing for existing in existing_messages if existing["message_id"] == mid),
None,
)
if existing_match is not None:
existing_index = existing_positions.get(mid)
if existing_index is not None:
existing_match = existing_messages[existing_index]
merged_existing = self._merge_duplicate_messages(existing_match, normalized_message)
if not self._message_persisted_equal(existing_match, merged_existing):
merged_timestamp = self._message_timestamp(merged_existing) or time.time()
@@ -1102,13 +1600,10 @@ class ChatStore:
project_id,
),
)
for idx, existing in enumerate(existing_messages):
if existing["message_id"] == mid:
existing_messages[idx] = {
**merged_existing,
"created_at": merged_timestamp,
}
break
semantic_index.replace(existing_index, {
**merged_existing,
"created_at": merged_timestamp,
})
inserted_messages.append({
**merged_existing,
"channel_id": channel_id,
@@ -1131,7 +1626,7 @@ class ChatStore:
)
if merged is not None:
existing_ids.add(mid)
existing_messages.append(merged)
existing_positions[mid] = semantic_index.append(merged)
continue
if existing_scope and existing_scope != (channel_id, project_id):
metadata = dict(normalized_message.get("metadata", {}) or {})
@@ -1144,17 +1639,14 @@ class ChatStore:
)
normalized_message["message_id"] = mid
duplicate_existing = next(
(
existing
for existing in reversed(existing_messages)
if existing["message_id"] not in consumed_existing_ids
and self._messages_semantically_match(existing, normalized_message)
),
None,
duplicate_index = semantic_index.latest_match(
normalized_message,
excluded_message_ids=consumed_existing_ids,
)
if duplicate_existing is not None:
consumed_existing_ids.add(duplicate_existing["message_id"])
if duplicate_index is not None:
consumed_existing_ids.add(
existing_messages[duplicate_index]["message_id"]
)
continue
try:
@@ -1184,8 +1676,9 @@ class ChatStore:
candidate=normalized_message,
)
if merged is not None:
existing_ids.add(normalized_message["message_id"])
existing_messages.append(merged)
merged_id = normalized_message["message_id"]
existing_ids.add(merged_id)
existing_positions[merged_id] = semantic_index.append(merged)
continue
metadata = dict(normalized_message.get("metadata", {}) or {})
metadata.setdefault("ui_message_id", normalized_message["message_id"])
@@ -1216,7 +1709,7 @@ class ChatStore:
)
inserted_messages.append(normalized_message)
existing_ids.add(mid)
existing_messages.append({
existing_positions[mid] = semantic_index.append({
**normalized_message,
"created_at": normalized_message["timestamp"],
})
@@ -1253,49 +1746,124 @@ class ChatStore:
limit: int = 100,
before_timestamp: float | None = None,
before_message_id: str | None = None,
detail_level: str = "full",
project_id: str = "default",
) -> list[dict[str, Any]]:
"""Return a paginated, de-duplicated channel slice in chronological order."""
fetch_limit = max(limit * 8, limit + 1, 1)
"""Return the message slice from :meth:`get_channel_messages_page_info`.
This compatibility wrapper intentionally delegates cursor handling to
the exact pager so callers cannot accidentally paginate raw rows before
renderer visibility and semantic de-duplication have been applied.
"""
page = await self.get_channel_messages_page_info(
channel_id,
limit=limit,
before_timestamp=before_timestamp,
before_message_id=before_message_id,
detail_level=detail_level,
project_id=project_id,
)
return page["messages"]
async def _get_channel_visible_messages(
self,
channel_id: str,
*,
detail_level: str,
project_id: str,
) -> list[dict[str, Any]]:
"""Load the final UI-visible, de-duplicated channel timeline.
The cache stores both transcript backfill and UI-only rows such as
approval cards and legacy notices. SQL can exclude detail-only rows,
but only the message formatter's semantic merge can determine the
final rows. Consequently the merge must happen across the complete
visible set before a page boundary is chosen.
"""
query = (
"SELECT message_id, channel_id, sender, sender_name, content, "
"timestamp, reply_to_id, mentions, metadata "
"FROM messages WHERE channel_id = ? AND project_id = ?"
)
params: list[Any] = [channel_id, project_id]
query += rendered_transcript_visibility_sql(
detail_level=detail_level,
)
query += " ORDER BY timestamp ASC, message_id ASC"
cursor = await self._db.execute(query, (channel_id, project_id))
rows = await cursor.fetchall()
messages = [self._row_to_message_dict(row) for row in rows]
deduped = self._dedupe_messages(messages)
return sorted(
deduped,
key=lambda message: (
self._message_timestamp(message),
str(message.get("message_id", "") or ""),
),
)
async def get_channel_messages_page_info(
self,
channel_id: str,
*,
limit: int = 100,
before_timestamp: float | None = None,
before_message_id: str | None = None,
detail_level: str = "full",
project_id: str = "default",
) -> dict[str, Any]:
"""Return an exact final-visible page and its pagination metadata.
``total_count`` counts the de-duplicated UI rows for the whole channel;
``has_more`` describes rows older than the returned page for the given
cursor. Both values include UI-only messages that have no transcript
counterpart.
"""
messages = await self._get_channel_visible_messages(
channel_id,
detail_level=detail_level,
project_id=project_id,
)
total_count = len(messages)
candidates = messages
normalized_before_id = str(before_message_id or "").strip()
if before_timestamp is not None:
normalized_before_timestamp = float(before_timestamp)
if normalized_before_id:
query += " AND (timestamp < ? OR (timestamp = ? AND message_id < ?))"
params.extend([before_timestamp, before_timestamp, normalized_before_id])
candidates = [
message
for message in messages
if (
self._message_timestamp(message),
str(message.get("message_id", "") or ""),
) < (normalized_before_timestamp, normalized_before_id)
]
else:
query += " AND timestamp < ?"
params.append(before_timestamp)
query += " ORDER BY timestamp DESC, message_id DESC LIMIT ?"
params.append(fetch_limit)
candidates = [
message
for message in messages
if self._message_timestamp(message) < normalized_before_timestamp
]
normalized_limit = max(int(limit), 1)
return {
"messages": candidates[-normalized_limit:],
"has_more": len(candidates) > normalized_limit,
"total_count": total_count,
}
cursor = await self._db.execute(query, tuple(params))
rows = await cursor.fetchall()
messages = [self._row_to_message_dict(row) for row in rows]
messages.reverse()
messages = self._dedupe_messages(messages)
if len(messages) > limit:
messages = messages[-limit:]
return messages
async def get_channel_visible_message_count(self, channel_id: str, project_id: str = "default") -> int:
async def get_channel_visible_message_count(
self,
channel_id: str,
project_id: str = "default",
*,
detail_level: str = "full",
) -> int:
"""Return the de-duplicated visible message count for a channel."""
cursor = await self._db.execute(
"SELECT message_id, channel_id, sender, sender_name, content, "
"timestamp, reply_to_id, mentions, metadata "
"FROM messages WHERE channel_id = ? AND project_id = ? ORDER BY timestamp ASC",
(channel_id, project_id),
messages = await self._get_channel_visible_messages(
channel_id,
detail_level=detail_level,
project_id=project_id,
)
rows = await cursor.fetchall()
if not rows:
return 0
messages = [self._row_to_message_dict(row) for row in rows]
return len(self._dedupe_messages(messages))
return len(messages)
async def get_unresolved_checkpoint_messages(
self,
@@ -1646,6 +2214,9 @@ class ChatStore:
preview = " ".join(detail.split())
folded = dict(target)
folded.update(entry)
# The folded stream is one UI timeline row. Preserve its creation
# timestamp so reconnect snapshots cannot move it around tools.
folded["timestamp"] = target.get("timestamp", entry.get("timestamp"))
folded["detail"] = detail
folded["summary"] = preview[:120].rstrip() + ("..." if len(preview) > 120 else "")
merged[index_by_key[key]] = folded
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-D7a_jL_7.js"></script>
<script type="module" crossorigin src="./assets/index-02tfsorH.js"></script>
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
<link rel="stylesheet" crossorigin href="./assets/index-CMqG6mW8.css">
<link rel="stylesheet" crossorigin href="./assets/index-BCWwLlJm.css">
</head>
<body>
<div id="root"></div>
@@ -139,4 +139,17 @@ assert.match(
'session_detail backfill of the final runtime assistant turn must clear matching Live Reply drafts',
)
// 9. Summary/full pagination and transport-local failures have independent
// lifecycle state. A locally failed Promise never reaches onAck.
assert.match(
src,
/void client\.sessionDetail\([\s\S]*?\.then\(\(payload\) => \{[\s\S]*?payload\.ok !== false[\s\S]*?detailLoading: false/,
'debounced session_detail refresh must clear loading on transport-local failure',
)
assert.match(
src,
/mergeSessionDetailHasMore\([\s\S]*?payload\.client_history_page === true[\s\S]*?fullHasMore: detailHasMore[\s\S]*?summaryHasMore: detailHasMore/,
'session_detail ACK must persist pagination state under its detail policy',
)
console.log('App.test.tsx: OK (org handlers + snapshot boundary + runtime displayTool/draft contract)')
+53 -15
View File
@@ -18,7 +18,7 @@ import { ExecutionPanel } from './kanban/ExecutionPanel'
import { ProjectSelector } from './components/ProjectSelector'
import { OrgTab } from './org/OrgTab'
import { notifyTaskAssigned } from './lib/taskChatBridge'
import { mapCollabSyncPayload, mapBackendMessage, mapBackendChannel, mapBackendSession, mapBackendBoard, mapBackendColumn, mapBackendTask } from './lib/collabSync'
import { mapCollabSyncPayload, mapBackendMessage, mapBackendChannel, mapBackendSession, mapBackendBoard, mapBackendColumn, mapBackendTask, mergeSessionDetailHasMore } from './lib/collabSync'
import { normalizeOrgInfoPayload } from './lib/runtimeOrg'
import { companyRuntimeControlPatchForBoardStatus } from './lib/sessionRuntime'
import { getExecutionTurnId } from './lib/workItemRuntimeIds'
@@ -621,18 +621,36 @@ export default function App() {
if (generation !== projectViewGenerationRef.current) return
// Re-check liveness at fire time (ref may have been updated by now)
if (!force && !shouldRefreshLiveSession(taskId, sessionStoreRef.current)) return
const client = clientRef.current
sessionStoreRef.current?.updateSession(taskId, {
detailLoading: true,
detailError: undefined,
viewGeneration: generation,
})
clientRef.current?.sessionDetail(scopedProjectId, taskId, {
if (!client) {
sessionStoreRef.current?.updateSession(taskId, {
detailLoading: false,
detailError: 'connection_unavailable',
})
return
}
void client.sessionDetail(scopedProjectId, taskId, {
limit: 200,
detailLevel,
include: detailLevel === 'full'
? ['messages', 'session_state', 'progress', 'work_items', 'runtime_context']
: ['messages', 'session_state'],
viewGeneration: generation,
}).then((payload) => {
// Transport-local failures resolve the request Promise without
// producing a websocket ACK.
if (payload.ok !== false) return
if (scopedProjectId !== getActiveProjectId()) return
if (generation !== projectViewGenerationRef.current) return
sessionStoreRef.current?.updateSession(taskId, {
detailLoading: false,
detailError: String(payload.error ?? 'request_failed'),
})
})
}, 180)
pendingSessionDetailRefreshRef.current.set(timerKey, tid)
@@ -1050,6 +1068,7 @@ export default function App() {
const totalMessageCount = typeof payload.message_count === 'number'
? payload.message_count
: detailMessages.length
const detailLevel = payload.detail_level === 'full' ? 'full' : 'summary'
const cs = chatStoreRef.current
if (cs && detailMessages.length > 0) {
cs.mergeMessagesFromBackend(detailMessages)
@@ -1057,6 +1076,14 @@ export default function App() {
const ss = sessionStoreRef.current
if (ss && detailTaskId) {
const existingSession = ss.sessions.find(session => session.taskId === detailTaskId)
const previousHasMore = detailLevel === 'full'
? existingSession?.fullHasMore
: existingSession?.summaryHasMore
const detailHasMore = mergeSessionDetailHasMore(
previousHasMore,
payload.has_more === true,
payload.client_history_page === true,
)
const draftTurnId = String(existingSession?.draftTurnId ?? '').trim()
const detailHasFinalForDraft = !!draftTurnId && detailMessages.some((message) => {
if (message.sender === 'user') return false
@@ -1079,8 +1106,11 @@ export default function App() {
...(typeof payload.handoff_to === 'string' ? { handoffTo: payload.handoff_to } : {}),
messageCount: totalMessageCount,
detailLoaded: true,
fullLoaded: payload.detail_level === 'full' && payload.has_more !== true,
hasMore: payload.has_more === true,
...(detailLevel === 'full' ? { fullLoaded: !detailHasMore } : {}),
hasMore: detailHasMore,
...(detailLevel === 'full'
? { fullHasMore: detailHasMore }
: { summaryHasMore: detailHasMore }),
detailLoading: false,
detailError: undefined,
viewGeneration: detailGeneration ?? projectViewGenerationRef.current,
@@ -2438,17 +2468,25 @@ export default function App() {
onSessionStop={handleSessionStop}
onSessionResume={handleSessionResume}
onSessionComplete={(taskId) => clientRef.current?.sessionComplete(getActiveProjectId(), taskId)}
onLoadSessionDetail={(taskId, opts) => clientRef.current?.sessionDetail(
getActiveProjectId(),
taskId,
{
...opts,
include: opts?.detailLevel === 'full'
? ['messages', 'session_state', 'progress', 'work_items', 'runtime_context']
: ['messages', 'session_state'],
viewGeneration: projectViewGenerationRef.current,
},
)}
onLoadSessionDetail={(taskId, opts) => {
const client = clientRef.current
if (!client) return
return client.sessionDetail(
getActiveProjectId(),
taskId,
{
...opts,
include: opts?.detailLevel === 'full'
? ['messages', 'session_state', 'progress', 'work_items', 'runtime_context']
: ['messages', 'session_state'],
viewGeneration: projectViewGenerationRef.current,
},
).then((payload) => {
if (payload.ok === false) {
throw new Error(String(payload.error ?? 'session_detail failed'))
}
})
}}
onOpenExecutionPanel={(taskId) => setExecutionPanelTaskId(taskId)}
onCollabSync={() => clientRef.current?.collabSync(getActiveProjectId(), undefined, projectViewGenerationRef.current)}
/>
@@ -156,7 +156,7 @@ export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElap
const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change
return (
<div key={progressEntryKey(entry, hiddenCount + i)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
<div key={progressEntryKey(entry)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
<div className="ptl-connector">
<div className="ptl-dot" style={{ color: cfg.color }}>
{cfg.icon}
@@ -5,6 +5,58 @@ import { mapBackendMessage } from '../lib/collabSync'
import { analyzeCheckpointMessages } from './checkpointUtils'
import { __chatStoreTestUtils } from './ChatStore'
const persistentTimestamps = __chatStoreTestUtils.latestPersistentMessageTimestamps([
{
id: 'db-assistant-1',
channelId: 'session:read-test',
sender: 'assistant',
senderName: 'OPC',
content: 'First persisted reply',
timestamp: 10,
mentions: [],
},
{
id: 'msg-local-only',
channelId: 'session:read-test',
sender: 'user',
senderName: 'You',
content: 'Optimistic message',
timestamp: 30,
mentions: [],
metadata: { ui_message_id: 'ui-local-only' },
},
{
id: 'db-assistant-2',
channelId: 'session:read-test',
sender: 'assistant',
senderName: 'OPC',
content: 'Latest persisted reply',
timestamp: 20,
mentions: [],
},
])
assert.equal(persistentTimestamps['session:read-test'], 20)
const unreadState = { 'session:read-test': 10 }
const advancedReadState = __chatStoreTestUtils.advanceReadTimestamp(
unreadState,
'session:read-test',
persistentTimestamps['session:read-test'],
)
assert.notEqual(advancedReadState, unreadState)
assert.equal(advancedReadState['session:read-test'], 20)
assert.equal(
__chatStoreTestUtils.advanceReadTimestamp(advancedReadState, 'session:read-test', 20),
advancedReadState,
'marking an already-read channel must preserve state identity',
)
assert.equal(
__chatStoreTestUtils.advanceReadTimestamp(advancedReadState, 'session:read-test', 15),
advancedReadState,
'an older snapshot must not move the read cursor backwards',
)
const syntheticCheckpoint: ChatMessage = {
id: 'checkpoint::cp-delivery',
channelId: 'session:task-1',
@@ -43,6 +95,7 @@ const mergedCheckpoint = __chatStoreTestUtils.dedupeMessages([
assert.equal(mergedCheckpoint.length, 1)
assert.equal(mergedCheckpoint[0].id, 'db-message-1')
assert.equal(mergedCheckpoint[0].timestamp, 1, 'checkpoint status updates must keep their original timeline position')
assert.equal(mergedCheckpoint[0].metadata?.checkpoint_status, 'ignored')
assert.deepEqual([...analyzeCheckpointMessages(mergedCheckpoint).pendingMessageIds], [])
assert.deepEqual([...analyzeCheckpointMessages(mergedCheckpoint).respondedMessageIds], ['db-message-1'])
@@ -100,6 +153,42 @@ const mergedUserMessage = __chatStoreTestUtils.dedupeMessages([
assert.equal(mergedUserMessage.length, 1)
assert.equal(mergedUserMessage[0].metadata?.ui_message_id, 'ui-1')
assert.equal(mergedUserMessage[0].timestamp, 4, 'backend acknowledgement must replace the optimistic client clock')
const mirroredUserMessages = __chatStoreTestUtils.dedupeMessages([
backendUserMessage,
{ ...backendUserMessage, id: 'db-user-mirror', channelId: 'session:child-task' },
])
assert.equal(
mirroredUserMessages.length,
2,
'ui_message_id mirrors in different channels must remain available to each channel projection',
)
assert.deepEqual(
__chatStoreTestUtils.unreadMessageCounts([
{
id: 'msg-local-system',
channelId: 'session:task-1',
sender: 'system',
senderName: 'System',
content: 'Local task assignment notice',
timestamp: 100,
mentions: [],
},
{
id: 'db-assistant-unread',
channelId: 'session:task-1',
sender: 'assistant',
senderName: 'OPC',
content: 'Persisted reply',
timestamp: 90,
mentions: [],
},
], {}),
{ 'session:task-1': 1 },
'local-only system rows must not become unread entries that markRead can never cover',
)
const nativeCompanyRawTurn: ChatMessage = {
id: 'native-raw-1',
@@ -137,6 +226,44 @@ const mergedNativeCompanyDuplicate = __chatStoreTestUtils.dedupeMessages([
assert.equal(mergedNativeCompanyDuplicate.length, 1)
assert.equal(mergedNativeCompanyDuplicate[0].id, 'role-result-1')
assert.equal(mergedNativeCompanyDuplicate[0].senderName, 'Chao')
assert.equal(mergedNativeCompanyDuplicate[0].timestamp, 5, 'semantic result replacement must retain its original timeline position')
assert.equal(
mergedNativeCompanyDuplicate[0].metadata?.ui_timeline_id,
'message:native-raw-1',
'semantic result replacement must retain the already-mounted row identity',
)
const repeatedNativeCompanySync = __chatStoreTestUtils.dedupeMessages([
...mergedNativeCompanyDuplicate,
nativeCompanyRawTurn,
companyRoleResult,
])
assert.equal(repeatedNativeCompanySync.length, 1)
assert.equal(repeatedNativeCompanySync[0].metadata?.ui_timeline_id, 'message:native-raw-1')
assert.equal(repeatedNativeCompanySync[0].timestamp, 5)
const mountedHighPriorityResult: ChatMessage = {
...companyRoleResult,
id: 'mounted-high-result',
timestamp: 10,
metadata: { source: 'engine', transcript_kind: 'child_task_result' },
}
const olderLowPrioritySurface: ChatMessage = {
...nativeCompanyRawTurn,
id: 'older-low-result',
timestamp: 4,
}
const historyBackfillMerge = __chatStoreTestUtils.mergeMessagesIntoExisting(
[mountedHighPriorityResult],
[olderLowPrioritySurface],
)
assert.equal(historyBackfillMerge.length, 1)
assert.equal(historyBackfillMerge[0].id, 'mounted-high-result')
assert.equal(historyBackfillMerge[0].timestamp, 10, 'history backfill must not move an already-mounted result row')
assert.equal(
historyBackfillMerge[0].metadata?.ui_timeline_id,
'message:mounted-high-result',
'history backfill must retain the mounted high-priority result key',
)
const mappedTaskGeneralistMessage = mapBackendMessage({
message_id: 'legacy-task-generalist',
@@ -152,4 +279,4 @@ const mappedTaskGeneralistMessage = mapBackendMessage({
assert.equal(mappedTaskGeneralistMessage.senderName, 'OPC')
console.log('ChatStore.test.ts: OK (optimistic, checkpoint, and company result identity merging)')
console.log('ChatStore.test.ts: OK (read cursors, optimistic, checkpoint, and company result identity merging)')
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useReducer, useState } from 'react'
import { useCallback, useMemo, useReducer, useRef, useState } from 'react'
import type { ChatChannel, ChatMessage } from '../types/chat'
import { stableMessageTimelineKey } from '../lib/messageTimelineIdentity'
function uid(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
@@ -73,6 +74,12 @@ function messageIdentityKeys(message: ChatMessage): Set<string> {
return keys
}
function scopedMessageIdentityKeys(message: ChatMessage): Set<string> {
return new Set(
[...messageIdentityKeys(message)].map(key => `${message.channelId}\u0000${key}`),
)
}
function isDerivedIdentityKey(value: string): boolean {
return value.startsWith('checkpoint:')
}
@@ -81,6 +88,52 @@ function messageTimestamp(message: ChatMessage): number {
return typeof message.timestamp === 'number' ? message.timestamp : 0
}
function isOptimisticMessage(message: ChatMessage): boolean {
return String(message.id ?? '').startsWith('msg-')
}
function isPersistentMessage(message: ChatMessage): boolean {
// sendMessage creates client-only optimistic rows with this prefix. Once the
// backend acknowledges one, message deduplication replaces its identity with
// the persistent ui_message_id / backend message id.
return !isOptimisticMessage(message)
}
function latestPersistentMessageTimestamps(messages: ChatMessage[]): Record<string, number> {
const latest: Record<string, number> = {}
for (const message of messages) {
if (!isPersistentMessage(message)) continue
const timestamp = messageTimestamp(message)
if (timestamp > (latest[message.channelId] ?? 0)) {
latest[message.channelId] = timestamp
}
}
return latest
}
function advanceReadTimestamp(
state: Record<string, number>,
channelId: string,
timestamp: number,
): Record<string, number> {
if (timestamp <= (state[channelId] ?? 0)) return state
return { ...state, [channelId]: timestamp }
}
function unreadMessageCounts(
messages: ChatMessage[],
readTimestamps: Record<string, number>,
): Record<string, number> {
const counts: Record<string, number> = {}
for (const message of messages) {
if (!isPersistentMessage(message) || message.sender === 'user') continue
const lastRead = readTimestamps[message.channelId] ?? 0
if (messageTimestamp(message) <= lastRead) continue
counts[message.channelId] = (counts[message.channelId] ?? 0) + 1
}
return counts
}
function messageRoleBucket(message: ChatMessage): 'user' | 'assistant' {
const sender = String(message.sender ?? '').trim().toLowerCase()
const metadata = messageMetadata(message)
@@ -154,7 +207,12 @@ function mergeDuplicateMessages(
let preferred = existing
let secondary = candidate
if (preferCandidate) {
const existingOptimistic = isOptimisticMessage(existing)
const candidateOptimistic = isOptimisticMessage(candidate)
if (existingOptimistic !== candidateOptimistic) {
preferred = existingOptimistic ? candidate : existing
secondary = existingOptimistic ? existing : candidate
} else if (preferCandidate) {
preferred = candidate
secondary = existing
} else if (messagePreferenceScore(candidate) > messagePreferenceScore(existing)) {
@@ -184,14 +242,30 @@ function mergeDuplicateMessages(
? normalizedContent
: preferred.content
const existingCheckpointId = String(messageMetadata(existing).checkpoint_id ?? '').trim()
const candidateCheckpointId = String(messageMetadata(candidate).checkpoint_id ?? '').trim()
const preservesCheckpointPosition = !!existingCheckpointId && existingCheckpointId === candidateCheckpointId
const replacesResultSurface = isResultSurface(existing) && isResultSurface(candidate)
const retainedTimelineId = replacesResultSurface
? String(
messageMetadata(existing).ui_timeline_id
?? messageMetadata(candidate).ui_timeline_id
?? stableMessageTimelineKey(existing),
).trim()
: ''
const mergedMetadata = { ...messageMetadata(secondary), ...messageMetadata(preferred) }
if (retainedTimelineId) mergedMetadata.ui_timeline_id = retainedTimelineId
return {
...secondary,
...preferred,
...(canonicalId ? { id: canonicalId } : {}),
content,
metadata: { ...messageMetadata(secondary), ...messageMetadata(preferred) },
metadata: mergedMetadata,
mentions,
timestamp: messageTimestamp(preferred) || messageTimestamp(secondary),
timestamp: preservesCheckpointPosition || replacesResultSurface
? messageTimestamp(existing) || messageTimestamp(candidate)
: messageTimestamp(preferred) || messageTimestamp(secondary),
}
}
@@ -201,7 +275,7 @@ function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
const identityKeyToIdx = new Map<string, number>()
for (const message of [...messages].sort((a, b) => messageTimestamp(a) - messageTimestamp(b))) {
const candidateIds = messageIdentityKeys(message)
const candidateIds = scopedMessageIdentityKeys(message)
let matchIndex = -1
let preferCandidate = false
@@ -242,7 +316,7 @@ function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
}
// Register all identity keys for the merged/inserted message for fast future lookups
for (const id of messageIdentityKeys(deduped[insertIdx])) {
for (const id of scopedMessageIdentityKeys(deduped[insertIdx])) {
if (!identityKeyToIdx.has(id)) identityKeyToIdx.set(id, insertIdx)
}
}
@@ -250,8 +324,63 @@ function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
return deduped
}
function mergeMessagesIntoExisting(
state: ChatMessage[],
incoming: ChatMessage[],
): ChatMessage[] {
const merged = [...state]
const identityKeyToIdx = new Map<string, number>()
merged.forEach((message, index) => {
for (const key of scopedMessageIdentityKeys(message)) identityKeyToIdx.set(key, index)
})
for (const candidate of [...incoming].sort((a, b) => messageTimestamp(a) - messageTimestamp(b))) {
let matchIndex = -1
for (const key of scopedMessageIdentityKeys(candidate)) {
const index = identityKeyToIdx.get(key)
if (index !== undefined) {
matchIndex = index
break
}
}
if (matchIndex < 0) {
for (let index = merged.length - 1; index >= 0; index -= 1) {
if (messagesSemanticallyMatch(merged[index], candidate)) {
matchIndex = index
break
}
}
}
if (matchIndex < 0) {
matchIndex = merged.length
merged.push(candidate)
} else {
const mounted = merged[matchIndex]
merged[matchIndex] = mergeDuplicateMessages(
mounted,
candidate,
mounted.id === candidate.id,
)
}
for (const key of scopedMessageIdentityKeys(candidate)) identityKeyToIdx.set(key, matchIndex)
for (const key of scopedMessageIdentityKeys(merged[matchIndex])) identityKeyToIdx.set(key, matchIndex)
}
return merged.sort((a, b) => (
messageTimestamp(a) === messageTimestamp(b)
? a.id.localeCompare(b.id)
: messageTimestamp(a) - messageTimestamp(b)
))
}
export const __chatStoreTestUtils = {
advanceReadTimestamp,
dedupeMessages,
latestPersistentMessageTimestamps,
mergeMessagesIntoExisting,
unreadMessageCounts,
}
type ChannelAction =
@@ -335,7 +464,7 @@ function messageReducer(state: ChatMessage[], action: MessageAction): ChatMessag
}
case 'MERGE': {
if (action.messages.length === 0) return state
return dedupeMessages([...state, ...action.messages])
return mergeMessagesIntoExisting(state, action.messages)
}
case 'MARK_SENDER_DELETED': return state.map(m =>
m.sender === action.senderId ? { ...m, senderDeleted: true, senderName: '[已删除的 Agent]' } : m
@@ -372,6 +501,14 @@ export function useChatStore(): ChatStoreState {
const [messages, dispatchMsg] = useReducer(messageReducer, [])
const [readTimestamps, setReadTimestamps] = useState<Record<string, number>>({})
const [scopeProjectId, setScopeProjectId] = useState<string>('default')
const readBaselineProjectRef = useRef<string | null>(null)
const latestPersistentTimestamps = useMemo(
() => latestPersistentMessageTimestamps(messages),
[messages],
)
const latestPersistentTimestampsRef = useRef(latestPersistentTimestamps)
latestPersistentTimestampsRef.current = latestPersistentTimestamps
const messagesByChannel = useMemo<Record<string, ChatMessage[]>>(() => {
const buckets: Record<string, ChatMessage[]> = {}
@@ -382,16 +519,10 @@ export function useChatStore(): ChatStoreState {
return buckets
}, [messages])
const unreadCounts = useMemo<Record<string, number>>(() => {
const counts: Record<string, number> = {}
for (const message of messages) {
if (message.sender === 'user') continue
const lastRead = readTimestamps[message.channelId] ?? 0
if (message.timestamp <= lastRead) continue
counts[message.channelId] = (counts[message.channelId] ?? 0) + 1
}
return counts
}, [messages, readTimestamps])
const unreadCounts = useMemo(
() => unreadMessageCounts(messages, readTimestamps),
[messages, readTimestamps],
)
const sendMessage = useCallback((opts: {
channelId: string; sender: string; senderName: string; content: string;
@@ -421,7 +552,9 @@ export function useChatStore(): ChatStoreState {
}, [unreadCounts])
const markRead = useCallback((channelId: string) => {
setReadTimestamps(prev => ({ ...prev, [channelId]: Date.now() }))
const latestTimestamp = latestPersistentTimestampsRef.current[channelId] ?? 0
if (latestTimestamp <= 0) return
setReadTimestamps(prev => advanceReadTimestamp(prev, channelId, latestTimestamp))
}, [])
const markSenderDeleted = useCallback((agentId: string) => {
@@ -440,12 +573,15 @@ export function useChatStore(): ChatStoreState {
const clear = useCallback(() => {
dispatchCh({ type: 'CLEAR' })
dispatchMsg({ type: 'CLEAR' })
readBaselineProjectRef.current = null
setReadTimestamps({})
}, [])
const initFromBackend = useCallback((projectId: string, chs: ChatChannel[], msgs: ChatMessage[]) => {
const nextProjectId = projectId || 'default'
const projectChanged = nextProjectId !== scopeProjectId
const shouldResetReadBaseline = readBaselineProjectRef.current !== nextProjectId
readBaselineProjectRef.current = nextProjectId
setScopeProjectId(nextProjectId)
dispatchCh({ type: 'SET', channels: chs })
// Backend `collab_sync` / `collab_sync_push` payloads carry the
@@ -462,14 +598,11 @@ export function useChatStore(): ChatStoreState {
} else {
dispatchMsg({ type: 'MERGE', messages: msgs })
}
// Mark all loaded messages as read so they don't show as unread (#17)
const latest: Record<string, number> = {}
for (const m of msgs) {
if (!latest[m.channelId] || m.timestamp > latest[m.channelId]) {
latest[m.channelId] = m.timestamp
}
// Establish one read baseline when entering a project. Repeated full-sync
// payloads must not advance it behind the viewport controller's back.
if (shouldResetReadBaseline) {
setReadTimestamps(latestPersistentMessageTimestamps(msgs))
}
setReadTimestamps(prev => projectChanged ? latest : ({ ...prev, ...latest }))
}, [scopeProjectId])
const addMessageFromBackend = useCallback((msg: ChatMessage) => {
@@ -1,54 +1,38 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { buildNarrativeMessageItems, copyTextToClipboard, parseProjectUpdatePayload, shouldReleaseStickToBottomOnScroll } from './MessageList'
import { buildNarrativeMessageItems, copyTextToClipboard, messageTimelineKey, parseProjectUpdatePayload } from './MessageList'
import type { MessageScrollPolicy } from './MessageList'
import type { ChatMessage } from '../types/chat'
import { progressEntryKey } from '../lib/progressEntryKey'
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 1200,
nextScrollTop: 900,
atBottom: false,
userScrolling: false,
programmaticScroll: false,
}),
true,
'scrollbar drag upward should release stick-to-bottom even without wheel/pointer events',
const messageListSource = readFileSync(new URL('./MessageList.tsx', import.meta.url), 'utf8')
const supportedScrollPolicies: MessageScrollPolicy[] = ['follow', 'initial-bottom', 'manual']
assert.deepEqual(supportedScrollPolicies, ['follow', 'initial-bottom', 'manual'])
assert.match(
messageListSource,
/export type MessageScrollPolicy = 'follow' \| 'initial-bottom' \| 'manual'/,
'MessageList must expose one unambiguous three-state scroll policy',
)
assert.match(messageListSource, /scrollPolicy = 'follow'/, 'main transcript behavior should default to follow mode')
assert.doesNotMatch(messageListSource, /useVirtualizer/, 'chat transcript must use stable normal DOM rows')
assert.doesNotMatch(messageListSource, /PROGRAMMATIC_SCROLL_GRACE_MS/, 'scroll behavior must not regress to timer-based intent guessing')
const progressWithoutServerId = {
type: 'status_change' as const,
summary: 'Waiting for reviewer',
detail: 'Gate entered',
timestamp: 1234,
}
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 1200,
nextScrollTop: 900,
atBottom: false,
userScrolling: false,
programmaticScroll: true,
}),
false,
'programmatic scrolls should not release stick-to-bottom',
progressEntryKey(progressWithoutServerId),
progressEntryKey({ ...progressWithoutServerId }),
'progress identity must derive from stable event fields rather than its array position',
)
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 900,
nextScrollTop: 900,
atBottom: false,
userScrolling: true,
programmaticScroll: false,
}),
true,
'explicit user scroll state should release stick-to-bottom while away from bottom',
)
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 900,
nextScrollTop: 1200,
atBottom: true,
userScrolling: true,
programmaticScroll: false,
}),
false,
'scrolling back to bottom should keep follow mode available',
assert.doesNotMatch(
progressEntryKey(progressWithoutServerId),
/:0$/,
'progress fallback identity must not carry a shifting array index',
)
const parsedUpdate = parseProjectUpdatePayload(JSON.stringify({
@@ -90,6 +74,81 @@ const baseMessage = (id: string, content: string, timestamp: number, sender = 's
metadata: {},
})
assert.equal(
messageTimelineKey({
...baseMessage('checkpoint-message', 'Approval needed', 10),
metadata: {
checkpoint_id: 'checkpoint-42',
canonical_turn_id: 'turn-ignored',
ui_message_id: 'ui-ignored',
},
}),
'checkpoint:checkpoint-42',
'checkpoint identity must win so pending/resolved updates reuse one row',
)
assert.equal(
messageTimelineKey({
...baseMessage('assistant-final', 'Final answer', 20, 'assistant'),
metadata: { canonical_turn_id: 'turn-7', transcript_kind: 'runtime_v2_assistant' },
}),
'turn:assistant:turn-7',
'assistant draft and final surfaces must share the canonical turn key',
)
assert.equal(
messageTimelineKey({
...baseMessage('user-message', 'Question', 30, 'user'),
metadata: { canonical_turn_id: 'turn-8', ui_message_id: 'ui-8' },
}),
'ui:ui-8',
'a persisted user turn must retain the optimistic ui_message_id key',
)
assert.equal(
messageTimelineKey({
...baseMessage('optimistic-message', 'Local echo', 40, 'user'),
metadata: { ui_message_id: 'ui-9' },
}),
'ui:ui-9',
'optimistic and persisted user echoes must share ui_message_id identity',
)
assert.equal(
messageTimelineKey(baseMessage('persistent-message', 'Stored message', 50, 'assistant')),
'message:persistent-message',
'messages without stronger runtime identity must fall back to the persistent id',
)
assert.equal(
messageTimelineKey({
...baseMessage('higher-priority-result', 'Final answer', 55, 'assistant'),
metadata: {
canonical_turn_id: 'turn-7',
transcript_kind: 'child_task_result',
ui_timeline_id: 'turn:assistant:turn-7',
},
}),
'turn:assistant:turn-7',
'a semantic result replacement must keep the mounted native-final/draft slot',
)
const sharedCompanyTurn = 'company-turn-1'
const companyTurnKeys = [
messageTimelineKey({
...baseMessage('runtime-context', 'Execution context', 60),
metadata: { kind: 'runtime_v2_user_turn', canonical_turn_id: sharedCompanyTurn },
}),
messageTimelineKey({
...baseMessage('company-stream-1', 'First company surface', 61, 'assistant'),
metadata: { kind: 'runtime_v2_company_assistant', canonical_turn_id: sharedCompanyTurn },
}),
messageTimelineKey({
...baseMessage('company-stream-2', 'Second company surface', 62, 'assistant'),
metadata: { kind: 'runtime_v2_company_assistant', canonical_turn_id: sharedCompanyTurn },
}),
messageTimelineKey({
...baseMessage('role-result', 'Role result', 63, 'assistant'),
metadata: { kind: 'company_role_result', canonical_turn_id: sharedCompanyTurn },
}),
]
assert.equal(new Set(companyTurnKeys).size, companyTurnKeys.length, 'distinct company rows sharing a turn need unique DOM keys')
const narrativeItems = buildNarrativeMessageItems([
baseMessage('m1', '[Company:cto::execute::abc] starting Research source reliability', 1000),
baseMessage('m2', '[Delegating to codex] task=Research source reliability | cmd=codex exec ...', 1100),
@@ -194,4 +253,4 @@ if (originalDocument) {
delete (globalThis as any).document
}
console.log('MessageList.test.tsx: OK (scroll + narrative timeline helpers)')
console.log('MessageList.test.tsx: OK (scroll contract + stable timeline identity + narrative helpers)')
File diff suppressed because it is too large Load Diff
@@ -85,11 +85,16 @@ assert.doesNotMatch(src, /localResponded|setLocalResponded/, 'panel must wait fo
assert.match(src, /user_input_answers/, 'structured answers must be forwarded to the backend')
const messageListSrc = readFileSync(join(here, 'MessageList.tsx'), 'utf8')
const progressIndex = messageListSrc.indexOf("items.push({ kind: 'progress-block' })")
const pendingIndex = messageListSrc.indexOf("items.push({ kind: 'pending-section' })")
const endIndex = messageListSrc.indexOf("items.push({ kind: 'end-anchor' })")
assert.ok(progressIndex !== -1 && pendingIndex !== -1 && endIndex !== -1)
assert.ok(progressIndex < pendingIndex, 'pending checkpoint cards should render after the progress block')
assert.ok(pendingIndex < endIndex, 'pending checkpoint cards should render before the end anchor')
const timelineIndex = messageListSrc.indexOf('{processed.map(row => (')
const progressIndex = messageListSrc.indexOf('{showProgressBlock && (')
const endIndex = messageListSrc.indexOf('<div className="msg-end-anchor" />')
assert.ok(timelineIndex !== -1 && progressIndex !== -1 && endIndex !== -1)
assert.ok(timelineIndex < progressIndex && progressIndex < endIndex)
assert.doesNotMatch(messageListSrc, /kind: 'pending-section'/, 'pending cards must not be moved into a second tail section')
assert.match(
messageListSrc,
/Checkpoint cards never leave the chronological transcript/,
'task-user-input cards must remain at their creation position',
)
console.log('TaskUserInputPanel.test.tsx: OK (markdown and choice checkpoint panel)')
@@ -81,4 +81,15 @@ const fallbackMarkup = renderToStaticMarkup(
assert.match(fallbackMarkup, /CTO/)
assert.doesNotMatch(fallbackMarkup, /Engineer/)
const preparingMarkup = renderToStaticMarkup(
React.createElement(WorkItemProgressCard, {
workItemLog: [],
isCompanyRuntime: true,
}),
)
assert.match(preparingMarkup, /Execution Progress/)
assert.match(preparingMarkup, /Preparing company roles/)
assert.match(preparingMarkup, /role="status"/)
console.log('WorkItemProgressCard.test.tsx: OK (executor rollup preferred with current-owner fallback)')
@@ -542,8 +542,8 @@ export function WorkItemProgressCard({
return isCompanyRuntime ? [] : workItemLogWorkItems
}, [isCompanyRuntime, roleSummaries, workItemLogWorkItems])
if (isCompanyRuntime && roleSummaries.length === 0) return null
if (workItemLog.length === 0 && workItems.length === 0 && roleSummaries.length === 0) return null
const isPreparingCompanyRuntime = isCompanyRuntime && roleSummaries.length === 0
if (!isCompanyRuntime && workItemLog.length === 0 && workItems.length === 0 && roleSummaries.length === 0) return null
return (
<div className="wi-progress-card">
@@ -571,6 +571,12 @@ export function WorkItemProgressCard({
</div>
)}
{isPreparingCompanyRuntime && (
<div className="wi-progress-pipeline wi-progress-pipeline-empty" role="status">
Preparing company roles
</div>
)}
</div>
)
}
@@ -794,6 +794,16 @@
/* ══════════════════════════════════════════════════════════════════════════
Message List
══════════════════════════════════════════════════════════════════════════ */
.msg-list-shell {
flex: 1;
min-height: 0;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
.msg-list {
flex: 1;
min-height: 0;
@@ -803,16 +813,82 @@
scrollbar-gutter: stable;
scrollbar-color: var(--border) transparent;
overscroll-behavior-y: contain;
overflow-anchor: none;
position: relative;
}
.msg-list:focus {
outline: none;
}
.msg-list-following {
overflow-anchor: none;
}
.msg-list-browsing {
overflow-anchor: auto;
}
.msg-list-content {
width: 100%;
min-height: 100%;
}
.msg-timeline-row {
overflow-anchor: auto;
}
.msg-list-following .msg-timeline-row {
overflow-anchor: none;
}
.msg-end-anchor {
height: 1px;
flex-shrink: 0;
overflow-anchor: none;
}
.msg-list-floating-actions {
position: absolute;
left: 50%;
bottom: 12px;
z-index: 8;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
max-width: calc(100% - 24px);
pointer-events: none;
}
.msg-list-float-btn {
pointer-events: auto;
min-width: 0;
max-width: min(320px, 70vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid color-mix(in srgb, var(--border) 72%, var(--accent) 28%);
border-radius: 999px;
padding: 7px 12px;
background: color-mix(in srgb, var(--bg-elevated) 92%, var(--accent) 8%);
color: var(--text);
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.28);
font: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.msg-list-float-btn:hover {
border-color: var(--accent);
background: color-mix(in srgb, var(--bg-elevated) 84%, var(--accent) 16%);
}
.msg-list-pending-btn {
border-color: color-mix(in srgb, var(--yellow) 55%, var(--border));
color: var(--yellow);
}
.msg-history-hint {
display: flex;
align-items: center;
@@ -849,33 +925,6 @@
color: var(--text-secondary);
}
.msg-pending-section {
margin-top: 10px;
padding: 12px 16px 0;
border-top: 1px solid var(--border);
background: linear-gradient(180deg, transparent 0%, var(--bg-elevated) 28px);
}
.msg-pending-header {
font-size: 10px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 8px 10px;
}
.msg-pending-stack {
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 8px;
}
.msg-row-pending {
animation: msg-enter 200ms ease-out;
}
/* ── Welcome ──────────────────────────────────────────────────────────── */
.msg-welcome {
display: flex;
@@ -15,6 +15,17 @@ const UNIX_MS_THRESHOLD = 1_000_000_000_000
const WORK_ITEM_EVENT_RE = /^\[Company:([^\]]+)\]\s*(.*)$/
const COMPANY_RUNTIME_EVENT_RE = /^\[Company\]\s*(.*)$/
export function mergeSessionDetailHasMore(
previous: boolean | undefined,
incoming: boolean,
isHistoryPage: boolean,
): boolean {
// A latest-page refresh only describes that 200-row response. It must not
// reopen an older-history cursor that the user already exhausted.
if (!isHistoryPage && previous === false) return false
return incoming
}
function normalizeAgentRuntimeStatus(rawStatus: unknown, rawAgentStatus: unknown): AgentAnimStatus | undefined {
if (rawAgentStatus === 'idle' || rawAgentStatus === 'reflecting' || rawAgentStatus === 'tool_active') {
return rawAgentStatus
@@ -52,6 +63,16 @@ function mapBackendProgressLog(raw: any): ProgressEntry[] {
: typeof entry.streamId === 'string'
? entry.streamId
: undefined,
toolCallId: typeof entry.tool_call_id === 'string'
? entry.tool_call_id
: typeof entry.toolCallId === 'string'
? entry.toolCallId
: undefined,
permissionGroupKey: typeof entry.permission_group_key === 'string'
? entry.permission_group_key
: typeof entry.permissionGroupKey === 'string'
? entry.permissionGroupKey
: undefined,
seq: typeof entry.seq === 'number' && Number.isFinite(entry.seq) ? entry.seq : undefined,
executionMode: typeof entry.execution_mode === 'string'
? entry.execution_mode
@@ -633,6 +654,8 @@ export function mapBackendSession(raw: any): Session {
detailLoaded: raw.detail_loaded ?? raw.detailLoaded,
fullLoaded: raw.full_loaded ?? raw.fullLoaded,
hasMore: raw.has_more ?? raw.hasMore,
summaryHasMore: raw.summary_has_more ?? raw.summaryHasMore,
fullHasMore: raw.full_has_more ?? raw.fullHasMore,
detailLoading: raw.detail_loading ?? raw.detailLoading,
detailError: raw.detail_error ?? raw.detailError,
viewGeneration: raw.view_generation ?? raw.viewGeneration,
@@ -0,0 +1,30 @@
import type { ChatMessage } from '../types/chat'
export function stableMessageTimelineKey(message: ChatMessage): string {
const metadata = message.metadata ?? {}
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
if (checkpointId) return `checkpoint:${checkpointId}`
const uiMessageId = String(metadata.ui_message_id ?? '').trim()
const transcriptKind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
const metadataRole = String((metadata as Record<string, unknown>).role ?? '').trim().toLowerCase()
const isUserTurn = message.sender === 'user'
|| metadataRole === 'user'
|| transcriptKind === 'runtime_v2_user_turn'
|| transcriptKind === 'top_level_user_turn'
// The optimistic and persisted user surfaces share one client identity.
if (isUserTurn && uiMessageId) return `ui:${uiMessageId}`
// ChatStore attaches this only when one semantic result surface replaces
// another. It preserves the already-mounted row without entering protocol
// or persistence data.
const retainedTimelineId = String(metadata.ui_timeline_id ?? '').trim()
if (retainedTimelineId) return retainedTimelineId
const turnId = String(metadata.canonical_turn_id ?? metadata.turn_id ?? '').trim()
if (!isUserTurn && turnId && transcriptKind === 'runtime_v2_assistant') {
return `turn:assistant:${turnId}`
}
return `message:${message.id}`
}
@@ -7,30 +7,39 @@ function compact(value: unknown): string {
.slice(0, 96)
}
export function progressEntryKey(entry: ProgressEntry, fallbackIndex = 0): string {
export function progressEntryKey(entry: ProgressEntry): string {
const stableId = entry.itemId || entry.streamId || entry.toolCallId || entry.permissionGroupKey
if (stableId) {
return `${entry.type}:${compact(entry.turnId)}:${compact(stableId)}`
}
if (entry.type === 'thinking') {
return `thinking:${compact(entry.turnId) || compact(entry.executionMode) || compact(entry.summary) || 'stream'}:${fallbackIndex}`
if (entry.type === 'thinking' || entry.type === 'assistant') {
return `${entry.type}:${compact(entry.turnId) || compact(entry.executionMode) || 'stream'}:${
Number.isFinite(entry.timestamp) ? entry.timestamp : ''
}`
}
if (entry.type === 'tool_call' && entry.turnId) {
return `tool:${compact(entry.turnId)}:${compact(entry.summary) || 'tool'}:${fallbackIndex}`
if (entry.type === 'tool_call') {
return `tool:${compact(entry.turnId) || 'turnless'}:${compact(entry.summary) || 'tool'}:${
Number.isFinite(entry.timestamp) ? entry.timestamp : ''
}`
}
if (entry.turnId && typeof entry.seq === 'number') {
return `${entry.type}:${compact(entry.turnId)}:seq:${entry.seq}`
}
return [
const fallbackParts: Array<string | number> = [
entry.type,
compact(entry.turnId),
]
if (typeof entry.seq === 'number' && Number.isFinite(entry.seq)) {
fallbackParts.push(`seq-${entry.seq}`)
}
fallbackParts.push(
Number.isFinite(entry.timestamp) ? entry.timestamp : '',
compact(entry.summary),
compact(entry.detail),
fallbackIndex,
].join(':')
)
return fallbackParts.join(':')
}
@@ -1,5 +1,8 @@
import assert from 'node:assert/strict'
import { appendProgressEntry } from './progressLog'
import type { ProgressEntry } from '../types/kanban'
import { mapBackendSession } from './collabSync'
import { progressEntryKey } from './progressEntryKey'
import { appendProgressEntry, normalizeProgressLog } from './progressLog'
let log = appendProgressEntry([], {
timestamp: 1,
@@ -149,3 +152,144 @@ assert.equal(assistantLog.length, 2)
assert.equal(assistantLog[0]?.detail, '文件已成功写入(278 行)。')
assert.equal(assistantLog[0]?.summary, '文件已成功写入(278 行)。')
assert.equal(assistantLog[1]?.detail, '采集完成报告')
// A live client receives these as individual deltas. A reconnect receives the
// same rows as a full snake_case snapshot. Both paths must produce identical
// row identities: otherwise React remounts progress rows and browser anchoring
// sees a false remove/insert pair during every full sync.
const snapshotSeconds = 1_700_000_000
const snapshotRows = [
{
timestamp: snapshotSeconds,
type: 'status_change',
summary: 'Running',
detail: 'phase=running',
},
{
timestamp: snapshotSeconds + 0.1,
type: 'status_change',
summary: 'Running',
detail: 'phase=running',
},
{
timestamp: snapshotSeconds + 1,
type: 'thinking',
summary: 'Thinking',
detail: 'Need ',
},
{
timestamp: snapshotSeconds + 1.1,
type: 'thinking',
summary: 'Thinking',
detail: 'context',
},
{
timestamp: snapshotSeconds + 2,
type: 'assistant',
summary: 'Answer',
detail: 'Answer ',
},
{
timestamp: snapshotSeconds + 2.1,
type: 'assistant',
summary: 'ready',
detail: 'ready',
},
{
timestamp: snapshotSeconds + 3,
type: 'tool_call',
summary: 'file_read',
detail: '{"path":',
},
{
timestamp: snapshotSeconds + 3.1,
type: 'tool_call',
summary: 'file_read',
detail: '"README.md"}',
},
] as const
const liveDeltas: ProgressEntry[] = snapshotRows.map(entry => ({
timestamp: entry.timestamp * 1000,
type: entry.type,
summary: entry.summary,
detail: entry.detail,
}))
const liveSnapshot = liveDeltas.reduce<ProgressEntry[]>(
(entries, entry) => appendProgressEntry(entries, entry),
[],
)
const normalizedSnapshot = normalizeProgressLog(liveDeltas)
const mappedSnapshot = mapBackendSession({
task_id: 'progress-snapshot',
channel_id: 'session:progress-snapshot',
progress_log: snapshotRows,
}).progressLog
assert.equal(liveSnapshot.length, 4)
assert.deepEqual(
liveSnapshot.map(entry => entry.type),
['status_change', 'thinking', 'assistant', 'tool_call'],
)
assert.deepEqual(
normalizedSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
liveSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
)
assert.deepEqual(
mappedSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
liveSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
)
assert.deepEqual(
liveSnapshot.map(entry => entry.timestamp),
[
snapshotSeconds * 1000,
(snapshotSeconds + 1) * 1000,
(snapshotSeconds + 2) * 1000,
(snapshotSeconds + 3) * 1000,
],
)
assert.deepEqual(
liveSnapshot.map(progressEntryKey),
[
'status_change::1700000000000:Running:phase=running',
'thinking:stream:1700000001000',
'assistant:stream:1700000002000',
'tool:turnless:file_read:1700000003000',
],
)
assert.ok(liveSnapshot.every(entry => (
!entry.itemId
&& !entry.streamId
&& !entry.toolCallId
&& !entry.permissionGroupKey
)))
// Persisted snake_case identifiers must survive the full-sync bridge. These
// identifiers take precedence over mutable summaries and timestamps when the
// UI derives a row key.
const mappedStableIds = mapBackendSession({
task_id: 'progress-stable-ids',
channel_id: 'session:progress-stable-ids',
progress_log: [
{
timestamp: snapshotSeconds + 10,
type: 'tool_call',
summary: 'shell_exec',
tool_call_id: 'call-42',
},
{
timestamp: snapshotSeconds + 11,
type: 'autonomy',
summary: 'shell_exec: ask',
permission_group_key: 'tool:shell_exec/python:domain:example.com',
},
],
}).progressLog
assert.equal(mappedStableIds[0]?.toolCallId, 'call-42')
assert.equal(mappedStableIds[1]?.permissionGroupKey, 'tool:shell_exec/python:domain:example.com')
assert.equal(progressEntryKey(mappedStableIds[0]!), 'tool_call::call-42')
assert.equal(
progressEntryKey(mappedStableIds[1]!),
'autonomy::tool:shell_exec/python:domain:example.com',
)
@@ -90,7 +90,9 @@ function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry
// the same way as thinking streams.
const detail = mergeText(left.detail ?? '', right.detail ?? '', 'thinking')
return {
timestamp: right.timestamp,
// A stream occupies the timeline slot where it began. Deltas update the
// row in place instead of repeatedly re-sorting it around tool events.
timestamp: left.timestamp,
type: left.type,
summary: summarizeThinking(detail, right.summary || left.summary),
detail: detail || undefined,
@@ -107,7 +109,7 @@ function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry
if (left.type === 'tool_call') {
const mergedDetail = mergeText(left.detail ?? '', right.detail ?? '', 'tool_call')
return {
timestamp: right.timestamp,
timestamp: left.timestamp,
type: 'tool_call',
summary: right.summary || left.summary,
detail: mergedDetail || undefined,
@@ -152,7 +154,7 @@ export function appendProgressEntry(
if (isDuplicateProgress(last, normalized)) {
return clampEntries([
...log.slice(0, actualIndex),
{ ...last, timestamp: normalized.timestamp },
last,
...log.slice(actualIndex + 1),
], maxEntries)
}
@@ -1,9 +1,9 @@
import assert from 'node:assert/strict'
import type { ChatMessage } from '../types/chat'
import type { Session } from '../types/kanban'
import { mapBackendSession } from './collabSync'
import { mapBackendSession, mergeSessionDetailHasMore } from './collabSync'
import { canonicalizeSessionExecutionIdentity } from './sessionIdentity'
import { deriveCompanyRuntimeDisplayStatus, getConversationHeaderSession, getConversationSessionView, getWorkItemChildSessions, getWorkItemRoleSessions, mergeConversationMessages, projectSessionConversation } from './workItemSessions'
import { deriveCompanyRuntimeDisplayStatus, getConversationHeaderSession, getConversationSessionView, getWorkItemChildSessions, getWorkItemRoleSessions, mergeConversationMessages, projectSessionConversation, selectCompanySummaryMessages } from './workItemSessions'
function makeSession(overrides: Partial<Session> & Pick<Session, 'taskId' | 'channelId' | 'title' | 'status' | 'columnId' | 'assigneeIds' | 'priority' | 'tags' | 'progressLog' | 'createdAt' | 'updatedAt' | 'messageCount'>): Session {
return {
@@ -189,6 +189,161 @@ const mergedDeliveryMessages = mergeConversationMessages([
assert.equal(mergedDeliveryMessages.length, 1)
assert.equal(mergedDeliveryMessages[0]?.id, 'child-direct')
const earlierResult = {
...resultMessage(
'earlier-parent-result',
'session:company-root',
finalBody,
{ source: 'engine', transcript_kind: 'child_result' },
),
timestamp: 900,
}
const authoritativeResult = {
...resultMessage(
'later-authoritative-result',
'session:company-child',
finalBody,
{ source: 'engine', transcript_kind: 'child_task_result' },
),
timestamp: 1_100,
}
for (const groups of [
[[earlierResult], [authoritativeResult]],
[[authoritativeResult], [earlierResult]],
]) {
const result = mergeConversationMessages(groups)
assert.equal(result.length, 1)
assert.equal(result[0]?.id, 'later-authoritative-result')
assert.equal(result[0]?.timestamp, 900, 'result chronology must not depend on channel traversal order')
}
const pendingCheckpoint = {
...resultMessage(
'pending-checkpoint-surface',
'session:company-root',
'Approval required.',
{ checkpoint_id: 'shared-checkpoint', checkpoint_type: 'human_escalation', status: 'pending' },
'system',
),
timestamp: 1_200,
}
const resolvedCheckpoint = {
...resultMessage(
'resolved-checkpoint-surface',
'session:company-child',
'Approval required.',
{ checkpoint_id: 'shared-checkpoint', checkpoint_type: 'human_escalation', status: 'resolved' },
'system',
),
timestamp: 1_300,
}
const mergedCheckpoint = mergeConversationMessages([[pendingCheckpoint], [resolvedCheckpoint]])
assert.equal(mergedCheckpoint.length, 1)
assert.equal(mergedCheckpoint[0]?.id, 'pending-checkpoint-surface')
assert.equal(mergedCheckpoint[0]?.timestamp, 1_200)
assert.equal(mergedCheckpoint[0]?.metadata?.status, 'resolved')
const companySummaryMessages = selectCompanySummaryMessages([
resultMessage(
'parent-user',
'session:company-root',
'Please investigate the issue.',
{ source: 'ui' },
'user',
),
resultMessage(
'child-transient',
'session:company-child',
'A child draft or internal assistant turn must stay out of the parent transcript.',
{ source: 'runtime_event', transcript_kind: 'runtime_v2_assistant' },
'assistant',
),
resultMessage(
'canonical-role-result',
'session:company-child',
'The canonical role delivery remains visible in the company summary.',
{ source: 'engine', transcript_kind: 'company_role_result' },
'assistant',
),
resultMessage(
'summary-company-final',
'session:company-child',
'A summary-visible company final remains when no canonical role mirror exists.',
{ source: 'engine', kind: 'runtime_v2_company_assistant', detail_visibility: 'summary' },
'assistant',
),
{
...resultMessage(
'parent-full-only-terminal',
'session:company-root',
'A full-only parent surface must neither render nor suppress the committed child summary.',
{
source: 'engine',
transcript_kind: 'runtime_v2_assistant',
detail_visibility: 'full',
canonical_turn_id: 'shared-terminal-turn',
},
'assistant',
),
timestamp: 1400,
},
{
...resultMessage(
'summary-terminal-a',
'session:company-child',
'First authoritative terminal for one shared canonical turn.',
{
source: 'engine',
transcript_kind: 'runtime_v2_assistant',
detail_visibility: 'summary',
canonical_turn_id: 'shared-terminal-turn',
},
'assistant',
),
timestamp: 1200,
},
{
...resultMessage(
'summary-terminal-b',
'session:company-sibling',
'A second terminal surface with different content must not duplicate the turn.',
{
source: 'engine',
transcript_kind: 'runtime_v2_assistant',
detail_visibility: 'summary',
canonical_turn_id: 'shared-terminal-turn',
},
'assistant',
),
timestamp: 1300,
},
resultMessage(
'child-checkpoint',
'session:company-child',
'Approval is required.',
{ checkpoint_id: 'checkpoint-child', checkpoint_type: 'company_work_item_gate' },
'assistant',
),
resultMessage(
'child-checkpoint-response',
'session:company-child',
'Approved.',
{ response_to_checkpoint_id: 'checkpoint-child', ui_message_id: 'ui-checkpoint-response' },
'user',
),
], 'session:company-root')
assert.deepEqual(
companySummaryMessages.map(message => message.id).sort(),
[
'parent-user',
'canonical-role-result',
'summary-company-final',
'summary-terminal-b',
'child-checkpoint',
'child-checkpoint-response',
].sort(),
)
assert.equal(companyHeaderView?.status, 'running')
assert.equal(companyHeaderView?.contextTokens, 0)
assert.equal(companyHeaderView?.contextWindow, 128000)
@@ -369,4 +524,15 @@ assert.equal(mappedCompanySession.execMode, 'company')
assert.equal(mappedCompanySession.companyProfile, 'corporate')
assert.equal(mappedCompanySession.orgId, undefined)
assert.equal(
mergeSessionDetailHasMore(false, true, false),
false,
'a cursorless live refresh must not reopen an exhausted history boundary',
)
assert.equal(
mergeSessionDetailHasMore(false, true, true),
true,
'a real cursor page may advance the scoped history boundary',
)
console.log('workItemSessions origin-task linking checks passed')
@@ -2,11 +2,20 @@ import type { ChatMessage } from '../types/chat'
import type { ProgressEntry, Session } from '../types/kanban'
import { getContextUsageMetrics } from './contextUsage'
import { isSessionWorking } from './sessionRuntime'
import { stableMessageTimelineKey } from './messageTimelineIdentity'
const CONTEXT_TOKENS_RE = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s+tokens/i
const USED_PCT_RE = /(\d{1,3})%\s*used/i
const REMAINING_PCT_RE = /(\d{1,3})%\s*remaining/i
export function isMessageVisibleAtDetailLevel(
message: ChatMessage,
detailLevel: 'summary' | 'full',
): boolean {
if (detailLevel === 'full') return true
return String(message.metadata?.detail_visibility ?? 'summary').trim() !== 'full'
}
function compactWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
@@ -58,7 +67,7 @@ function resultSurfacePriority(message: ChatMessage): number {
return 0
}
function resultSurfaceDedupeKey(message: ChatMessage): string {
export function resultSurfaceDedupeKey(message: ChatMessage): string {
if (resultSurfacePriority(message) <= 0) return ''
const content = compactWhitespace(stripNarrativeTitlePrefix(message.content)).slice(0, 2000)
return content ? `result:${content}` : ''
@@ -454,10 +463,29 @@ export function getConversationHeaderSession(
export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatMessage[] {
const seen = new Set<string>()
const resultKeyIndex = new Map<string, number>()
const checkpointIndex = new Map<string, number>()
const merged: ChatMessage[] = []
for (const group of messageGroups) {
for (const message of group) {
const metadata = (message.metadata ?? {}) as Record<string, unknown>
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
if (checkpointId) {
const existingIndex = checkpointIndex.get(checkpointId)
if (existingIndex !== undefined) {
const existing = merged[existingIndex]
const latest = message.timestamp >= existing.timestamp ? message : existing
const earliest = message.timestamp < existing.timestamp ? message : existing
merged[existingIndex] = {
...existing,
...latest,
id: existing.id,
channelId: existing.channelId,
timestamp: earliest.timestamp,
metadata: { ...(existing.metadata ?? {}), ...(latest.metadata ?? {}) },
}
continue
}
}
const uiMessageId = typeof metadata.ui_message_id === 'string'
? metadata.ui_message_id.trim()
: ''
@@ -465,16 +493,32 @@ export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatM
if (resultKey) {
const existingIndex = resultKeyIndex.get(resultKey)
if (existingIndex !== undefined) {
if (resultSurfacePriority(message) > resultSurfacePriority(merged[existingIndex])) {
merged[existingIndex] = message
const existing = merged[existingIndex]
const candidateWins = resultSurfacePriority(message) > resultSurfacePriority(existing)
const preferred = candidateWins ? message : existing
const secondary = candidateWins ? existing : message
merged[existingIndex] = {
...secondary,
...preferred,
// A result surface keeps the chronology of the first underlying
// delivery, independent of which related channel happened to be
// traversed first for this render.
timestamp: Math.min(existing.timestamp, message.timestamp),
metadata: {
...(secondary.metadata ?? {}),
...(preferred.metadata ?? {}),
ui_timeline_id: stableMessageTimelineKey(existing),
},
}
continue
}
resultKeyIndex.set(resultKey, merged.length)
}
const dedupeKey = resultKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}`
const checkpointKey = checkpointId ? `checkpoint:${checkpointId}` : ''
const dedupeKey = resultKey || checkpointKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}`
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
if (checkpointId) checkpointIndex.set(checkpointId, merged.length)
merged.push(message)
}
}
@@ -485,6 +529,98 @@ export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatM
))
}
/**
* Build the durable transcript shown by a company/org parent session.
*
* A parent conversation may observe every related runtime channel so that
* canonical role deliveries can be surfaced in one place. Those channels
* also contain transient child turns, however, and rendering all of them in
* the parent transcript makes the visible timeline change whenever the
* runtime projection selects a different child. Keep the parent's committed
* messages and only admit the canonical cross-channel result surfaces.
*/
export function selectCompanySummaryMessages(
messages: ChatMessage[],
parentChannelId: string,
): ChatMessage[] {
const terminalAssistantTurn = (message: ChatMessage): string => {
const metadata = (message.metadata ?? {}) as Record<string, unknown>
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
if (kind !== 'runtime_v2_assistant' && kind !== 'runtime_v2_company_assistant') return ''
return String(metadata.canonical_turn_id ?? metadata.turn_id ?? '').trim()
}
const parentTerminalTurns = new Set(
messages
.filter(message => (
message.channelId === parentChannelId
&& isMessageVisibleAtDetailLevel(message, 'summary')
))
.map(terminalAssistantTurn)
.filter(Boolean),
)
const childTerminalByTurn = new Map<string, ChatMessage>()
const durableMessages: ChatMessage[] = []
for (const message of messages) {
if (message.channelId === parentChannelId) {
if (isMessageVisibleAtDetailLevel(message, 'summary')) {
durableMessages.push(message)
}
continue
}
const metadata = (message.metadata ?? {}) as Record<string, unknown>
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
const checkpointType = String(metadata.checkpoint_type ?? '').trim()
const checkpointResponseId = String(metadata.response_to_checkpoint_id ?? '').trim()
const escalationResponseId = String(metadata.response_to_escalation_id ?? '').trim()
if ((checkpointId && checkpointType) || checkpointResponseId || escalationResponseId) {
durableMessages.push(message)
continue
}
if ([
'company_role_result',
'company_role_result_retry',
'child_task_result',
'child_task_result_retry',
'child_result',
'top_level_reply',
].includes(kind)) {
durableMessages.push(message)
continue
}
// Snapshot builder deliberately marks the final runtime surface as
// summary-visible. Preserve that durable contract instead of reusing the
// result-dedupe priority table as a visibility threshold.
const isSummaryTerminal = String(metadata.detail_visibility ?? '').trim() === 'summary'
&& (kind === 'runtime_v2_assistant' || kind === 'runtime_v2_company_assistant')
if (!isSummaryTerminal) continue
const turnId = terminalAssistantTurn(message)
if (!turnId) {
durableMessages.push(message)
continue
}
if (parentTerminalTurns.has(turnId)) continue
const existing = childTerminalByTurn.get(turnId)
if (!existing) {
childTerminalByTurn.set(turnId, message)
continue
}
const existingPriority = resultSurfacePriority(existing)
const candidatePriority = resultSurfacePriority(message)
if (candidatePriority > existingPriority) {
childTerminalByTurn.set(turnId, message)
} else if (
candidatePriority === existingPriority
&& (message.timestamp > existing.timestamp
|| (message.timestamp === existing.timestamp && message.id.localeCompare(existing.id) > 0))
) {
childTerminalByTurn.set(turnId, message)
}
}
durableMessages.push(...childTerminalByTurn.values())
return mergeConversationMessages([durableMessages])
}
export function mergeConversationProgressLog(timelineSessions: Session[]): ProgressEntry[] {
const seen = new Set<string>()
const merged: ProgressEntry[] = []
@@ -0,0 +1,217 @@
import assert from 'node:assert/strict'
import { VisualSocketClient } from './wsClient'
type TestSocketClient = {
handleMessage: (raw: unknown) => void
pendingQueue: string[]
pendingSessionDetailRequests: Array<{
queued: boolean
settled: boolean
timeout: ReturnType<typeof setTimeout> | null
}>
timeoutSessionDetailRequest: (index: number) => void
}
const deliverAck = (
client: VisualSocketClient,
payload: Record<string, unknown>,
) => {
;(client as unknown as TestSocketClient).handleMessage(JSON.stringify({
type: 'ack',
payload,
}))
}
const flushPromises = async () => {
await Promise.resolve()
await Promise.resolve()
}
const client = new VisualSocketClient('ws://unit.test', {})
// A summary and a full request for the same task are distinct correlations.
// Neither Promise may settle merely because the request was queued locally.
const summaryPromise = client.sessionDetail('project-a', 'task-1', { detailLevel: 'summary' })
const fullPromise = client.sessionDetail('project-a', 'task-1', { detailLevel: 'full' })
const summarySettlements: Array<Record<string, unknown>> = []
const fullSettlements: Array<Record<string, unknown>> = []
void summaryPromise.then(payload => { summarySettlements.push(payload) })
void fullPromise.then(payload => { fullSettlements.push(payload) })
await flushPromises()
assert.equal(summarySettlements.length, 0, 'summary Promise must remain pending before its ACK')
assert.equal(fullSettlements.length, 0, 'full Promise must remain pending before its ACK')
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-1',
detail_level: 'full',
marker: 'full-ack',
})
await flushPromises()
assert.equal(fullSettlements[0]?.marker, 'full-ack', 'the matching full ACK must settle the full request')
assert.equal(summarySettlements.length, 0, 'a full ACK must not settle the same task\'s summary request')
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-1',
detail_level: 'summary',
marker: 'summary-ack',
})
await flushPromises()
assert.equal(summarySettlements[0]?.marker, 'summary-ack', 'the matching summary ACK must settle the summary request')
assert.equal(summarySettlements[0]?.client_history_page, false, 'a request without a cursor is not a history page')
const historyPagePromise = client.sessionDetail('project-a', 'task-history', {
detailLevel: 'summary',
beforeCreatedAt: 123,
})
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-history',
detail_level: 'summary',
})
assert.equal((await historyPagePromise).client_history_page, true, 'a cursor request must be identified as a history page')
// Requests with the same correlation fields are settled in request order.
const firstPromise = client.sessionDetail('project-a', 'task-2', { detailLevel: 'summary' })
const secondPromise = client.sessionDetail('project-a', 'task-2', { detailLevel: 'summary' })
const firstSettlements: Array<Record<string, unknown>> = []
const secondSettlements: Array<Record<string, unknown>> = []
void firstPromise.then(payload => { firstSettlements.push(payload) })
void secondPromise.then(payload => { secondSettlements.push(payload) })
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-2',
detail_level: 'summary',
marker: 'first-ack',
})
await flushPromises()
assert.equal(firstSettlements[0]?.marker, 'first-ack', 'the first matching ACK must settle the oldest request')
assert.equal(secondSettlements.length, 0, 'the second same-scope request must remain pending after one ACK')
deliverAck(client, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-2',
detail_level: 'summary',
marker: 'second-ack',
})
await flushPromises()
assert.equal(secondSettlements[0]?.marker, 'second-ack', 'the next matching ACK must settle the next FIFO request')
// The backend's early store-not-ready path cannot echo request fields. The
// client must still correlate and normalize that error instead of leaving the
// history single-flight Promise pending forever.
const storeNotReadyPromise = client.sessionDetail('project-a', 'task-store', {
detailLevel: 'summary',
viewGeneration: 9,
})
const storeNotReadySettlements: Array<Record<string, unknown>> = []
void storeNotReadyPromise.then(payload => { storeNotReadySettlements.push(payload) })
deliverAck(client, {
ok: false,
action: 'create_session',
error: 'store_not_ready',
project_id: 'project-a',
view_generation: 9,
})
await flushPromises()
assert.equal(
storeNotReadySettlements.length,
0,
'store_not_ready for another explicit action must not settle a session_detail request',
)
deliverAck(client, {
ok: false,
error: 'store_not_ready',
project_id: 'project-a',
view_generation: 9,
})
const storeNotReady = await storeNotReadyPromise
assert.equal(storeNotReady.action, 'session_detail')
assert.equal(storeNotReady.task_id, 'task-store')
assert.equal(storeNotReady.detail_level, 'summary')
// A sent request timeout releases its caller, but leaves a settled FIFO
// tombstone so a late ACK cannot be mis-correlated to a newer request.
const timeoutClient = new VisualSocketClient('ws://unit.test', {})
const sentPromise = timeoutClient.sessionDetail('project-a', 'task-timeout', {
detailLevel: 'summary',
})
const sentSettlements: Array<Record<string, unknown>> = []
void sentPromise.then(payload => { sentSettlements.push(payload) })
const timeoutInternals = timeoutClient as unknown as TestSocketClient
timeoutInternals.pendingSessionDetailRequests[0].queued = false
timeoutInternals.pendingQueue = []
timeoutInternals.timeoutSessionDetailRequest(0)
await flushPromises()
assert.equal(sentSettlements[0]?.error, 'request_timeout', 'sent timeout must release the loading caller')
assert.equal(timeoutInternals.pendingSessionDetailRequests.length, 1, 'sent timeout must retain a FIFO tombstone')
assert.equal(timeoutInternals.pendingSessionDetailRequests[0].settled, true, 'the retained request must be a settled tombstone')
assert.equal(timeoutInternals.pendingSessionDetailRequests[0].timeout, null, 'sent request timer must be released')
const afterTimeoutPromise = timeoutClient.sessionDetail('project-a', 'task-timeout', {
detailLevel: 'summary',
})
const afterTimeoutSettlements: Array<Record<string, unknown>> = []
void afterTimeoutPromise.then(payload => { afterTimeoutSettlements.push(payload) })
deliverAck(timeoutClient, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-timeout',
detail_level: 'summary',
marker: 'old-request-ack',
})
await flushPromises()
assert.equal(sentSettlements.length, 1, 'a late ACK must be consumed by the older tombstone')
assert.equal(sentSettlements[0]?.error, 'request_timeout', 'a late ACK cannot resettle the timed-out Promise')
assert.equal(afterTimeoutSettlements.length, 0, 'the first ACK must not settle the newer same-scope request')
assert.equal(timeoutInternals.pendingSessionDetailRequests.length, 1, 'the newer request must remain pending')
deliverAck(timeoutClient, {
ok: true,
action: 'session_detail',
project_id: 'project-a',
task_id: 'task-timeout',
detail_level: 'summary',
marker: 'current-request-ack',
})
await flushPromises()
assert.equal(afterTimeoutSettlements[0]?.marker, 'current-request-ack', 'the next ACK must settle the newer request')
const queuedTimeoutClient = new VisualSocketClient('ws://unit.test', {})
const queuedTimeout = queuedTimeoutClient.sessionDetail('project-a', 'task-queued-timeout', {
beforeMessageId: 'older-message',
})
const queuedTimeoutInternals = queuedTimeoutClient as unknown as TestSocketClient
queuedTimeoutInternals.timeoutSessionDetailRequest(0)
const queuedTimeoutFailure = await queuedTimeout
assert.equal(queuedTimeoutFailure.error, 'request_timeout', 'a request still queued locally may time out')
assert.equal(queuedTimeoutFailure.client_history_page, true, 'synthetic failures must preserve history-page correlation')
assert.equal(queuedTimeoutInternals.pendingSessionDetailRequests.length, 0)
assert.equal(queuedTimeoutInternals.pendingQueue.length, 0)
const disconnectClient = new VisualSocketClient('ws://unit.test', {})
const disconnected = disconnectClient.sessionDetail('project-a', 'task-disconnect')
disconnectClient.disconnect()
assert.equal((await disconnected).error, 'disconnected', 'disconnect must settle every pending detail request')
const saturatedClient = new VisualSocketClient('ws://unit.test', {})
;(saturatedClient as unknown as TestSocketClient).pendingQueue = Array.from({ length: 100 }, () => '{}')
const saturated = await saturatedClient.sessionDetail('project-a', 'task-saturated')
assert.equal(saturated.error, 'send_queue_full', 'a saturated transport queue must fail immediately')
console.log('wsClient.test.ts: OK (session_detail correlation and lifecycle cleanup)')
@@ -174,6 +174,9 @@ const PROJECT_SCOPED_MESSAGE_TYPES = new Set([
'comms_read_message',
])
const SESSION_DETAIL_REQUEST_TIMEOUT_MS = 30_000
type SendDisposition = 'sent' | 'queued' | 'queue-full' | 'send-failed'
export class VisualSocketClient {
private ws: WebSocket | null = null
private reconnectTimer: number | null = null
@@ -182,6 +185,18 @@ export class VisualSocketClient {
private pendingQueue: string[] = []
private heartbeatTimer: number | null = null
private pongTimer: number | null = null
private pendingSessionDetailRequests: Array<{
projectId: string
taskId: string
detailLevel: 'summary' | 'full'
viewGeneration?: number
historyPage: boolean
wireData: string
queued: boolean
settled: boolean
timeout: ReturnType<typeof setTimeout> | null
resolve: (payload: Record<string, unknown>) => void
}> = []
constructor(
private url: string,
@@ -211,6 +226,7 @@ export class VisualSocketClient {
}
this.ws.onclose = () => {
this.stopHeartbeat()
this.failPendingSessionDetailRequests('connection_closed')
this.handlers.onStatus?.('disconnected')
this.ws = null
if (!this.closedByUser) {
@@ -222,6 +238,7 @@ export class VisualSocketClient {
disconnect(): void {
this.closedByUser = true
this.stopHeartbeat()
this.failPendingSessionDetailRequests('disconnected')
if (this.reconnectTimer !== null) {
window.clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
@@ -230,18 +247,24 @@ export class VisualSocketClient {
this.ws = null
}
send(payload: Record<string, unknown>): void {
send(payload: Record<string, unknown>): SendDisposition {
if (!this.ensureProjectScope(payload)) {
return
return 'send-failed'
}
const data = JSON.stringify(payload)
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
if (this.pendingQueue.length < PENDING_QUEUE_MAX) {
this.pendingQueue.push(data)
return 'queued'
}
return
return 'queue-full'
}
try {
this.ws.send(data)
return 'sent'
} catch {
return 'send-failed'
}
this.ws.send(data)
}
// ── Agent management ───────────────────────────────────────────────────
@@ -411,18 +434,50 @@ export class VisualSocketClient {
projectId: string,
taskId: string,
opts?: { limit?: number; beforeCreatedAt?: number; beforeMessageId?: string; detailLevel?: 'summary' | 'full'; include?: string[]; viewGeneration?: number },
): void {
): Promise<Record<string, unknown>> {
const pid = this.requireProjectId(projectId, 'session_detail')
this.send({
type: 'session_detail',
project_id: pid,
task_id: taskId,
limit: opts?.limit,
before_created_at: opts?.beforeCreatedAt,
before_message_id: opts?.beforeMessageId,
detail_level: opts?.detailLevel,
include: opts?.include,
view_generation: opts?.viewGeneration,
const detailLevel = opts?.detailLevel ?? 'summary'
return new Promise((resolve) => {
const payload = {
type: 'session_detail',
project_id: pid,
task_id: taskId,
limit: opts?.limit,
before_created_at: opts?.beforeCreatedAt,
before_message_id: opts?.beforeMessageId,
detail_level: detailLevel,
include: opts?.include,
view_generation: opts?.viewGeneration,
}
const wireData = JSON.stringify(payload)
const request = {
projectId: pid,
taskId,
detailLevel,
viewGeneration: opts?.viewGeneration,
historyPage: opts?.beforeCreatedAt !== undefined || !!opts?.beforeMessageId,
wireData,
queued: false,
settled: false,
timeout: null as ReturnType<typeof setTimeout> | null,
resolve,
}
request.timeout = setTimeout(() => {
const index = this.pendingSessionDetailRequests.indexOf(request)
if (index >= 0) this.timeoutSessionDetailRequest(index)
}, SESSION_DETAIL_REQUEST_TIMEOUT_MS)
this.pendingSessionDetailRequests.push(request)
const disposition = this.send(payload)
request.queued = disposition === 'queued'
if (disposition === 'queue-full' || disposition === 'send-failed') {
const index = this.pendingSessionDetailRequests.indexOf(request)
if (index >= 0) {
this.failSessionDetailRequest(
index,
disposition === 'queue-full' ? 'send_queue_full' : 'send_failed',
)
}
}
})
}
@@ -656,9 +711,13 @@ export class VisualSocketClient {
case 'event':
this.handlers.onEvent?.(parsed.payload)
break
case 'ack':
this.handlers.onAck?.(parsed.payload)
case 'ack': {
const ackPayload = this.settleSessionDetailRequest(
parsed.payload as unknown as Record<string, unknown>,
)
this.handlers.onAck?.(ackPayload as typeof parsed.payload)
break
}
case 'channel_created':
this.handlers.onChannelCreated?.(parsed.payload)
break
@@ -799,11 +858,109 @@ export class VisualSocketClient {
} catch (e) { console.error('[wsClient] Error handling message:', parsed.type, e) }
}
private settleSessionDetailRequest(payload: Record<string, unknown>): Record<string, unknown> {
const action = typeof payload.action === 'string' ? payload.action.trim() : ''
const isSessionDetailAck = action === 'session_detail'
|| (!action && payload.error === 'store_not_ready')
if (!isSessionDetailAck) return payload
const projectId = this.normalizeProjectId(payload.project_id ?? payload.projectId)
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
const detailLevel = payload.detail_level === 'full' ? 'full' : payload.detail_level === 'summary' ? 'summary' : ''
const viewGeneration = typeof payload.view_generation === 'number' ? payload.view_generation : undefined
const index = this.pendingSessionDetailRequests.findIndex(request => (
(!projectId || request.projectId === projectId)
&& (!taskId || request.taskId === taskId)
&& (!detailLevel || request.detailLevel === detailLevel)
&& (viewGeneration === undefined || request.viewGeneration === viewGeneration)
))
if (index < 0) return payload
const [request] = this.pendingSessionDetailRequests.splice(index, 1)
if (request.timeout !== null) clearTimeout(request.timeout)
const normalizedPayload = {
...payload,
action: 'session_detail',
project_id: projectId || request.projectId,
task_id: taskId || request.taskId,
detail_level: detailLevel || request.detailLevel,
client_history_page: request.historyPage,
}
if (!request.settled) {
request.settled = true
request.resolve(normalizedPayload)
}
return normalizedPayload
}
private timeoutSessionDetailRequest(index: number): void {
const request = this.pendingSessionDetailRequests[index]
if (!request) return
if (request.queued) {
this.failSessionDetailRequest(index, 'request_timeout')
return
}
if (request.timeout !== null) clearTimeout(request.timeout)
request.timeout = null
if (!request.settled) {
request.settled = true
request.resolve(this.sessionDetailFailurePayload(request, 'request_timeout'))
}
// Keep a settled tombstone in FIFO order until its ACK or connection
// cleanup. Removing it would let a late ACK settle a newer request with
// identical correlation fields; closing the shared socket would interrupt
// unrelated runtime events.
}
private failSessionDetailRequest(index: number, error: string): void {
const [request] = this.pendingSessionDetailRequests.splice(index, 1)
if (!request) return
if (request.timeout !== null) clearTimeout(request.timeout)
if (request.queued) {
const queuedIndex = this.pendingQueue.indexOf(request.wireData)
if (queuedIndex >= 0) this.pendingQueue.splice(queuedIndex, 1)
}
if (!request.settled) request.resolve(this.sessionDetailFailurePayload(request, error))
}
private sessionDetailFailurePayload(
request: {
projectId: string
taskId: string
detailLevel: 'summary' | 'full'
historyPage: boolean
},
error: string,
): Record<string, unknown> {
return {
ok: false,
action: 'session_detail',
error,
project_id: request.projectId,
task_id: request.taskId,
detail_level: request.detailLevel,
client_history_page: request.historyPage,
}
}
private failPendingSessionDetailRequests(error: string): void {
while (this.pendingSessionDetailRequests.length > 0) {
this.failSessionDetailRequest(0, error)
}
}
private flushPendingQueue(): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
const queued = this.pendingQueue.splice(0)
for (const data of queued) {
this.ws.send(data)
const detailRequestIndex = this.pendingSessionDetailRequests.findIndex(
request => request.queued && request.wireData === data,
)
try {
this.ws.send(data)
if (detailRequestIndex >= 0) this.pendingSessionDetailRequests[detailRequestIndex].queued = false
} catch {
if (detailRequestIndex >= 0) this.failSessionDetailRequest(detailRequestIndex, 'send_failed')
}
}
}
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -b",
"test:scroll": "node --import tsx ./tests/message-list-scroll.spec.ts",
"preview": "vite preview"
},
"dependencies": {
@@ -260,6 +260,8 @@ function sessionReducer(state: Session[], action: SessionAction): Session[] {
detailLoaded: existing.detailLoaded ?? incoming.detailLoaded,
fullLoaded: existing.fullLoaded ?? incoming.fullLoaded,
hasMore: incoming.hasMore ?? existing.hasMore,
summaryHasMore: incoming.summaryHasMore ?? existing.summaryHasMore,
fullHasMore: incoming.fullHasMore ?? existing.fullHasMore,
detailLoading: incoming.detailLoading ?? existing.detailLoading,
detailError: incoming.detailError ?? existing.detailError,
viewGeneration: incoming.viewGeneration ?? existing.viewGeneration,
@@ -314,6 +316,8 @@ function sessionReducer(state: Session[], action: SessionAction): Session[] {
detailLoaded: nextSession.detailLoaded ?? s.detailLoaded,
fullLoaded: nextSession.fullLoaded ?? s.fullLoaded,
hasMore: nextSession.hasMore ?? s.hasMore,
summaryHasMore: nextSession.summaryHasMore ?? s.summaryHasMore,
fullHasMore: nextSession.fullHasMore ?? s.fullHasMore,
detailLoading: nextSession.detailLoading ?? s.detailLoading,
detailError: nextSession.detailError ?? s.detailError,
viewGeneration: nextSession.viewGeneration ?? s.viewGeneration,
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MessageList scroll regression fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/tests/message-list-scroll.tsx"></script>
</body>
</html>
@@ -0,0 +1,734 @@
import assert from 'node:assert/strict'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { createServer, type ViteDevServer } from 'vite'
// Run from frontend_src:
// node --import tsx ./tests/message-list-scroll.spec.ts
const __dirname = dirname(fileURLToPath(import.meta.url))
const FRONTEND_ROOT = resolve(__dirname, '..')
interface ScrollMetrics {
scrollTop: number
scrollHeight: number
clientHeight: number
bottomGap: number
}
interface FixtureTelemetry {
markReadCalls: number
renders: number
scrollEvents: number
scrollTopWrites: number
}
async function settle(page: Page, milliseconds = 80): Promise<void> {
await page.evaluate(() => new Promise<void>((resolveFrame) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame()))
}))
await page.waitForTimeout(milliseconds)
}
async function metrics(page: Page): Promise<ScrollMetrics> {
return page.locator('.msg-list').evaluate((element) => {
const list = element as HTMLElement
return {
scrollTop: list.scrollTop,
scrollHeight: list.scrollHeight,
clientHeight: list.clientHeight,
bottomGap: list.scrollHeight - list.clientHeight - list.scrollTop,
}
})
}
async function gotoFixture(
page: Page,
baseUrl: string,
policy: string,
extraQuery = '',
): Promise<void> {
const suffix = extraQuery ? `&${extraQuery}` : ''
await page.goto(`${baseUrl}tests/message-list-scroll.html?policy=${policy}${suffix}`)
await page.waitForFunction(() => window.__messageListFixtureReady === true)
await page.waitForSelector('.msg-list')
await settle(page, 150)
}
async function resetTelemetry(page: Page): Promise<void> {
await page.evaluate(() => window.__messageListFixture?.resetTelemetry())
}
async function telemetry(page: Page): Promise<FixtureTelemetry> {
return page.evaluate(() => {
const value = window.__messageListFixture?.telemetry()
if (!value) throw new Error('fixture telemetry is unavailable')
return value
})
}
async function appendMessages(page: Page, count: number): Promise<void> {
await page.evaluate((amount) => window.__messageListFixture?.appendMessages(amount), count)
await page.waitForSelector(`text=fixture-marker-${String(225 + count - 1).padStart(4, '0')}`)
await settle(page)
}
async function firstFullyVisibleMarker(page: Page): Promise<{ marker: string; top: number }> {
return page.locator('.msg-list').evaluate((element) => {
const list = element as HTMLElement
const listRect = list.getBoundingClientRect()
for (const row of Array.from(list.querySelectorAll<HTMLElement>('.msg-row'))) {
const rect = row.getBoundingClientRect()
const markerMatch = /fixture-marker-(\d+)/.exec(row.textContent || '')
const marker = markerMatch?.[0]
if (markerMatch && Number(markerMatch[1]) % 5 === 0) continue
if (marker && rect.top >= listRect.top + 1 && rect.bottom <= listRect.bottom - 1) {
return { marker, top: rect.top }
}
}
throw new Error('no fully visible fixture row found')
})
}
async function markerTop(page: Page, marker: string): Promise<number> {
return page.locator('.msg-list').evaluate((element, expectedMarker) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-row'))
.find((candidate) => candidate.textContent?.includes(expectedMarker))
if (!row) throw new Error(`marker row not found: ${expectedMarker}`)
return row.getBoundingClientRect().top
}, marker)
}
async function markerViewportOffset(page: Page, marker: string): Promise<number> {
return page.locator('.msg-list').evaluate((element, expectedMarker) => {
const list = element as HTMLElement
const row = Array.from(list.querySelectorAll<HTMLElement>('.msg-row'))
.find((candidate) => candidate.textContent?.includes(expectedMarker))
if (!row) throw new Error(`marker row not found: ${expectedMarker}`)
return row.getBoundingClientRect().top - list.getBoundingClientRect().top
}, marker)
}
async function nearestMarkerAbove(page: Page, anchorMarker: string): Promise<{ marker: string; index: number }> {
return page.locator('.msg-list').evaluate((element, expectedAnchor) => {
const rows = Array.from(element.querySelectorAll<HTMLElement>('.msg-row'))
const anchor = rows.find((row) => row.textContent?.includes(expectedAnchor))
if (!anchor) throw new Error(`anchor row not found: ${expectedAnchor}`)
const anchorTop = anchor.getBoundingClientRect().top
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index]
if (row.getBoundingClientRect().bottom > anchorTop - 1) continue
const marker = /fixture-marker-(\d+)/.exec(row.textContent || '')
if (marker) return { marker: marker[0], index: Number(marker[1]) }
}
throw new Error('no rendered fixture row exists above the viewport anchor')
}, anchorMarker)
}
async function clickHistoryWithoutScrolling(page: Page): Promise<void> {
await page.locator('.msg-history-load-btn').evaluate((element) => {
;(element as HTMLButtonElement).click()
})
}
async function runFollowAndBrowsingCases(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'follow')
const initial = await metrics(page)
assert.ok(initial.scrollHeight > initial.clientHeight * 8, 'fixture must exercise a long, 200+ row transcript')
assert.ok(Math.abs(initial.bottomGap) <= 2, `follow policy should initially settle at bottom; gap=${initial.bottomGap}`)
// Regression for scrollToEnd -> onMarkRead -> state -> changed callback ->
// layout effect -> scrollToEnd. Once settled, an idle transcript must not
// keep invoking markRead, re-rendering itself, or even assigning scrollTop.
await resetTelemetry(page)
await page.waitForTimeout(2_000)
const idle = await telemetry(page)
assert.equal(idle.markReadCalls, 0, 'idle bottom transcript must not run a markRead feedback loop')
assert.equal(idle.renders, 0, 'idle bottom transcript must not re-render from markRead feedback')
assert.equal(idle.scrollEvents, 0, 'idle bottom transcript must not keep writing its scroll position')
assert.equal(idle.scrollTopWrites, 0, 'idle bottom transcript must perform zero direct scrollTop writes for two seconds')
await resetTelemetry(page)
await appendMessages(page, 5)
const afterAppend = await metrics(page)
const followTelemetry = await telemetry(page)
assert.ok(Math.abs(afterAppend.bottomGap) <= 2, `follow policy must stay at bottom after append; gap=${afterAppend.bottomGap}`)
assert.ok(followTelemetry.markReadCalls <= 1, `one append batch may mark read at most once; calls=${followTelemetry.markReadCalls}`)
assert.ok(followTelemetry.scrollEvents <= 1, `one append batch may scroll at most once; events=${followTelemetry.scrollEvents}`)
assert.ok(followTelemetry.scrollTopWrites <= 1, `one append batch may assign scrollTop at most once; writes=${followTelemetry.scrollTopWrites}`)
await page.evaluate(() => window.__messageListFixture?.growDraft(4_000))
await page.waitForSelector('.msg-row-draft')
await settle(page, 150)
const afterDraftGrowth = await metrics(page)
assert.ok(
Math.abs(afterDraftGrowth.bottomGap) <= 2,
`follow policy must absorb a large live-reply height change; gap=${afterDraftGrowth.bottomGap}`,
)
// Use a real browser wheel input, not a synthetic scrollTop-only change, to
// transition from FOLLOWING to BROWSING.
const list = page.locator('.msg-list')
await list.hover()
await page.mouse.wheel(0, -1_100)
await settle(page)
const detached = await metrics(page)
assert.ok(detached.bottomGap > 400, `wheel-up must detach from bottom immediately; gap=${detached.bottomGap}`)
const anchorBefore = await firstFullyVisibleMarker(page)
await resetTelemetry(page)
await page.evaluate(() => {
window.__messageListFixture?.appendMessages(15)
window.__messageListFixture?.growDraft(5_000)
})
await page.waitForSelector('text=fixture-marker-0244')
await settle(page, 150)
const anchorAfterTop = await markerTop(page, anchorBefore.marker)
const browsingTelemetry = await telemetry(page)
assert.ok(
Math.abs(anchorAfterTop - anchorBefore.top) <= 1,
`browsing anchor moved by ${anchorAfterTop - anchorBefore.top}px during append storm`,
)
assert.equal(browsingTelemetry.markReadCalls, 0, 'browsing updates must not mark the detached transcript read')
assert.equal(browsingTelemetry.scrollEvents, 0, 'browsing updates must not write the transcript scroll position')
// A Markdown row which grows above the anchor is the same geometry change
// produced by an image load or an expanded approval card. Native anchoring
// plus the single controller must hold the visible row exactly in place.
const rowAbove = await nearestMarkerAbove(page, anchorBefore.marker)
await page.evaluate(({ index }) => window.__messageListFixture?.growMessage(index, 6_000), rowAbove)
await settle(page, 150)
const anchorAfterHeightChange = await markerTop(page, anchorBefore.marker)
const afterHeightChange = await metrics(page)
assert.ok(
Math.abs(anchorAfterHeightChange - anchorBefore.top) <= 1,
`browsing anchor moved by ${anchorAfterHeightChange - anchorBefore.top}px when ${rowAbove.marker} grew above it`,
)
assert.ok(afterHeightChange.bottomGap > 400, 'height growth above a browsing anchor must not pull the transcript to bottom')
assert.equal(
await page.locator('.msg-list').getAttribute('data-viewport-mode'),
'browsing',
'height growth must retain browsing mode',
)
// Result surfaces can be replaced by a higher-priority durable projection
// and full-sync payloads rebuild every message object. Neither operation may
// give JavaScript permission to write the browsing scroll position.
await resetTelemetry(page)
await page.locator('.msg-list').evaluate((element, marker) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes(marker))
if (!row) throw new Error(`result anchor row is missing: ${marker}`)
row.dataset.identityProbe = 'stable-result-row'
}, anchorBefore.marker)
const anchorIndex = Number(anchorBefore.marker.slice('fixture-marker-'.length))
await page.evaluate((index) => window.__messageListFixture?.upgradeResultSurface(index), anchorIndex)
await settle(page, 120)
const resultReplacement = await page.locator('.msg-list').evaluate((element, marker) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes(marker))
if (!row) throw new Error(`replaced result anchor row is missing: ${marker}`)
return {
top: row.getBoundingClientRect().top,
probe: row.dataset.identityProbe,
key: row.dataset.timelineKey,
}
}, anchorBefore.marker)
assert.ok(Math.abs(resultReplacement.top - anchorBefore.top) <= 1, 'result-surface replacement must preserve the browsing anchor')
assert.equal(resultReplacement.probe, 'stable-result-row', 'result-surface replacement must reuse the anchored DOM node')
assert.equal(resultReplacement.key, `turn:assistant:fixture-turn-${anchorIndex}`, 'result replacement must retain the first surface timeline key')
await page.evaluate(() => window.__messageListFixture?.repeatFullSync())
await settle(page, 120)
assert.ok(
Math.abs(await markerTop(page, anchorBefore.marker) - anchorBefore.top) <= 1,
'repeated full sync must preserve the browsing anchor',
)
const syncTelemetry = await telemetry(page)
assert.equal(syncTelemetry.scrollTopWrites, 0, 'result upgrade and repeated full sync must not directly write browsing scrollTop')
// Resizing the panel changes the viewport rather than the message content.
// The controller restores the same row offset through its sole observer.
const offsetBeforeResize = await markerViewportOffset(page, anchorBefore.marker)
await page.setViewportSize({ width: 1280, height: 620 })
await settle(page, 150)
const offsetAfterResize = await markerViewportOffset(page, anchorBefore.marker)
assert.ok(
Math.abs(offsetAfterResize - offsetBeforeResize) <= 1,
`panel resize moved the browsing anchor by ${offsetAfterResize - offsetBeforeResize}px`,
)
await page.setViewportSize({ width: 1280, height: 800 })
await settle(page, 150)
// First expose the 25 locally-windowed rows, then exercise the real remote
// history callback which prepends 40 rows. DOM clicks avoid Playwright
// scrolling the history button into view before the assertion.
await clickHistoryWithoutScrolling(page)
await settle(page, 120)
const anchorAfterLocalHistory = await markerTop(page, anchorBefore.marker)
assert.ok(
Math.abs(anchorAfterLocalHistory - anchorBefore.top) <= 1,
`browsing anchor moved by ${anchorAfterLocalHistory - anchorBefore.top}px when the local window expanded`,
)
await clickHistoryWithoutScrolling(page)
await page.waitForSelector('text=history-marker-00-039')
await settle(page, 150)
const anchorAfterPrepend = await markerTop(page, anchorBefore.marker)
const afterPrepend = await metrics(page)
assert.ok(
Math.abs(anchorAfterPrepend - anchorBefore.top) <= 1,
`browsing anchor moved by ${anchorAfterPrepend - anchorBefore.top}px after history prepend`,
)
assert.ok(afterPrepend.bottomGap > 400, 'history prepend must not pull a browsing transcript to bottom')
assert.equal(
await page.locator('.msg-list').getAttribute('data-viewport-mode'),
'browsing',
'history prepend must retain browsing mode',
)
const jumpToLatest = page.getByRole('button', { name: /(?:latest|最新)/i })
await jumpToLatest.waitFor({ state: 'visible' })
await jumpToLatest.click()
await settle(page)
const resumed = await metrics(page)
assert.ok(Math.abs(resumed.bottomGap) <= 2, `jump-to-latest must resume follow mode; gap=${resumed.bottomGap}`)
}
async function runInputIntentCases(page: Page, baseUrl: string): Promise<void> {
const list = page.locator('.msg-list')
await gotoFixture(page, baseUrl, 'follow')
await list.focus()
await page.keyboard.press('PageUp')
await settle(page)
assert.equal(await list.getAttribute('data-viewport-mode'), 'browsing', 'PageUp must immediately enter browsing mode')
assert.ok((await metrics(page)).bottomGap > 100, 'PageUp must detach the transcript from the tail')
await gotoFixture(page, baseUrl, 'follow')
const listBox = await list.boundingBox()
if (!listBox) throw new Error('scrollbar fixture has no bounding box')
const beforeDrag = await metrics(page)
const scrollbarWidth = await list.evaluate((element) => {
const viewport = element as HTMLElement
return viewport.offsetWidth - viewport.clientWidth
})
const scrollbarX = listBox.x + listBox.width - Math.max(2, scrollbarWidth / 2)
const scrollbarTrackY = listBox.y + beforeDrag.clientHeight / 2
await page.mouse.click(scrollbarX, scrollbarTrackY)
await settle(page)
const afterDrag = await metrics(page)
assert.equal(
await list.getAttribute('data-viewport-mode'),
'browsing',
`scrollbar interaction must immediately enter browsing mode; width=${scrollbarWidth} before=${JSON.stringify(beforeDrag)} after=${JSON.stringify(afterDrag)}`,
)
assert.ok(afterDrag.bottomGap > 100, 'scrollbar interaction must detach the transcript from the tail')
// Some assistive/native scroll paths expose only the resulting scroll
// event. A downward move while already browsing must replace the stored
// anchor so a later resize cannot restore an older viewport position.
await gotoFixture(page, baseUrl, 'follow')
await list.hover()
await page.mouse.wheel(0, -2_400)
await settle(page)
await list.evaluate((element) => {
const viewport = element as HTMLElement
viewport.scrollTop += 1_000
})
await settle(page)
const nativeDownAnchor = await firstFullyVisibleMarker(page)
const nativeDownOffset = await markerViewportOffset(page, nativeDownAnchor.marker)
await page.setViewportSize({ width: 1280, height: 650 })
await settle(page, 150)
assert.ok(
Math.abs(await markerViewportOffset(page, nativeDownAnchor.marker) - nativeDownOffset) <= 1,
'untagged downward browsing scroll must become the anchor used by a later viewport resize',
)
await page.setViewportSize({ width: 1280, height: 800 })
await settle(page, 120)
}
async function runTouchIntentCase(browser: Browser, baseUrl: string): Promise<void> {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, hasTouch: true })
try {
await gotoFixture(page, baseUrl, 'follow')
const list = page.locator('.msg-list')
const box = await list.boundingBox()
if (!box) throw new Error('touch fixture has no bounding box')
const cdp = await page.context().newCDPSession(page)
const x = box.x + box.width / 2
const startY = box.y + box.height * 0.45
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x, y: startY }],
})
for (const delta of [35, 75, 120, 170]) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x, y: startY + delta }],
})
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await settle(page, 150)
assert.equal(await list.getAttribute('data-viewport-mode'), 'browsing', 'native touch swipe must enter browsing mode')
assert.ok((await metrics(page)).bottomGap > 100, 'native touch swipe must detach the transcript from the tail')
} finally {
await page.close()
}
}
async function runTimelineIdentityCases(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'follow')
await page.evaluate(() => window.__messageListFixture?.addSharedTurnCompanyRows([4]))
await page.waitForSelector('text=shared-company-row-4')
await settle(page)
await page.locator('.msg-list').evaluate((element) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes('shared-company-row-4'))
if (!row) throw new Error('mounted shared-turn owner is missing')
row.dataset.identityProbe = 'shared-turn-owner'
})
await page.evaluate(() => window.__messageListFixture?.addSharedTurnCompanyRows([0, 1, 2, 3]))
await page.waitForSelector('text=shared-company-row-3')
await settle(page)
const sharedTurnKeys = await page.evaluate(() => [0, 1, 2, 3, 4].map((index) => {
const row = Array.from(document.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find((candidate) => candidate.textContent?.includes(`shared-company-row-${index}`))
if (!row?.dataset.timelineKey) throw new Error(`shared company row ${index} is missing a timeline key`)
return row.dataset.timelineKey
}))
assert.equal(new Set(sharedTurnKeys).size, 5, `company rows sharing a canonical turn need unique DOM keys: ${sharedTurnKeys.join(', ')}`)
assert.equal(
await page.locator('.msg-list').evaluate((element) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes('shared-company-row-4'))
return row?.dataset.identityProbe
}),
'shared-turn-owner',
'inserting an older duplicate-turn row must not remount the existing owner',
)
await gotoFixture(page, baseUrl, 'follow')
const resultMarker = 'fixture-marker-0211'
const initialResultIdentity = await page.locator('.msg-list').evaluate((element, marker) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes(marker))
if (!row) throw new Error('cross-channel result fixture row is missing')
row.dataset.identityProbe = 'cross-channel-result-owner'
return { key: row.dataset.timelineKey }
}, resultMarker)
await page.evaluate(() => window.__messageListFixture?.mergeResultGroupOrder(211, true))
await settle(page, 120)
await page.evaluate(() => window.__messageListFixture?.mergeResultGroupOrder(211, false))
await settle(page, 120)
const reorderedResult = await page.locator('.msg-list').evaluate((element, marker) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes(marker))
if (!row) throw new Error('reordered cross-channel result row is missing')
return { key: row.dataset.timelineKey, probe: row.dataset.identityProbe }
}, resultMarker)
assert.equal(reorderedResult.key, initialResultIdentity.key, 'cross-channel result group order must not change its mounted timeline key')
assert.equal(reorderedResult.probe, 'cross-channel-result-owner', 'cross-channel result group order must reuse the mounted DOM row')
await gotoFixture(page, baseUrl, 'follow')
await page.evaluate(() => window.__messageListFixture?.growDraft(9_000))
await page.waitForSelector('.msg-row-draft')
await settle(page)
const draftGeometry = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>('.msg-list')
window.__rememberedDraftTimelineRow = document.querySelector(
'[data-timeline-key="turn:assistant:fixture-live-turn"]',
)
const row = window.__rememberedDraftTimelineRow as HTMLElement | null
if (!list || !row) throw new Error('live draft timeline wrapper is missing')
list.scrollTop = Math.max(0, row.offsetTop + row.offsetHeight / 2 - list.clientHeight / 2)
return { height: row.getBoundingClientRect().height }
})
await settle(page, 120)
assert.equal(
await page.locator('.msg-list').getAttribute('data-viewport-mode'),
'browsing',
'moving into the middle of a long draft must enter browsing mode',
)
const draftScrollTop = (await metrics(page)).scrollTop
await resetTelemetry(page)
await page.evaluate(() => window.__messageListFixture?.finalizeDraft())
await page.waitForFunction(() => {
const row = document.querySelector('[data-timeline-key="turn:assistant:fixture-live-turn"]')
return !!row && !row.querySelector('.msg-row-draft')
})
await settle(page)
const finalizedDraft = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>('.msg-list')
const committed = document.querySelector<HTMLElement>('[data-timeline-key="turn:assistant:fixture-live-turn"]')
if (!list || !committed) throw new Error('committed live turn is missing')
return {
reused: committed === window.__rememberedDraftTimelineRow,
height: committed.getBoundingClientRect().height,
scrollTop: list.scrollTop,
mode: list.dataset.viewportMode,
collapsed: !!committed.querySelector('.msg-collapse-toggle'),
}
})
assert.equal(finalizedDraft.reused, true, 'draft -> runtime_v2_assistant final must reuse the same outer timeline DOM node')
assert.equal(finalizedDraft.collapsed, false, 'a mounted expanded draft must not auto-collapse when its final arrives')
assert.ok(
finalizedDraft.height >= draftGeometry.height - 4,
`draft -> final must not collapse the long turn (${draftGeometry.height}px -> ${finalizedDraft.height}px)`,
)
assert.ok(Math.abs(finalizedDraft.scrollTop - draftScrollTop) <= 1, 'draft -> final must preserve a browsing viewport inside the turn')
assert.equal(finalizedDraft.mode, 'browsing', 'draft -> final must not resume following while the user is browsing')
assert.equal((await telemetry(page)).scrollTopWrites, 0, 'draft -> final must not programmatically write browsing scrollTop')
}
async function runProgressCapIdentityCase(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'follow', 'progress=1')
await page.waitForSelector('text=progress-marker-0099')
const list = page.locator('.msg-list')
const before = await list.evaluate((element) => {
const viewport = element as HTMLElement
const row = Array.from(viewport.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes('progress-marker-0040'))
if (!row) throw new Error('progress cap anchor row is missing')
viewport.scrollTop += row.getBoundingClientRect().top - viewport.getBoundingClientRect().top - 80
row.dataset.identityProbe = 'stable-progress-row'
return { key: row.dataset.timelineKey }
})
await settle(page, 120)
assert.equal(await list.getAttribute('data-viewport-mode'), 'browsing', 'scrolling to an old progress row must enter browsing mode')
const topBefore = await list.evaluate((element) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes('progress-marker-0040'))
if (!row) throw new Error('progress cap anchor row disappeared before append')
return row.getBoundingClientRect().top
})
await resetTelemetry(page)
await page.evaluate(() => window.__messageListFixture?.appendProgressEntries(1))
await page.waitForSelector('text=progress-marker-0100')
await settle(page, 150)
const after = await list.evaluate((element) => {
const viewport = element as HTMLElement
const row = Array.from(viewport.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes('progress-marker-0040'))
if (!row) throw new Error('progress cap anchor row disappeared after append')
return {
key: row.dataset.timelineKey,
probe: row.dataset.identityProbe,
top: row.getBoundingClientRect().top,
mode: viewport.dataset.viewportMode,
}
})
assert.equal(after.key, before.key, 'progress key must survive the 100-entry window shift')
assert.equal(after.probe, 'stable-progress-row', 'React must retain the anchored progress DOM node')
assert.ok(Math.abs(after.top - topBefore) <= 1, `progress cap moved browsing anchor by ${after.top - topBefore}px`)
assert.equal(after.mode, 'browsing', 'progress cap append must retain browsing mode')
assert.equal((await telemetry(page)).scrollTopWrites, 0, 'progress cap append must not programmatically write browsing scrollTop')
assert.equal(await page.getByText('progress-marker-0000').count(), 0, 'the fixture must actually evict the oldest progress row')
}
async function runExternalProgressGeometryCase(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'follow', 'externalProgress=1')
const list = page.locator('.msg-list')
await list.hover()
await page.mouse.wheel(0, -1_300)
await settle(page)
const anchor = await firstFullyVisibleMarker(page)
const offsetBefore = await markerViewportOffset(page, anchor.marker)
const heightBefore = await list.evaluate(element => (element as HTMLElement).clientHeight)
await resetTelemetry(page)
await page.evaluate(() => window.__messageListFixture?.setExternalRoleCount(12))
await page.waitForSelector('text=Fixture Role 11')
await settle(page, 150)
const heightAfter = await list.evaluate(element => (element as HTMLElement).clientHeight)
const offsetAfter = await markerViewportOffset(page, anchor.marker)
const progressTelemetry = await telemetry(page)
assert.equal(heightAfter, heightBefore, 'role additions must not resize the message viewport')
assert.ok(
Math.abs(offsetAfter - offsetBefore) <= 1,
`external Execution Progress moved the browsing anchor by ${offsetAfter - offsetBefore}px`,
)
assert.equal(progressTelemetry.scrollTopWrites, 0, 'external role additions must not write browsing scrollTop')
assert.equal(
await list.getAttribute('data-viewport-mode'),
'browsing',
'external role additions must retain browsing mode',
)
}
async function runHiddenPendingCase(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'follow')
const initiallyHidden = await page.evaluate(() => !Array.from(document.querySelectorAll<HTMLElement>('.ckpt-title'))
.some((element) => element.textContent?.trim() === 'Approval checkpoint 0'))
assert.equal(initiallyHidden, true, 'the oldest pending checkpoint must begin outside the 200-row DOM window')
await page.getByRole('button', { name: /Pending actions/i }).click()
await page.waitForFunction(() => Array.from(document.querySelectorAll<HTMLElement>('.ckpt-title'))
.some((element) => element.textContent?.trim() === 'Approval checkpoint 0'))
await settle(page, 120)
const focused = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>('.msg-list')
const row = document.querySelector<HTMLElement>('[data-timeline-key="checkpoint:fixture-checkpoint-0"]')
if (!list || !row) throw new Error('focused pending checkpoint row is missing')
const listRect = list.getBoundingClientRect()
const rowRect = row.getBoundingClientRect()
return {
mode: list.dataset.viewportMode,
visible: rowRect.bottom > listRect.top && rowRect.top < listRect.bottom,
bottomGap: list.scrollHeight - list.clientHeight - list.scrollTop,
}
})
assert.equal(focused.mode, 'browsing', 'locating an old pending checkpoint must enter browsing mode')
assert.equal(focused.visible, true, 'pending reminder must locate the checkpoint outside the initial 200-row window')
assert.ok(focused.bottomGap > 100, 'locating an old pending checkpoint must not jump back to latest')
}
async function runCheckpointCase(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'follow')
await page.waitForSelector('text=Approval checkpoint 110')
await page.waitForSelector('text=fixture-marker-0111')
const chronological = await page.evaluate(() => {
const checkpointTitle = Array.from(document.querySelectorAll<HTMLElement>('.ckpt-title'))
.find((element) => element.textContent?.includes('Approval checkpoint 110'))
const checkpointRow = checkpointTitle?.closest('.msg-row')
const nextRow = Array.from(document.querySelectorAll<HTMLElement>('.msg-row'))
.find((element) => element.textContent?.includes('fixture-marker-0111'))
if (!checkpointRow || !nextRow) throw new Error('checkpoint chronology nodes are missing')
window.__rememberedCheckpointRow = checkpointRow
return !!(checkpointRow.compareDocumentPosition(nextRow) & Node.DOCUMENT_POSITION_FOLLOWING)
})
assert.equal(chronological, true, 'pending checkpoint must remain before its chronological successor')
await page.evaluate(() => window.__messageListFixture?.resolveCheckpoint())
await page.waitForSelector('text=Approved fixture checkpoint')
await settle(page)
const retainedNode = await page.evaluate(() => {
const checkpointTitle = Array.from(document.querySelectorAll<HTMLElement>('.ckpt-title'))
.find((element) => element.textContent?.includes('Approval checkpoint 110'))
const currentRow = checkpointTitle?.closest('.msg-row')
return !!currentRow
&& currentRow === window.__rememberedCheckpointRow
&& document.contains(window.__rememberedCheckpointRow ?? null)
})
assert.equal(retainedNode, true, 'resolving a checkpoint must update the same chronological DOM row')
}
async function runPolicyCases(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'manual')
const manual = await metrics(page)
assert.ok(manual.scrollTop <= 2, `manual policy must not perform initial scrolling; scrollTop=${manual.scrollTop}`)
await gotoFixture(page, baseUrl, 'initial-bottom')
const initial = await metrics(page)
assert.ok(Math.abs(initial.bottomGap) <= 2, `initial-bottom must initially reach bottom; gap=${initial.bottomGap}`)
await page.evaluate(() => window.__messageListFixture?.repeatFullSync())
await settle(page, 100)
await page.evaluate(() => window.__messageListFixture?.growTailLayout(1_000))
await settle(page, 150)
const afterEquivalentSyncLayout = await metrics(page)
assert.ok(
Math.abs(afterEquivalentSyncLayout.bottomGap) <= 2,
`equivalent full sync must not end initial-bottom late-layout following; gap=${afterEquivalentSyncLayout.bottomGap}`,
)
await gotoFixture(page, baseUrl, 'initial-bottom')
await page.evaluate(() => window.__messageListFixture?.appendMessages(6))
await page.waitForSelector('text=fixture-marker-0230')
await settle(page)
const afterAppend = await metrics(page)
assert.ok(afterAppend.bottomGap > 100, 'initial-bottom policy must not follow later appends')
// initial-bottom remains a browsing policy after its first layout. The
// return affordance must exist even before new data, and reaching the tail
// through explicit keyboard input must mark the latest durable row once.
await gotoFixture(page, baseUrl, 'initial-bottom')
const list = page.locator('.msg-list')
await list.hover()
await page.mouse.wheel(0, -1_100)
await settle(page)
const latestButton = page.locator('.msg-list-latest-btn')
await latestButton.waitFor({ state: 'visible' })
assert.equal((await latestButton.textContent())?.trim(), 'Back to latest', 'browsing needs a return control before new messages arrive')
await resetTelemetry(page)
await appendMessages(page, 1)
assert.equal((await telemetry(page)).markReadCalls, 0, 'detached initial-bottom append must remain unread')
assert.match((await latestButton.textContent()) ?? '', /1 new/, 'a durable tail append must increment the detached counter')
await list.focus()
await page.keyboard.press('End')
await settle(page, 120)
assert.ok(Math.abs((await metrics(page)).bottomGap) <= 2, 'End must reach the strict bottom under initial-bottom policy')
assert.equal((await telemetry(page)).markReadCalls, 1, 'user reaching strict bottom must mark the latest row exactly once')
await page.waitForFunction(() => !document.querySelector('.msg-list-latest-btn'))
// Old history is earlier than the detach boundary and must never be counted
// as a new tail delivery, including after the local 200-row window expands.
await gotoFixture(page, baseUrl, 'initial-bottom')
await list.hover()
await page.mouse.wheel(0, -1_100)
await settle(page)
await clickHistoryWithoutScrolling(page)
await settle(page, 100)
await clickHistoryWithoutScrolling(page)
await page.waitForSelector('text=history-marker-00-039')
await settle(page, 120)
assert.equal(
(await page.locator('.msg-list-latest-btn').textContent())?.trim(),
'Back to latest',
'prepended history must not increment the detached new-message counter',
)
await gotoFixture(page, baseUrl, 'initial-bottom', 'empty=1')
assert.equal(await page.locator('.msg-timeline-row').count(), 0, 'empty-summary fixture must begin without a visible timeline row')
await page.getByRole('button', { name: 'Load older messages' }).click()
await page.waitForSelector('text=history-marker-00-039')
assert.ok(await page.locator('.msg-timeline-row').count() > 0, 'an empty filtered summary must still continue history pagination')
}
async function main(): Promise<void> {
let server: ViteDevServer | undefined
let browser: Browser | undefined
const pageErrors: string[] = []
const consoleErrors: string[] = []
try {
server = await createServer({
root: FRONTEND_ROOT,
logLevel: 'error',
server: { host: '127.0.0.1', port: 0, strictPort: false },
})
await server.listen()
const address = server.httpServer?.address()
if (!address || typeof address === 'string') throw new Error('Vite did not expose a TCP port')
const baseUrl = `http://127.0.0.1:${address.port}/`
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
page.on('pageerror', (error) => pageErrors.push(error.message))
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
await runFollowAndBrowsingCases(page, baseUrl)
await runInputIntentCases(page, baseUrl)
await runTouchIntentCase(browser, baseUrl)
await runTimelineIdentityCases(page, baseUrl)
await runProgressCapIdentityCase(page, baseUrl)
await runExternalProgressGeometryCase(page, baseUrl)
await runHiddenPendingCase(page, baseUrl)
await runCheckpointCase(page, baseUrl)
await runPolicyCases(page, baseUrl)
assert.deepEqual(pageErrors, [], `browser page errors:\n${pageErrors.join('\n')}`)
assert.deepEqual(consoleErrors, [], `browser console errors:\n${consoleErrors.join('\n')}`)
console.log('message-list-scroll.spec.ts: OK (scroll writes, follow, browsing geometry/history, stable keys, checkpoints, policies)')
} finally {
await browser?.close()
await server?.close()
}
}
await main()
@@ -0,0 +1,534 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import '../index.css'
import { MessageList } from '../chat/MessageList'
import { WorkItemProgressCard } from '../chat/WorkItemProgressCard'
import { __chatStoreTestUtils } from '../chat/ChatStore'
import { mergeConversationMessages } from '../lib/workItemSessions'
import type { ChatMessage } from '../types/chat'
import type { ProgressEntry, RoleWorkItemSummary } from '../types/kanban'
type ScrollPolicy = 'follow' | 'initial-bottom' | 'manual'
interface FixtureTelemetry {
markReadCalls: number
renders: number
scrollEvents: number
scrollTopWrites: number
}
interface MessageListFixtureApi {
appendMessages(count: number): void
appendProgressEntries(count: number): void
addSharedTurnCompanyRows(indices?: number[]): void
finalizeDraft(): void
growDraft(characters: number): void
growMessage(index: number, characters: number): void
growTailLayout(pixels: number): void
mergeResultGroupOrder(index: number, parentFirst: boolean): void
repeatFullSync(): void
resolveCheckpoint(): void
resetTelemetry(): void
setExternalRoleCount(count: number): void
telemetry(): FixtureTelemetry
upgradeResultSurface(index: number): void
}
declare global {
interface Window {
__messageListFixture?: MessageListFixtureApi
__messageListFixtureReady?: boolean
__rememberedCheckpointRow?: Element | null
__rememberedDraftTimelineRow?: Element | null
}
}
const CHANNEL_ID = 'session:scroll-regression'
const BASE_TIMESTAMP = Date.UTC(2026, 6, 13, 8, 0, 0)
const INITIAL_MESSAGE_COUNT = 225
const CHECKPOINT_INDEX = 110
const CHECKPOINT_ID = 'fixture-checkpoint-110'
const CHECKPOINT_INDICES = new Set([0, 50, 70, 90, CHECKPOINT_INDEX, 130, 150, 170, 190])
const SHARED_COMPANY_TURN_ID = 'fixture-shared-company-turn'
const LIVE_TURN_ID = 'fixture-live-turn'
const INITIAL_PROGRESS_COUNT = 100
// Count direct JS writes, including no-op writes which do not dispatch a
// scroll event. This catches the historical idle scrollToEnd feedback loop.
const scrollTopProbe = { writes: 0 }
const scrollTopDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
if (scrollTopDescriptor?.get && scrollTopDescriptor.set && scrollTopDescriptor.configurable) {
Object.defineProperty(Element.prototype, 'scrollTop', {
...scrollTopDescriptor,
set(value: number) {
if ((this as Element).classList?.contains('msg-list')) scrollTopProbe.writes += 1
scrollTopDescriptor.set!.call(this, value)
},
})
}
function messageContent(index: number): string {
const detail = index % 37 === 0
? `\n\n${'Long-form company result with citations, caveats, and acceptance evidence. '.repeat(85)}`
: index % 9 === 0
? '\n\nThis deliberately longer paragraph exercises dynamic Markdown height without relying on a simplified test-only row. '.repeat(3)
: '\n\nA stable production transcript row.'
return `fixture-marker-${String(index).padStart(4, '0')} ${detail}`
}
function buildMessage(index: number): ChatMessage {
if (CHECKPOINT_INDICES.has(index)) {
const checkpointId = `fixture-checkpoint-${index}`
return {
id: `fixture-message-${index}`,
channelId: CHANNEL_ID,
sender: 'system',
senderName: 'OPC',
content: 'Checkpoint fixture payload',
timestamp: BASE_TIMESTAMP + index * 1_000,
mentions: [],
metadata: {
checkpoint_type: 'human_escalation',
checkpoint_id: checkpointId,
escalation_id: checkpointId,
escalation_type: 'decision_needed',
prompt: `Approval checkpoint ${index}\nKeep this card at its chronological position.`,
summary: 'Chronological checkpoint regression fixture',
options: [
{ id: 'approve', label: 'Approve' },
{ id: 'deny', label: 'Deny' },
],
},
}
}
const isUser = index % 5 === 0
return {
id: `fixture-message-${index}`,
channelId: CHANNEL_ID,
sender: isUser ? 'user' : `fixture-agent-${index % 4}`,
senderName: isUser ? 'You' : `Fixture Agent ${index % 4}`,
content: messageContent(index),
timestamp: BASE_TIMESTAMP + index * 1_000,
mentions: [],
metadata: isUser
? { ui_message_id: `fixture-ui-${index}` }
: {
source: 'engine',
canonical_turn_id: `fixture-turn-${index}`,
transcript_kind: 'runtime_v2_assistant',
},
}
}
function buildInitialMessages(): ChatMessage[] {
return Array.from({ length: INITIAL_MESSAGE_COUNT }, (_, index) => buildMessage(index))
}
function buildHistoryMessage(batch: number, offset: number): ChatMessage {
return {
id: `history-message-${batch}-${offset}`,
channelId: CHANNEL_ID,
sender: `history-agent-${offset % 3}`,
senderName: `History Agent ${offset % 3}`,
content: `history-marker-${String(batch).padStart(2, '0')}-${String(offset).padStart(3, '0')}\n\nA prepended historical row.`,
timestamp: BASE_TIMESTAMP - ((batch + 1) * 100_000) + offset * 1_000,
mentions: [],
metadata: { canonical_turn_id: `history-turn-${batch}-${offset}` },
}
}
function buildSharedTurnCompanyMessage(index: number, transcriptKind: string): ChatMessage {
return {
id: `shared-company-message-${index}`,
channelId: CHANNEL_ID,
sender: `company-role-${index}`,
senderName: `Company Role ${index}`,
content: `shared-company-row-${index} — independent committed company surface`,
timestamp: BASE_TIMESTAMP + 2_000_000 + index,
mentions: [],
metadata: {
canonical_turn_id: SHARED_COMPANY_TURN_ID,
transcript_kind: transcriptKind,
},
}
}
function buildProgressEntry(index: number): ProgressEntry {
return {
type: 'status_change',
summary: `progress-marker-${String(index).padStart(4, '0')}`,
detail: `Stable no-id status event ${index}`,
timestamp: BASE_TIMESTAMP + 4_000_000 + index * 1_000,
}
}
function buildRoleWorkItems(count: number): Record<string, RoleWorkItemSummary> {
return Object.fromEntries(Array.from({ length: count }, (_, index) => {
const roleId = `fixture-role-${index}`
return [roleId, {
roleKey: roleId,
roleId,
roleName: `Fixture Role ${index}`,
runtimeStatus: index % 3 === 0 ? 'reflecting' : 'idle',
aggregatedStatus: index % 3 === 0 ? 'active' : 'pending',
workItems: [{
workItemId: `fixture-work-item-${index}`,
workItemProjectionId: `fixture-projection-${index}`,
phase: index % 3 === 0 ? 'running' : 'queued',
kanbanColumn: index % 3 === 0 ? 'in_progress' : 'todo',
title: `Fixture Work Item ${index}`,
executorRoleId: roleId,
executorRoleName: `Fixture Role ${index}`,
createdAt: BASE_TIMESTAMP + index,
updatedAt: BASE_TIMESTAMP + index,
executionTurnId: `fixture-role-turn-${index}`,
progressLog: [],
}],
} satisfies RoleWorkItemSummary]
}))
}
function Fixture() {
const query = useMemo(() => new URLSearchParams(window.location.search), [])
const policy = (query.get('policy') || 'follow') as ScrollPolicy
const progressFixture = query.get('progress') === '1'
const externalProgressFixture = query.get('externalProgress') === '1'
const emptySummaryFixture = query.get('empty') === '1'
const [messages, setMessages] = useState<ChatMessage[]>(() => {
const initial = buildInitialMessages()
if (!emptySummaryFixture) return initial
return initial.map(message => ({
...message,
metadata: { ...(message.metadata ?? {}), detail_visibility: 'full' },
}))
})
const [progressLog, setProgressLog] = useState<ProgressEntry[]>(() => (
progressFixture
? Array.from({ length: INITIAL_PROGRESS_COUNT }, (_, index) => buildProgressEntry(index))
: []
))
const [externalRoleWorkItems, setExternalRoleWorkItems] = useState<Record<string, RoleWorkItemSummary>>({})
const [draftText, setDraftText] = useState('')
const draftTextRef = useRef(draftText)
draftTextRef.current = draftText
const historyBatchRef = useRef(0)
// This state deliberately changes from markRead. The former implementation
// accidentally made that state update part of a scroll-to-bottom feedback
// loop; a correct MessageList must not care that this callback is recreated.
const [, setReadVersion] = useState(0)
const telemetryRef = useRef<FixtureTelemetry>({ markReadCalls: 0, renders: 0, scrollEvents: 0, scrollTopWrites: 0 })
telemetryRef.current.renders += 1
const appendMessages = useCallback((count: number) => {
setMessages((current) => {
const start = current.reduce((max, message) => {
const match = /^fixture-message-(\d+)$/.exec(message.id)
return match ? Math.max(max, Number(match[1]) + 1) : max
}, INITIAL_MESSAGE_COUNT)
return [
...current,
...Array.from({ length: count }, (_, offset) => buildMessage(start + offset)),
]
})
}, [])
const appendProgressEntries = useCallback((count: number) => {
setProgressLog((current) => {
const start = current.reduce((max, entry) => {
const match = /^progress-marker-(\d+)$/.exec(entry.summary)
return match ? Math.max(max, Number(match[1]) + 1) : max
}, INITIAL_PROGRESS_COUNT)
return [
...current,
...Array.from({ length: count }, (_, offset) => buildProgressEntry(start + offset)),
].slice(-INITIAL_PROGRESS_COUNT)
})
}, [])
const growDraft = useCallback((characters: number) => {
setDraftText((current) => `${current}${' live-reply-content'.repeat(Math.max(1, Math.ceil(characters / 19)))}`)
}, [])
const growMessage = useCallback((index: number, characters: number) => {
setMessages((current) => current.map((message) => message.id === `fixture-message-${index}`
? {
...message,
content: `${message.content}\n\n${'Expanded content above the viewport anchor. '.repeat(Math.max(1, Math.ceil(characters / 44)))}`,
}
: message))
}, [])
const repeatFullSync = useCallback(() => {
setMessages((current) => current.map((message) => ({
...message,
metadata: message.metadata ? { ...message.metadata } : undefined,
})))
}, [])
const growTailLayout = useCallback((pixels: number) => {
const tail = Array.from(document.querySelectorAll<HTMLElement>('.msg-timeline-row')).at(-1)
if (!tail) throw new Error('tail timeline row is missing')
let spacer = tail.querySelector<HTMLElement>('[data-late-layout-spacer]')
if (!spacer) {
spacer = document.createElement('div')
spacer.dataset.lateLayoutSpacer = 'true'
tail.appendChild(spacer)
}
spacer.style.height = `${pixels}px`
}, [])
const upgradeResultSurface = useCallback((index: number) => {
setMessages((current) => {
const existing = current.find(message => message.id === `fixture-message-${index}`)
if (!existing || existing.sender === 'user') return current
return __chatStoreTestUtils.dedupeMessages([
...current,
{
...existing,
id: `fixture-upgraded-result-${index}`,
sender: 'fixture-company-role',
senderName: 'Fixture Company Role',
metadata: {
...existing.metadata,
source: 'engine',
canonical_turn_id: `fixture-upgraded-turn-${index}`,
transcript_kind: 'child_task_result',
detail_visibility: 'summary',
},
},
])
})
}, [])
const mergeResultGroupOrder = useCallback((index: number, parentFirst: boolean) => {
const native = buildMessage(index)
if (native.sender === 'user') throw new Error('result group fixture needs an assistant row')
const authoritative: ChatMessage = {
...native,
id: `fixture-cross-channel-result-${index}`,
channelId: 'session:scroll-regression-child',
sender: 'fixture-company-role',
senderName: 'Fixture Company Role',
timestamp: native.timestamp + 10,
metadata: {
...native.metadata,
canonical_turn_id: `fixture-authoritative-turn-${index}`,
transcript_kind: 'child_task_result',
},
}
const selected = mergeConversationMessages(parentFirst
? [[authoritative], [native]]
: [[native], [authoritative]])
setMessages((current) => [
...current.filter(message => (
message.id !== native.id
&& message.id !== authoritative.id
)),
...selected,
])
}, [])
const addSharedTurnCompanyRows = useCallback((indices = [0, 1, 2, 3, 4]) => {
const transcriptKinds = [
'company_role_result',
'child_result',
'runtime_v2_intermediate_assistant',
'runtime_v2_assistant',
'runtime_v2_assistant',
]
setMessages((current) => {
const existingIds = new Set(current.map(message => message.id))
return [
...current,
...indices
.filter(index => !existingIds.has(`shared-company-message-${index}`))
.map(index => buildSharedTurnCompanyMessage(index, transcriptKinds[index] ?? 'runtime_v2_assistant')),
]
})
}, [])
const finalizeDraft = useCallback(() => {
const committedContent = draftTextRef.current.trim() || 'fixture-live-final-content — committed assistant response'
setDraftText('')
setMessages((current) => current.some((message) => message.id === 'fixture-live-final')
? current
: [
...current,
{
id: 'fixture-live-final',
channelId: CHANNEL_ID,
sender: 'fixture-agent-final',
senderName: 'Fixture Final Agent',
content: committedContent,
timestamp: BASE_TIMESTAMP + 3_000_000,
mentions: [],
metadata: {
canonical_turn_id: LIVE_TURN_ID,
transcript_kind: 'runtime_v2_assistant',
},
},
])
}, [])
const loadOlderHistory = useCallback(() => {
const batch = historyBatchRef.current
historyBatchRef.current += 1
setMessages((current) => [
...Array.from({ length: 40 }, (_, offset) => buildHistoryMessage(batch, offset)),
...current,
])
}, [])
const resolveCheckpoint = useCallback(() => {
setMessages((current) => {
if (current.some((message) => message.id === 'fixture-checkpoint-response')) return current
return [
...current,
{
id: 'fixture-checkpoint-response',
channelId: CHANNEL_ID,
sender: 'user',
senderName: 'You',
content: 'Approved fixture checkpoint',
timestamp: BASE_TIMESTAMP + 1_000_000,
mentions: [],
metadata: {
ui_message_id: 'fixture-checkpoint-response-ui',
response_to_checkpoint_id: CHECKPOINT_ID,
response_to_checkpoint_type: 'human_escalation',
checkpoint_reply_kind: 'approve',
},
},
]
})
}, [])
const resetTelemetry = useCallback(() => {
telemetryRef.current.markReadCalls = 0
telemetryRef.current.renders = 0
telemetryRef.current.scrollEvents = 0
scrollTopProbe.writes = 0
}, [])
window.__messageListFixture = {
appendMessages,
appendProgressEntries,
addSharedTurnCompanyRows,
finalizeDraft,
growDraft,
growMessage,
growTailLayout,
mergeResultGroupOrder,
repeatFullSync,
resolveCheckpoint,
resetTelemetry,
setExternalRoleCount: (count: number) => setExternalRoleWorkItems(buildRoleWorkItems(count)),
telemetry: () => ({ ...telemetryRef.current, scrollTopWrites: scrollTopProbe.writes }),
upgradeResultSurface,
}
const handleMarkRead = () => {
telemetryRef.current.markReadCalls += 1
setReadVersion((value) => value + 1)
}
// Use an intersection type so the fixture can be committed alongside the
// implementation change: the old component ignores scrollPolicy and fails
// these checks; the new public contract consumes it.
const ProductionMessageList = MessageList as React.ComponentType<
React.ComponentProps<typeof MessageList> & { scrollPolicy: ScrollPolicy }
>
React.useEffect(() => {
window.__messageListFixtureReady = true
return () => {
window.__messageListFixtureReady = false
delete window.__messageListFixture
}
}, [])
React.useEffect(() => {
const list = document.querySelector('.msg-list')
const countScroll = () => {
telemetryRef.current.scrollEvents += 1
}
list?.addEventListener('scroll', countScroll, { passive: true })
return () => list?.removeEventListener('scroll', countScroll)
}, [])
return (
<main className="app-shell theme-paper message-list-scroll-fixture">
<div className="message-list-scroll-fixture-header">
Production MessageList fixture {policy}
</div>
<section className="message-list-scroll-fixture-body">
{externalProgressFixture && (
<div className="ctx-work-item-progress">
<WorkItemProgressCard
workItemLog={[]}
roleWorkItems={externalRoleWorkItems}
isCompanyRuntime
/>
</div>
)}
<ProductionMessageList
messages={messages}
channelName="Scroll regression"
viewKind="session"
detailMode={progressFixture ? 'full' : 'summary'}
scrollPolicy={policy}
draftAssistantText={draftText}
draftUpdatedAt={BASE_TIMESTAMP + 900_000}
draftTurnId={LIVE_TURN_ID}
isCompanyRuntime
onSend={() => undefined}
onMarkRead={handleMarkRead}
hasOlderHistory
totalMessageCount={messages.length + 100}
onLoadOlderHistory={loadOlderHistory}
progressLog={progressLog}
showWorkItemRuntimeCard={false}
showRuntimeProgress={progressFixture}
/>
</section>
</main>
)
}
const style = document.createElement('style')
style.textContent = `
.message-list-scroll-fixture {
display: flex;
flex-direction: column;
width: 620px;
height: 720px;
max-height: 92vh;
margin: 4vh auto;
border: 1px solid var(--border);
border-radius: 12px;
}
.message-list-scroll-fixture-header {
flex: 0 0 44px;
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text-secondary);
font-size: 12px;
}
.message-list-scroll-fixture-body {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
`
document.head.appendChild(style)
const root = document.getElementById('root')
if (!root) throw new Error('MessageList fixture root is missing')
createRoot(root).render(<Fixture />)
@@ -338,6 +338,8 @@ export interface ChatMessageMeta {
kind?: string
ui_message_id?: string
ui_created_at?: number
/** UI-only identity retained across semantic result-surface replacement. */
ui_timeline_id?: string
canonical_turn_id?: string
turn_id?: string
execution_mode?: string
@@ -298,6 +298,10 @@ export interface Session {
detailLoaded?: boolean
fullLoaded?: boolean
hasMore?: boolean
/** Pagination state for the committed company/task transcript. */
summaryHasMore?: boolean
/** Pagination state for the full child/runtime transcript. */
fullHasMore?: boolean
detailLoading?: boolean
detailError?: string
viewGeneration?: number
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict'
import type { Session } from '../types/kanban'
import { composerExecModeForSession } from './ContextPanel'
import {
composerExecModeForSession,
conversationHasOlderHistory,
sessionHasMoreForDetail,
} from './ContextPanel'
function makeSession(overrides: Partial<Session> = {}): Session {
return {
@@ -49,4 +53,44 @@ assert.equal(
'org',
)
const independentlyPaged = makeSession({
hasMore: false,
summaryHasMore: false,
fullHasMore: true,
messageCount: 400,
})
assert.equal(sessionHasMoreForDetail(independentlyPaged, 'summary'), false)
assert.equal(sessionHasMoreForDetail(independentlyPaged, 'full'), true)
assert.equal(
conversationHasOlderHistory([independentlyPaged], 200, 'summary'),
false,
'a full-detail ACK must not reopen summary history',
)
assert.equal(
conversationHasOlderHistory([independentlyPaged], 200, 'full'),
true,
'full history must retain its own cursor state',
)
assert.equal(
conversationHasOlderHistory([independentlyPaged], 200, 'full', false),
true,
'a known scoped cursor remains loadable while a company turn is running',
)
const summaryOnlyState = makeSession({
hasMore: false,
summaryHasMore: true,
messageCount: 400,
})
assert.equal(
sessionHasMoreForDetail(summaryOnlyState, 'full'),
undefined,
'generic hasMore is no longer authoritative after a scoped policy is known',
)
assert.equal(
conversationHasOlderHistory([makeSession({ messageCount: 400 })], 200, 'summary', false),
false,
'only the racy message-count fallback is suppressed during active generation',
)
console.log('ContextPanel composer identity checks passed')
@@ -193,6 +193,45 @@ function hasCompanyRuntimeIdentity(session: Session): boolean {
)
}
function isCompanyRuntimeSession(
session: Session | null | undefined,
relatedSessionCount = 0,
): boolean {
if (!session) return false
const mode = String(session.execMode ?? '').trim().toLowerCase()
return relatedSessionCount > 0
|| hasCompanyRuntimeIdentity(session)
|| mode === 'company'
|| mode === 'org'
|| mode === 'custom'
}
export function sessionHasMoreForDetail(
session: Session,
detailLevel: 'summary' | 'full',
): boolean | undefined {
const scoped = detailLevel === 'full' ? session.fullHasMore : session.summaryHasMore
if (scoped !== undefined) return scoped
// Old snapshots only expose the unscoped value. Once either scoped cursor
// has been observed, the generic field may describe the other policy.
if (session.summaryHasMore === undefined && session.fullHasMore === undefined) {
return session.hasMore
}
return undefined
}
export function conversationHasOlderHistory(
sessions: Session[],
displayedMessageCount: number,
detailLevel: 'summary' | 'full',
allowMessageCountFallback = true,
): boolean {
const pagination = sessions.map(session => sessionHasMoreForDetail(session, detailLevel))
if (pagination.some(hasMore => hasMore === true)) return true
if (sessions.length !== 1 || pagination[0] === false) return false
return allowMessageCountFallback && sessions[0].messageCount > displayedMessageCount
}
function hasCustomRuntimeIdentity(session: Session): boolean {
const rawMode = String(session.execMode ?? '').trim().toLowerCase()
const normalizedMode = normalizePanelExecMode(session.execMode)
@@ -498,7 +537,7 @@ export function ContextPanel({
const isChildDetail = activeView.kind === 'child-detail'
const isTaskDetail = activeView.kind === 'task-detail'
const isCompanyRuntime = !!(activeSession && (activeSession.isCompanyRuntime || childSessions.length > 0))
const isCompanyRuntime = isCompanyRuntimeSession(activeSession, childSessions.length)
const showTabs = activeView.kind === 'session' && activeSession
const canSend = isSecretary ? true : !!activeSession
const showSessionStrip = !isChildDetail && openSessions.length > 0
@@ -553,10 +592,14 @@ export function ContextPanel({
const matched = activeConversation.timelineSessions.find(
(session) => session.channelId === oldestMessage.channelId,
)
if (matched) return matched
if (matched && sessionHasMoreForDetail(matched, activeDetailMode) !== false) return matched
}
const knownTarget = activeConversation.timelineSessions.find(
session => sessionHasMoreForDetail(session, activeDetailMode) === true,
)
if (knownTarget) return knownTarget
return activeDisplaySession ?? activeSession
}, [activeConversation.timelineSessions, activeDisplaySession, activeSession])
}, [activeConversation.timelineSessions, activeDetailMode, activeDisplaySession, activeSession])
// Child detail: find the agent for this session
const childDetailAgent = useMemo(() => {
@@ -706,24 +749,20 @@ export function ContextPanel({
draftTurnId={childDetailSession.draftTurnId}
onMarkRead={onMarkRead}
hasOlderHistory={
// The `messageCount > loaded.length` race flashes the
// "Load older messages" hint every ~1s while the agent
// streams: backend bumps count, new message arrives
// at chatStore 1 tick later, hint appears then hides.
// The insertion/removal of the hint row also triggers
// auto-scroll, which pushes the user's own input off
// the top of the viewport. Suppress the hint while the
// session is actively working — any transient delta
// during active turns is almost always in-flight new
// messages, not an older-history gap.
!isSessionWorking(childDetailSession)
&& childDetailSession.messageCount > childDetailMessages.length
// A scoped backend cursor remains actionable during live
// work. Only the racy messageCount fallback is suppressed.
conversationHasOlderHistory(
[childDetailSession],
childDetailMessages.length,
'full',
!isSessionWorking(childDetailSession),
)
}
totalMessageCount={childDetailSession.messageCount}
onLoadOlderHistory={(oldestMessage) => onLoadSessionHistory?.(childDetailSession.taskId, oldestMessage, 'full')}
loadingOlderHistory={isSessionHistoryLoading?.(childDetailSession.taskId) ?? false}
autoScroll={false}
initialScrollToBottom
scrollPolicy="initial-bottom"
scrollScope={childDetailSession.channelId}
showRuntimeProgress
renderUserMarkdown
/>
@@ -911,7 +950,7 @@ export function ContextPanel({
.map(id => agents.find(agent => agent.agent_id === id)?.name ?? id)
.filter(Boolean)
const runtimeLabel = sessionRuntimeLabel(sessionConversationSession ?? session, activeChildCount)
const sessionIsCompanyRuntime = !!(session.isCompanyRuntime || sessionChildren.length > 0)
const sessionIsCompanyRuntime = isCompanyRuntimeSession(session, sessionChildren.length)
const sessionDisplaySession = sessionConversation.displaySession ?? session
const sessionProgressLog = mergeConversationProgressLog(sessionConversation.timelineSessions)
const sessionMessageCount = getConversationMessageCount(sessionConversation.timelineSessions)
@@ -970,37 +1009,52 @@ export function ContextPanel({
channelName={sessionDisplaySession?.title ?? session.title}
viewKind="session"
detailMode={sessionDetailLevel(sessionDisplaySession)}
agentStatus={sessionConversationSession?.agentStatus ?? sessionDisplaySession?.agentStatus}
currentTool={sessionConversationSession?.currentTool ?? sessionDisplaySession?.currentTool}
toolElapsedMs={sessionConversationSession?.toolElapsedMs ?? sessionDisplaySession?.toolElapsedMs}
lastToolSummary={sessionConversationSession?.lastToolSummary ?? sessionDisplaySession?.lastToolSummary}
progressLog={sessionProgressLog}
draftAssistantText={sessionConversationSession?.draftAssistantText ?? sessionDisplaySession?.draftAssistantText}
draftUpdatedAt={sessionConversationSession?.draftUpdatedAt ?? sessionDisplaySession?.draftUpdatedAt}
draftIteration={sessionConversationSession?.draftIteration ?? sessionDisplaySession?.draftIteration}
draftTurnId={sessionConversationSession?.draftTurnId ?? sessionDisplaySession?.draftTurnId}
isCompanyRuntime={sessionConversationSession?.isCompanyRuntime ?? sessionIsCompanyRuntime}
agentStatus={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.agentStatus ?? sessionDisplaySession?.agentStatus)}
currentTool={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.currentTool ?? sessionDisplaySession?.currentTool)}
toolElapsedMs={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.toolElapsedMs ?? sessionDisplaySession?.toolElapsedMs)}
lastToolSummary={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.lastToolSummary ?? sessionDisplaySession?.lastToolSummary)}
progressLog={sessionIsCompanyRuntime ? undefined : sessionProgressLog}
draftAssistantText={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftAssistantText ?? sessionDisplaySession?.draftAssistantText)}
draftUpdatedAt={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftUpdatedAt ?? sessionDisplaySession?.draftUpdatedAt)}
draftIteration={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftIteration ?? sessionDisplaySession?.draftIteration)}
draftTurnId={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftTurnId ?? sessionDisplaySession?.draftTurnId)}
isCompanyRuntime={sessionIsCompanyRuntime}
workItemLog={sessionConversationSession?.workItemLog ?? session.workItemLog}
childSessions={sessionWorkItemRoleSessions}
showWorkItemRuntimeCard={!sessionIsCompanyRuntime}
onSend={(content, _taskId, metadata) => onSessionSend?.(session.taskId, content, undefined, metadata)}
onWorkItemClick={onWorkItemClick}
onWorkItemOpenSession={onWorkItemOpenSession}
onMarkRead={() => onSessionMarkRead?.(session.taskId)}
scrollScope={session.channelId}
hasOlderHistory={
// Suppress during active work — see note
// on the childDetailSession case above.
!sessionConversation.timelineSessions.some(isSessionWorking)
&& sessionMessageCount > sessionMessages.length
// Keep known cursors available during live work;
// suppress only the count-based fallback.
conversationHasOlderHistory(
sessionConversation.timelineSessions,
sessionMessages.length,
sessionDetailLevel(sessionDisplaySession ?? session),
!sessionConversation.timelineSessions.some(isSessionWorking),
)
}
totalMessageCount={sessionMessageCount}
onLoadOlderHistory={(oldestMessage) => {
const targetSession = sessionConversation.timelineSessions.find(
const detailLevel = sessionDetailLevel(sessionDisplaySession ?? session)
const matchedSession = sessionConversation.timelineSessions.find(
(timelineSession) => timelineSession.channelId === oldestMessage?.channelId,
)
const targetSession = (
matchedSession
&& sessionHasMoreForDetail(matchedSession, detailLevel) !== false
? matchedSession
: undefined
) ?? sessionConversation.timelineSessions.find(
timelineSession => sessionHasMoreForDetail(timelineSession, detailLevel) === true,
) ?? sessionDisplaySession ?? session
return onLoadSessionHistory?.(
targetSession.taskId,
oldestMessage,
sessionDetailLevel(targetSession, { childDetail: targetSession.mode === 'child' }),
detailLevel,
)
}}
loadingOlderHistory={sessionHistoryLoading}
@@ -1062,6 +1116,7 @@ export function ContextPanel({
detailMode="summary"
onSend={onMessageSend}
onMarkRead={onMarkRead}
scrollScope={channelId}
/>
</>
)}
@@ -1077,6 +1132,7 @@ export function ContextPanel({
detailMode="summary"
onSend={onMessageSend}
onMarkRead={onMarkRead}
scrollScope={secretaryChannelId}
/>
<MessageComposer
disabled={false}
@@ -1099,7 +1155,7 @@ export function ContextPanel({
onComplete={(activeHeaderSession ?? activeSession).status !== 'done' && (activeHeaderSession ?? activeSession).status !== 'cancelled' ? onComplete : undefined}
onResume={onResume}
/>
{isCompanyRuntime && (hasRoleWorkItems || activeWorkItemLog.length > 0 || activeWorkItemRoleSessions.length > 0) && (
{isCompanyRuntime && (
<div className="ctx-work-item-progress">
<WorkItemProgressCard
workItemLog={activeWorkItemLog}
@@ -1117,16 +1173,16 @@ export function ContextPanel({
channelName={channelName}
viewKind="session"
detailMode={activeDetailMode}
agentStatus={activeConversationSession?.agentStatus ?? activeDisplaySession?.agentStatus}
currentTool={activeConversationSession?.currentTool ?? activeDisplaySession?.currentTool}
toolElapsedMs={activeConversationSession?.toolElapsedMs ?? activeDisplaySession?.toolElapsedMs}
lastToolSummary={activeConversationSession?.lastToolSummary ?? activeDisplaySession?.lastToolSummary}
progressLog={activeConversationProgress}
draftAssistantText={activeConversationSession?.draftAssistantText ?? activeDisplaySession?.draftAssistantText}
draftUpdatedAt={activeConversationSession?.draftUpdatedAt ?? activeDisplaySession?.draftUpdatedAt}
draftIteration={activeConversationSession?.draftIteration ?? activeDisplaySession?.draftIteration}
draftTurnId={activeConversationSession?.draftTurnId ?? activeDisplaySession?.draftTurnId}
isCompanyRuntime={activeConversationSession?.isCompanyRuntime ?? isCompanyRuntime}
agentStatus={isCompanyRuntime ? undefined : (activeConversationSession?.agentStatus ?? activeDisplaySession?.agentStatus)}
currentTool={isCompanyRuntime ? undefined : (activeConversationSession?.currentTool ?? activeDisplaySession?.currentTool)}
toolElapsedMs={isCompanyRuntime ? undefined : (activeConversationSession?.toolElapsedMs ?? activeDisplaySession?.toolElapsedMs)}
lastToolSummary={isCompanyRuntime ? undefined : (activeConversationSession?.lastToolSummary ?? activeDisplaySession?.lastToolSummary)}
progressLog={isCompanyRuntime ? undefined : activeConversationProgress}
draftAssistantText={isCompanyRuntime ? undefined : (activeConversationSession?.draftAssistantText ?? activeDisplaySession?.draftAssistantText)}
draftUpdatedAt={isCompanyRuntime ? undefined : (activeConversationSession?.draftUpdatedAt ?? activeDisplaySession?.draftUpdatedAt)}
draftIteration={isCompanyRuntime ? undefined : (activeConversationSession?.draftIteration ?? activeDisplaySession?.draftIteration)}
draftTurnId={isCompanyRuntime ? undefined : (activeConversationSession?.draftTurnId ?? activeDisplaySession?.draftTurnId)}
isCompanyRuntime={isCompanyRuntime}
workItemLog={activeWorkItemLog}
roleWorkItems={activeRoleWorkItems}
executorRoleWorkItems={activeExecutorRoleWorkItems}
@@ -1135,11 +1191,16 @@ export function ContextPanel({
onWorkItemClick={onWorkItemClick}
onWorkItemOpenSession={onWorkItemOpenSession}
onMarkRead={onMarkRead}
scrollScope={channelId}
hasOlderHistory={
// Suppress during active work — see note on the
// childDetailSession case above.
!activeConversation.timelineSessions.some(isSessionWorking)
&& activeConversationMessageCount > messages.length
// Keep known cursors available during live work;
// suppress only the count-based fallback.
conversationHasOlderHistory(
activeConversation.timelineSessions,
messages.length,
activeDetailMode,
!activeConversation.timelineSessions.some(isSessionWorking),
)
}
totalMessageCount={activeConversationMessageCount}
onLoadOlderHistory={(oldestMessage) => {
@@ -1148,7 +1209,7 @@ export function ContextPanel({
return onLoadSessionHistory?.(
targetSession.taskId,
oldestMessage,
sessionDetailLevel(targetSession, { childDetail: targetSession.mode === 'child' }),
activeDetailMode,
)
}}
loadingOlderHistory={activeConversationLoading}
@@ -297,9 +297,11 @@ export function TaskDetailView({
{linkedSession && linkedSessionMessages && linkedSessionMessages.length > 0 ? (
<div className="task-detail-linked-messages">
<MessageList
key={linkedSession.channelId}
messages={linkedSessionMessages}
channelName={linkedSession.title ?? 'Runtime Session'}
detailMode="summary"
scrollScope={linkedSession.channelId}
/>
</div>
) : linkedSession ? (
@@ -10,10 +10,74 @@ assert.match(src, /makeOptimisticUserMessageId/, 'ordinary composer sends must c
assert.match(src, /chatStore\.sendMessage/, 'ordinary composer sends must echo the user message locally before backend response')
assert.match(src, /ui_message_id: uiMessageId/, 'optimistic local message and websocket metadata must share ui_message_id')
assert.match(src, /checkpointReplyId/, 'checkpoint replies must be excluded from ordinary optimistic composer echo')
assert.match(src, /const \{ markRead \} = chatStore/, 'workspace must consume the stable markRead action directly')
assert.doesNotMatch(src, /chatStore\.markRead/, 'workspace mark-read callbacks must not depend on the aggregate chatStore object')
assert.equal(
[...src.matchAll(/\bmarkRead\(/g)].length,
2,
'markRead must only be invoked by the active viewport and per-session viewport callbacks',
)
assert.match(
src,
/const handleMarkRead = useCallback\(\(\) => \{\s*for \(const visibleChannelId of visibleChannelIds\) \{\s*markRead\(visibleChannelId\)\s*\}\s*\}, \[visibleChannelIds, markRead\]\)/,
'the active transcript viewport must mark every channel represented by the visible company timeline',
)
assert.match(
src,
/const handleMarkSessionRead = useCallback\(\(taskId: string\) => \{[\s\S]*?if \(session\) markRead\(session\.channelId\)[\s\S]*?\}, \[sessions, markRead\]\)/,
'each multi-session transcript viewport callback must own its channel markRead',
)
assert.match(src, /onMarkRead=\{handleMarkRead\}/, 'the active viewport must receive the markRead callback')
assert.match(src, /onSessionMarkRead=\{handleMarkSessionRead\}/, 'multi-session viewports must receive their scoped markRead callback')
assert.match(
src,
/const outgoing = metadata\?\.ui_message_id\s*\?\s*metadata\s*:\s*\{ \.\.\.\(metadata \?\? \{\}\), ui_message_id: makeOptimisticUserMessageId\(\) \}/,
'every session send must carry a client-generated ui_message_id so the backend can deduplicate re-deliveries',
)
console.log('WorkspacePage.test.ts: OK (optimistic composer echo wiring)')
const requestHistoryStart = src.indexOf('const requestSessionHistory = useCallback')
const requestHistoryEnd = src.indexOf('const isSessionHistoryLoading = useCallback', requestHistoryStart)
assert.ok(requestHistoryStart >= 0 && requestHistoryEnd > requestHistoryStart, 'history request implementation must be present')
const requestHistorySrc = src.slice(requestHistoryStart, requestHistoryEnd)
assert.doesNotMatch(
requestHistorySrc,
/setTimeout/,
'history single-flight completion must follow the transport Promise, not a fixed 800ms timer',
)
assert.match(
requestHistorySrc,
/Promise\.resolve\(request\)[\s\S]*?\.finally\(\(\) => \{[\s\S]*?historyRequestInFlightRef\.current\.delete\(requestKey\)/,
'history single-flight state must be released only when the transport request settles',
)
assert.match(
requestHistorySrc,
/const generation = historyRequestGenerationRef\.current[\s\S]*?const requestKey = \[\s*generation,/,
'history claims must be scoped to a project generation',
)
assert.match(
requestHistorySrc,
/historyRequestInFlightRef\.current\.delete\(requestKey\)\s*if \(historyRequestGenerationRef\.current !== generation\) return/,
'an old project Promise must not clear loading state for a newer project generation',
)
assert.match(
requestHistorySrc,
/oldestMessage && targetChannelId && oldestMessage\.channelId !== targetChannelId[\s\S]*?getChannelMessagesRef\.current\(targetChannelId\)\.find\([\s\S]*?isMessageVisibleAtDetailLevel\(message, detailLevel\)/,
'multi-channel history must use a selected target cursor visible to the requested detail policy',
)
assert.match(
src,
/if \(autoHistoryRequestRef\.current\.scope !== activeSessionId\) \{\s*autoHistoryRequestRef\.current\.scope = activeSessionId\s*autoHistoryRequestRef\.current\.active\.clear\(\)/,
'switching the active transcript scope must clear prior auto-history claims',
)
assert.match(
src,
/const historyTargets = activeConversation\.timelineSessions\.length > 0\s*\? activeConversation\.timelineSessions/,
'company history must enumerate the root and child timeline sessions',
)
assert.match(
src,
/const detailLevel = isCompanyConversation\(activeSession, childSessions\.length\)\s*\? 'summary'[\s\S]*?requestSessionHistory\(\s*session\.taskId,\s*undefined,\s*detailLevel/,
'company root and child history requests must use summary detail independently',
)
console.log('WorkspacePage.test.ts: OK (composer, mark-read, and history wiring)')
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { AgentInfo, OrgInfoPayload, SavedOrgSummary } from '../types/visual'
import { WorkItemRecoveryPanel } from './WorkItemRecoveryPanel'
import type { ChatMessage, CheckpointReplyMetadata, OutgoingAttachmentPayload } from '../types/chat'
@@ -14,8 +14,10 @@ import { BoardSelector } from '../kanban/BoardSelector'
import {
getConversationPeerSessions,
getWorkItemChildSessions,
isMessageVisibleAtDetailLevel,
mergeConversationMessages,
projectSessionConversation,
selectCompanySummaryMessages,
} from '../lib/workItemSessions'
import { getRuntimeOrgView } from '../lib/runtimeOrg'
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
@@ -125,6 +127,18 @@ function sessionDetailLevel(
return session.execMode === 'company' || session.execMode === 'org' || session.execMode === 'custom' ? 'summary' : 'full'
}
function isCompanyConversation(session: Session | null | undefined, relatedSessionCount = 0): boolean {
if (!session) return false
const mode = String(session.execMode ?? '').trim().toLowerCase()
return relatedSessionCount > 0
|| !!session.isCompanyRuntime
|| !!session.roleWorkItems
|| !!session.executorRoleWorkItems
|| mode === 'company'
|| mode === 'org'
|| mode === 'custom'
}
function sessionBoardId(session: Session | null | undefined): string | null {
const boardId = String(session?.originTaskId ?? session?.taskId ?? '').trim()
return boardId || null
@@ -244,7 +258,7 @@ interface WorkspacePageProps {
onLoadSessionDetail?: (
taskId: string,
opts?: { beforeCreatedAt?: number; beforeMessageId?: string; limit?: number; detailLevel?: 'summary' | 'full'; include?: string[] },
) => void
) => Promise<void> | void
onOpenExecutionPanel?: (taskId: string) => void
onCollabSync?: () => void
orgInfoData?: OrgInfoPayload | null
@@ -304,6 +318,7 @@ export function WorkspacePage({
onSavedOrgLoad,
}: WorkspacePageProps) {
const { sessions, activeSessionId, activeSession } = sessionStore
const { markRead } = chatStore
// ── Panel state ──
const [panelState, setPanelState] = useState<'collapsed' | 'open' | 'maximized'>('collapsed')
@@ -320,10 +335,18 @@ export function WorkspacePage({
const [multiSessionView, setMultiSessionView] = useState(false)
const [sessionHistoryLoading, setSessionHistoryLoading] = useState<Record<string, boolean>>({})
const onLoadSessionDetailRef = useRef(onLoadSessionDetail)
const autoHistoryRequestRef = useRef<{ active: string | null; child: string | null }>({
active: null,
const sessionsRef = useRef(sessions)
const getChannelMessagesRef = useRef(chatStore.getChannelMessages)
const autoHistoryRequestRef = useRef<{ scope: string | null; active: Set<string>; child: string | null }>({
scope: null,
active: new Set(),
child: null,
})
const historyRequestInFlightRef = useRef<Set<string>>(new Set())
const historyRequestGenerationRef = useRef(0)
sessionsRef.current = sessions
getChannelMessagesRef.current = chatStore.getChannelMessages
const isCompanyMode = execMode === 'company' || execMode === 'org' || execMode === 'custom'
@@ -375,16 +398,58 @@ export function WorkspacePage({
) => {
const loadSessionDetail = onLoadSessionDetailRef.current
if (!loadSessionDetail || !taskId) return
setSessionHistoryLoading(prev => prev[taskId] ? prev : { ...prev, [taskId]: true })
loadSessionDetail(taskId, {
limit: SESSION_DETAIL_PAGE_SIZE,
beforeCreatedAt: oldestMessage?.timestamp,
beforeMessageId: oldestMessage?.id,
const generation = historyRequestGenerationRef.current
const targetSession = sessionsRef.current.find(session => session.taskId === taskId)
const targetChannelId = targetSession?.channelId
const cursorMessage = oldestMessage && targetChannelId && oldestMessage.channelId !== targetChannelId
? getChannelMessagesRef.current(targetChannelId).find(
message => isMessageVisibleAtDetailLevel(message, detailLevel),
)
: oldestMessage
const requestKey = [
generation,
taskId,
detailLevel,
cursorMessage?.timestamp ?? 'latest',
cursorMessage?.id ?? '',
].join('|')
if (historyRequestInFlightRef.current.has(requestKey)) return
// Claim the cursor synchronously before invoking the transport. Loading
// state is asynchronous and cannot serve as a single-flight guard.
historyRequestInFlightRef.current.add(requestKey)
setSessionHistoryLoading(prev => prev[taskId] ? prev : { ...prev, [taskId]: true })
let request: Promise<void> | void
try {
request = loadSessionDetail(taskId, {
limit: SESSION_DETAIL_PAGE_SIZE,
beforeCreatedAt: cursorMessage?.timestamp,
beforeMessageId: cursorMessage?.id,
detailLevel,
})
} catch (error) {
historyRequestInFlightRef.current.delete(requestKey)
if (historyRequestGenerationRef.current === generation) {
setSessionHistoryLoading(prev => prev[taskId] ? { ...prev, [taskId]: false } : prev)
autoHistoryRequestRef.current.active.delete(`${taskId}:${detailLevel}`)
if (autoHistoryRequestRef.current.child === taskId) autoHistoryRequestRef.current.child = null
}
return
}
return Promise.resolve(request).catch(() => {
if (historyRequestGenerationRef.current === generation) {
autoHistoryRequestRef.current.active.delete(`${taskId}:${detailLevel}`)
if (autoHistoryRequestRef.current.child === taskId) autoHistoryRequestRef.current.child = null
}
}).finally(() => {
historyRequestInFlightRef.current.delete(requestKey)
if (historyRequestGenerationRef.current !== generation) return
const taskPrefix = `${generation}|${taskId}|`
const taskStillLoading = [...historyRequestInFlightRef.current.keys()]
.some(key => key.startsWith(taskPrefix))
if (!taskStillLoading) {
setSessionHistoryLoading(prev => prev[taskId] ? { ...prev, [taskId]: false } : prev)
}
})
window.setTimeout(() => {
setSessionHistoryLoading(prev => prev[taskId] ? { ...prev, [taskId]: false } : prev)
}, 800)
}, [])
const isSessionHistoryLoading = useCallback((taskId: string) => {
@@ -392,10 +457,18 @@ export function WorkspacePage({
}, [sessionHistoryLoading])
// Auto-clear childDetailTaskId if session was deleted
useEffect(() => {
autoHistoryRequestRef.current = { active: null, child: null }
useLayoutEffect(() => {
historyRequestGenerationRef.current += 1
historyRequestInFlightRef.current.clear()
autoHistoryRequestRef.current = { scope: null, active: new Set(), child: null }
setSessionHistoryLoading({})
}, [projectId])
useEffect(() => () => {
historyRequestGenerationRef.current += 1
historyRequestInFlightRef.current.clear()
}, [])
useEffect(() => {
if (childDetailTaskId && !childDetailSession) {
setChildDetailTaskId(null)
@@ -432,31 +505,37 @@ export function WorkspacePage({
useEffect(() => {
if (!activeSessionId) {
autoHistoryRequestRef.current.active = null
autoHistoryRequestRef.current.scope = null
autoHistoryRequestRef.current.active.clear()
return
}
if (autoHistoryRequestRef.current.scope !== activeSessionId) {
autoHistoryRequestRef.current.scope = activeSessionId
autoHistoryRequestRef.current.active.clear()
}
const historyTargets = activeConversation.timelineSessions.length > 0
? activeConversation.timelineSessions
: (sessions.find(session => session.taskId === activeSessionId)
? [sessions.find(session => session.taskId === activeSessionId)!]
: [])
if (historyTargets.length === 0) {
autoHistoryRequestRef.current.active = null
autoHistoryRequestRef.current.active.clear()
return
}
const requestKey = historyTargets
.map((session) => `${session.taskId}:${sessionDetailLevel(session, { childDetail: session.mode === 'child' })}`)
.join('|')
if (autoHistoryRequestRef.current.active === requestKey) return
autoHistoryRequestRef.current.active = requestKey
for (const session of historyTargets) {
const detailLevel = isCompanyConversation(activeSession, childSessions.length)
? 'summary'
: sessionDetailLevel(session)
const requestKey = `${session.taskId}:${detailLevel}`
if (autoHistoryRequestRef.current.active.has(requestKey)) continue
autoHistoryRequestRef.current.active.add(requestKey)
requestSessionHistory(
session.taskId,
undefined,
sessionDetailLevel(session, { childDetail: session.mode === 'child' }),
detailLevel,
)
}
}, [activeConversation.timelineSessions, activeSessionId, requestSessionHistory, sessions])
}, [activeConversation.timelineSessions, activeSession, activeSessionId, childSessions.length, requestSessionHistory, sessions])
// Sync activeView when activeSessionId changes externally
const effectiveView: ActiveView = useMemo(() => {
@@ -517,9 +596,13 @@ export function WorkspacePage({
.reverse()
}
if (effectiveView.kind === 'session' && visibleChannelIds.length > 1) {
return mergeConversationMessages(
visibleChannelIds.map((visibleChannelId) => chatStore.getChannelMessages(visibleChannelId)),
const messageGroups = visibleChannelIds.map(
(visibleChannelId) => chatStore.getChannelMessages(visibleChannelId),
)
if (isCompanyConversation(activeSession, childSessions.length) && activeSession) {
return selectCompanySummaryMessages(messageGroups.flat(), activeSession.channelId)
}
return mergeConversationMessages(messageGroups)
}
return chatStore.getChannelMessages(channelId)
}, [
@@ -528,6 +611,8 @@ export function WorkspacePage({
channelId,
effectiveView.kind,
activeChannelIds,
activeSession,
childSessions.length,
visibleChannelIds,
])
const childDetailMessages = useMemo(() => {
@@ -607,11 +692,12 @@ export function WorkspacePage({
const sessionChildren = getWorkItemChildSessions(session, sessions)
const sessionPeers = getConversationPeerSessions(session, sessions)
const projection = projectSessionConversation(session, [...sessionPeers, ...sessionChildren])
result[session.taskId] = mergeConversationMessages(
projection.timelineSessions.map((timelineSession) => (
chatStore.getChannelMessages(timelineSession.channelId)
)),
)
const messageGroups = projection.timelineSessions.map((timelineSession) => (
chatStore.getChannelMessages(timelineSession.channelId)
))
result[session.taskId] = isCompanyConversation(session, sessionChildren.length)
? selectCompanySummaryMessages(messageGroups.flat(), session.channelId)
: mergeConversationMessages(messageGroups)
}
return result
}, [openSessions, sessions, chatStore.getChannelMessages])
@@ -670,24 +756,16 @@ export function WorkspacePage({
return () => document.removeEventListener('keydown', handleKeyDown)
}, [panelState])
// Auto-mark channel as read
useEffect(() => {
if (panelState === 'collapsed') return
for (const visibleChannelId of visibleChannelIds) {
chatStore.markRead(visibleChannelId)
}
}, [visibleChannelIds, chatStore, panelState])
const handleMarkRead = useCallback(() => {
for (const visibleChannelId of visibleChannelIds) {
chatStore.markRead(visibleChannelId)
markRead(visibleChannelId)
}
}, [visibleChannelIds, chatStore])
}, [visibleChannelIds, markRead])
const handleMarkSessionRead = useCallback((taskId: string) => {
const session = sessions.find(item => item.taskId === taskId)
if (session) chatStore.markRead(session.channelId)
}, [sessions, chatStore])
if (session) markRead(session.channelId)
}, [sessions, markRead])
const focusSession = useCallback((taskId: string) => {
const session = sessions.find(item => item.taskId === taskId)
@@ -698,8 +776,7 @@ export function WorkspacePage({
setPanelState('open')
setPanelTab('chat')
setChildDetailTaskId(null)
chatStore.markRead(session.channelId)
}, [sessions, ensureSessionOpen, sessionStore, chatStore])
}, [sessions, ensureSessionOpen, sessionStore])
const handleCloseSessionView = useCallback((taskId: string) => {
const remaining = openSessionIds.filter(id => id !== taskId)
@@ -711,14 +788,12 @@ export function WorkspacePage({
const nextActive = remaining[remaining.length - 1] ?? null
sessionStore.setActiveSession(nextActive)
if (nextActive) {
const nextSession = sessions.find(item => item.taskId === nextActive)
setActiveView({ kind: 'session', taskId: nextActive })
setPanelTab('chat')
if (nextSession) chatStore.markRead(nextSession.channelId)
return
}
setActiveView({ kind: 'activity' })
}, [openSessionIds, childDetailTaskId, activeSessionId, sessionStore, sessions, chatStore])
}, [openSessionIds, childDetailTaskId, activeSessionId, sessionStore])
// ── Session selection (sidebar click or board card click) ──
const handleSelectSession = useCallback((taskId: string | null) => {
@@ -746,8 +821,7 @@ export function WorkspacePage({
sessionStore.setActiveSession(null)
setChildDetailTaskId(null)
setPanelState('open')
chatStore.markRead(secretaryChannelId)
}, [sessionStore, chatStore, secretaryChannelId])
}, [sessionStore])
// ── Board interactions ──
const handleCardClick = useCallback((task: { id: string }) => {
@@ -999,9 +1073,8 @@ export function WorkspacePage({
setChildDetailTaskId(session.taskId)
setPanelState('open')
setPanelTab('chat')
chatStore.markRead(session.channelId)
}
}, [sessions, chatStore])
}, [sessions])
const handleWorkItemClick = useCallback((executionTurnId: string) => {
// Always forward to ExecutionPanel. The panel's lookup matches against
@@ -665,21 +665,53 @@
overflow: hidden;
}
.ctx-body > .msg-list {
.ctx-body > .msg-list,
.ctx-body > .msg-list-shell {
min-height: 0;
}
.ctx-work-item-progress {
flex-shrink: 0;
flex: 0 0 84px;
height: 84px;
min-height: 84px;
padding: 12px 12px 0;
overflow-y: auto;
overflow-x: hidden;
max-height: 50vh;
overflow: hidden;
position: relative;
z-index: 1;
background: var(--bg-elevated);
}
.ctx-work-item-progress .wi-progress-card {
box-sizing: border-box;
height: 72px;
padding: 10px 12px;
gap: 7px;
overflow: hidden;
}
.ctx-work-item-progress .wi-progress-pipeline {
min-width: 0;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.ctx-work-item-progress .wi-progress-pipeline::-webkit-scrollbar {
display: none;
}
.ctx-work-item-progress .wi-projection-group {
flex: 0 0 auto;
}
.ctx-work-item-progress .wi-progress-pipeline-empty {
color: var(--text-secondary);
font-size: 11px;
line-height: 22px;
white-space: nowrap;
}
.ctx-multi-grid {
flex: 1;
min-height: 0;
@@ -1863,13 +1895,21 @@
TaskDetailView linked session messages
*/
.task-detail-linked-messages {
max-height: 400px;
overflow-y: auto;
height: min(400px, 55vh);
min-height: 220px;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 0;
background: var(--bg);
}
.task-detail-linked-messages > .msg-list-shell {
flex: 1;
min-height: 0;
}
.task-detail-empty-hint {
color: var(--text-secondary);
font-size: 12px;
+19 -22
View File
@@ -16,10 +16,16 @@ import json
import re
import time
import uuid
from typing import Any, Literal, TYPE_CHECKING
from typing import Any, TYPE_CHECKING
from loguru import logger
from opc.core.models import normalize_role_runtime_status
from opc.core.transcript_visibility import (
FULL_DETAIL_ONLY_TRANSCRIPT_KINDS,
TranscriptDetailLevel,
normalize_transcript_detail_level,
transcript_metadata_visible,
)
from opc.layer2_organization.phase import (
DONE_PHASES,
IN_PROGRESS_PHASES,
@@ -118,14 +124,7 @@ def _session_message_ui_identity(message: Any) -> tuple[str, float, dict[str, An
return canonical_id or str(getattr(message, "message_id", "") or ""), timestamp, ui_meta
TranscriptDetailLevel = Literal["summary", "full"]
_FULL_DETAIL_ONLY_TRANSCRIPT_KINDS: frozenset[str] = frozenset({
"runtime_v2_user_turn",
"runtime_v2_intermediate_assistant",
"runtime_v2_company_assistant",
"runtime_v2_tool_output",
})
_FULL_DETAIL_ONLY_TRANSCRIPT_KINDS = FULL_DETAIL_ONLY_TRANSCRIPT_KINDS
_TRANSCRIPT_DUPLICATE_KIND_GROUPS: tuple[frozenset[str], ...] = (
frozenset({
@@ -208,10 +207,7 @@ def _strip_narrative_title_prefix(content: str) -> str:
def _normalize_transcript_detail_level(value: Any) -> TranscriptDetailLevel:
normalized = str(value or "").strip().lower()
if normalized == "full":
return "full"
return "summary"
return normalize_transcript_detail_level(value)
def _transcript_message_kind(message: Any) -> str:
@@ -229,13 +225,7 @@ def _transcript_message_hidden_from_ui(
detail_level: TranscriptDetailLevel = "summary",
) -> bool:
metadata = dict(getattr(message, "metadata", {}) or {})
kind = str(metadata.get("kind", "") or "").strip()
if metadata.get("company_final_turn"):
# The role's final reply of a company turn is the user-visible result
# (intake/aggregate turns have no engine-recorded result surface, so
# hiding this would drop the reply from the chat entirely).
return False
return detail_level != "full" and kind in _FULL_DETAIL_ONLY_TRANSCRIPT_KINDS
return not transcript_metadata_visible(metadata, detail_level=detail_level)
def _render_text_parts(parts: list[Any]) -> str:
@@ -1168,7 +1158,14 @@ def _prefer_duplicate_message(left: dict[str, Any], right: dict[str, Any]) -> tu
return right, left
def _collapse_adjacent_transcript_duplicates(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
def collapse_adjacent_transcript_duplicates(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Collapse duplicate rendered result surfaces in chronological order.
This is public within the Office UI package because transcript pagination
formats raw database chunks incrementally. Reusing the renderer's exact
collapse rule at chunk boundaries keeps pagination and full snapshots
identical.
"""
collapsed: list[dict[str, Any]] = []
for message in messages:
if not collapsed:
@@ -1236,7 +1233,7 @@ def build_transcript_ui_messages(
"metadata": dict(formatted.get("metadata", {}) or {}),
})
collapsed_messages = _collapse_adjacent_transcript_duplicates(formatted_messages)
collapsed_messages = collapse_adjacent_transcript_duplicates(formatted_messages)
normalized_detail_level = _normalize_transcript_detail_level(detail_level)
if normalized_detail_level == "full":
return collapsed_messages
+90 -40
View File
@@ -40,6 +40,7 @@ from opc.core.org_config import (
write_org_index,
)
from opc.core.models import normalize_role_runtime_status
from opc.core.transcript_visibility import rendered_transcript_metadata_visible
from opc.presentation.kanban import build_company_board_columns
from opc.layer2_organization.phase import (
kanban_column,
@@ -77,6 +78,7 @@ from opc.plugins.office_ui.services import (
from opc.plugins.office_ui.snapshot_builder import (
STATUS_TO_COLUMN,
_build_company_runtime_control_by_task,
collapse_adjacent_transcript_duplicates,
_build_session_context_preview,
_extract_markdown_text,
_sanitize_ui_message_dict,
@@ -947,11 +949,8 @@ class WSHandler:
@staticmethod
def _message_visible_in_detail_level(message: dict[str, Any], detail_level: str) -> bool:
normalized_detail_level = _normalize_transcript_detail_level(detail_level)
if normalized_detail_level == "full":
return True
metadata = dict(message.get("metadata", {}) or {})
return str(metadata.get("detail_visibility", "summary")).strip() != "full"
return rendered_transcript_metadata_visible(metadata, detail_level=detail_level)
@classmethod
def _filter_ui_messages_for_detail_level(
@@ -989,25 +988,86 @@ class WSHandler:
channel_id = f"session:{task_id}"
page_loader = getattr(store, "get_session_transcript_page", None)
if callable(page_loader):
before_dt = datetime.fromtimestamp(before_timestamp) if before_timestamp is not None else None
raw_page = page_loader(
session_id,
limit=limit,
before_created_at=before_dt,
before_message_id=before_message_id,
detail_level=_normalize_transcript_detail_level(detail_level),
normalized_limit = max(1, min(int(limit), 500))
normalized_detail_level = _normalize_transcript_detail_level(detail_level)
# A database-visible transcript row can still disappear when the
# renderer finds no content, and adjacent result surfaces can
# collapse to one UI row. Page raw rows in bounded chunks until
# we have one *rendered* look-ahead row or exhaust the transcript.
# This makes has_more describe the UI timeline rather than the SQL
# row set and prevents an empty raw page from stalling history.
chunk_limit = normalized_limit
raw_before_dt = (
datetime.fromtimestamp(before_timestamp)
if before_timestamp is not None
else None
)
page = await raw_page if inspect.isawaitable(raw_page) else raw_page
transcript_page = list((page or {}).get("messages", []) or [])
formatted_page = build_transcript_ui_messages(
transcript_page,
channel_id=channel_id,
task_id=task_id,
detail_level=_normalize_transcript_detail_level(detail_level),
raw_before_id = before_message_id
seen_raw_cursors: set[tuple[datetime, str]] = set()
formatted_messages: list[dict[str, Any]] = []
total_count = 0
raw_has_more = False
while True:
raw_page = page_loader(
session_id,
limit=chunk_limit,
before_created_at=raw_before_dt,
before_message_id=raw_before_id,
detail_level=normalized_detail_level,
)
page = await raw_page if inspect.isawaitable(raw_page) else raw_page
transcript_chunk = list((page or {}).get("messages", []) or [])
total_count = max(
total_count,
int((page or {}).get("total_count", 0) or 0),
)
raw_has_more = bool((page or {}).get("has_more", False))
if not transcript_chunk:
break
formatted_chunk = build_transcript_ui_messages(
transcript_chunk,
channel_id=channel_id,
task_id=task_id,
detail_level=normalized_detail_level,
)
formatted_messages = collapse_adjacent_transcript_duplicates([
*formatted_chunk,
*formatted_messages,
])
if len(formatted_messages) > normalized_limit:
return (
formatted_messages[-normalized_limit:],
max(total_count, len(formatted_messages)),
True,
)
if not raw_has_more:
break
oldest_message = transcript_chunk[0].get("message")
oldest_created_at = getattr(oldest_message, "created_at", None)
oldest_message_id = str(
getattr(oldest_message, "message_id", "") or ""
).strip()
if not isinstance(oldest_created_at, datetime) or not oldest_message_id:
break
next_cursor = (oldest_created_at, oldest_message_id)
if next_cursor in seen_raw_cursors:
break
seen_raw_cursors.add(next_cursor)
raw_before_dt, raw_before_id = next_cursor
# Small client pages should not require one SQL round-trip per
# empty row. Start at the requested size so duplicate/empty
# boundaries are exact, then grow only while looking through
# rows which did not fill the rendered page.
chunk_limit = min(500, max(chunk_limit + 1, chunk_limit * 2))
return (
formatted_messages[-normalized_limit:],
max(total_count, len(formatted_messages)),
raw_has_more,
)
total_count = int((page or {}).get("total_count", len(formatted_page)) or 0)
has_more = bool((page or {}).get("has_more", False))
return formatted_page, max(total_count, len(formatted_page)), has_more
transcript_loader = getattr(store, "get_session_transcript", None)
if not callable(transcript_loader):
@@ -5497,38 +5557,28 @@ class WSHandler:
)
try:
messages = await self.chat_store.get_channel_messages_page(
cache_page = await self.chat_store.get_channel_messages_page_info(
channel_id,
limit=request_limit,
before_timestamp=before_timestamp,
before_message_id=before_message_id,
detail_level=detail_level,
project_id=project_id,
)
messages = list(cache_page.get("messages", []) or [])
visible_cache_count = int(cache_page.get("total_count", len(messages)) or 0)
cache_has_more = bool(cache_page.get("has_more", False))
messages = self._filter_ui_messages_for_detail_level(messages, detail_level)
messages = [_sanitize_ui_message_dict(message) for message in messages]
except Exception:
messages = []
try:
if transcript_total_count:
visible_cache_count = transcript_total_count
else:
visible_cache_count = len(self._filter_ui_messages_for_detail_level(
await self.chat_store.get_channel_messages(
channel_id,
limit=max(request_limit * 8, 500),
project_id=project_id,
),
detail_level,
))
except Exception as exc:
if self._is_expected_shutdown_error(exc):
logger.debug(f"session_detail: visible count skipped during shutdown for {task_id}")
logger.debug(f"session_detail: cache page skipped during shutdown for {task_id}")
return
messages = []
visible_cache_count = len(messages)
cache_has_more = False
total_message_count = max(transcript_total_count, visible_cache_count, len(messages))
has_more = transcript_has_more or (
before_timestamp is None and total_message_count > len(messages)
)
has_more = transcript_has_more or cache_has_more
task_meta = task.metadata if isinstance(getattr(task, "metadata", None), dict) else {}
handoff_context = _extract_markdown_text(task_meta.get("handoff_context"), max_chars=None)