fix(ui): stabilize workplace chat scrolling
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
+1
-1
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)')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user