Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

### Fixed

- **Clearing a forked conversation no longer resurrects the original conversation's messages if the original is later compressed.** When viewing a fork's full transcript, the display could stitch in the original (pre-fork) conversation's history after that original got compressed — so a cleared fork child appeared to bring back messages it shouldn't. Fork transcripts now include only their own fork-sourced history and stop at the first non-fork ancestor. Ordinary (non-fork) conversation history display is unchanged. Thanks @rodboev. (#5582, #5571)
- **Steering a response now keeps your attachments.** If you had files staged in the composer and submitted them mid-response (steer), the attachments were silently dropped. They're now uploaded and included with the steer so the agent can read them, the file chips stay put if the steer is rejected (nothing lost), and retrying a rejected steer reuses the already-uploaded files instead of uploading duplicates. Files you stage while the steer is uploading are preserved. Thanks @ruizanthony. (#5459)
- **Mobile sidebar header: the close (✕) and new-conversation (+) buttons are now a consistent, aligned pair.** The close button rendered a larger icon sitting slightly lower than the adjacent + button; both now use the same icon size on the same baseline, with the tap target kept large enough for comfortable mobile use. Desktop is unaffected. (mobile UI polish)
- **iOS PWA: tapping a conversation now closes the sidebar immediately and reliably opens it.** On the mobile PWA, tapping an older (large) conversation left the sidebar drawer open for several seconds with only a tiny spinner — and a background refresh could cancel the switch mid-flight so the conversation never opened and the highlight jumped back. The sidebar now closes the instant you tap (matching how ChatGPT/Claude mobile dismiss the drawer on select, with a "Loading conversation…" placeholder in the chat area), and an in-flight session switch is protected from being cancelled by a concurrent background refresh. Desktop is unaffected. Thanks @luperrypf. (#5602)
Expand Down
5 changes: 5 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8052,6 +8052,8 @@ def _webui_sidecar_lineage_messages_for_display(session, *, max_hops: int = 20)
segments = []
current = session
session_messages = list(getattr(session, "messages", []) or [])
source = str(getattr(session, "session_source", "") or "").strip().lower()
root_is_fork = source == "fork"
seen = {str(getattr(session, "session_id", "") or "")}
for _ in range(max(0, int(max_hops))):
parent_id = str(getattr(current, "parent_session_id", "") or "").strip()
Expand All @@ -8060,6 +8062,9 @@ def _webui_sidecar_lineage_messages_for_display(session, *, max_hops: int = 20)
parent = Session.load(parent_id)
if not parent or not getattr(parent, "pre_compression_snapshot", False):
break
parent_source = str(getattr(parent, "session_source", "") or "").strip().lower()
if root_is_fork and parent_source != "fork":
break
if not segments and _messages_start_with_visible_prefix(
session_messages,
getattr(parent, "messages", []) or [],
Expand Down
116 changes: 116 additions & 0 deletions tests/test_session_lineage_full_transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,122 @@ def test_webui_fork_session_does_not_stitch_non_snapshot_parent(monkeypatch):
]


def test_webui_fork_session_does_not_stitch_snapshot_parent(monkeypatch):
"""A fork child must stay isolated even if its parent later becomes a snapshot."""
parent = SimpleNamespace(
session_id="parent-snapshot-fork",
parent_session_id=None,
session_source="webui",
pre_compression_snapshot=True,
truncation_watermark=None,
messages=[{"role": "user", "content": "parent should stay separate", "timestamp": 1.0}],
)
child = SimpleNamespace(
session_id="child-snapshot-fork",
parent_session_id="parent-snapshot-fork",
session_source="fork",
pre_compression_snapshot=False,
truncation_watermark=None,
messages=[{"role": "user", "content": "fork child only", "timestamp": 2.0}],
)

monkeypatch.setattr(routes.Session, "load", lambda sid: parent if sid == "parent-snapshot-fork" else None)

assert [m["content"] for m in routes._webui_sidecar_lineage_messages_for_display(child)] == [
"fork child only",
]


def test_webui_compressed_fork_stitches_fork_snapshots_only(monkeypatch):
original_parent = SimpleNamespace(
session_id="original-fork-parent",
parent_session_id=None,
session_source="webui",
pre_compression_snapshot=True,
truncation_watermark=None,
messages=[{"role": "user", "content": "original parent should stay separate", "timestamp": 0.0}],
)
first_snapshot = SimpleNamespace(
session_id="fork-compression-snapshot-1",
parent_session_id="original-fork-parent",
session_source="fork",
pre_compression_snapshot=True,
truncation_watermark=None,
messages=[{"role": "user", "content": "before first fork compression", "timestamp": 1.0}],
)
second_snapshot = SimpleNamespace(
session_id="fork-compression-snapshot-2",
parent_session_id="fork-compression-snapshot-1",
session_source="fork",
pre_compression_snapshot=True,
truncation_watermark=None,
messages=[{"role": "assistant", "content": "before second fork compression", "timestamp": 2.0}],
)
child = SimpleNamespace(
session_id="fork-compression-child",
parent_session_id="fork-compression-snapshot-2",
session_source="fork",
pre_compression_snapshot=False,
truncation_watermark=None,
messages=[{"role": "assistant", "content": "after fork compression", "timestamp": 3.0}],
)
by_id = {
"original-fork-parent": original_parent,
"fork-compression-snapshot-1": first_snapshot,
"fork-compression-snapshot-2": second_snapshot,
}

monkeypatch.setattr(routes.Session, "load", lambda sid: by_id.get(sid))

assert [m["content"] for m in routes._webui_sidecar_lineage_messages_for_display(child)] == [
"before first fork compression",
"before second fork compression",
"after fork compression",
]


def test_webui_merged_lineage_keeps_session_source_fork_isolated(monkeypatch):
parent = SimpleNamespace(
session_id="merged-parent-fork",
messages=[{"role": "user", "content": "parent only", "timestamp": 1.0}],
)
fork = SimpleNamespace(
session_id="merged-fork",
parent_session_id="merged-parent-fork",
session_source="fork",
relationship_type="",
messages=[{"role": "user", "content": "fork starts here", "timestamp": 2.0}],
truncation_watermark=None,
)

monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: parent)

merged = routes._merged_webui_lineage_messages_for_display(fork, fork.messages)

assert [m["content"] for m in merged] == ["fork starts here"]


def test_webui_merged_lineage_keeps_child_relationship_isolated(monkeypatch):
parent = SimpleNamespace(
session_id="merged-parent-child",
messages=[{"role": "user", "content": "parent only", "timestamp": 1.0}],
)
child = SimpleNamespace(
session_id="merged-child",
parent_session_id="merged-parent-child",
session_source="",
relationship_type="child_session",
messages=[{"role": "user", "content": "child starts here", "timestamp": 2.0}],
truncation_watermark=None,
)

monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: parent)

merged = routes._merged_webui_lineage_messages_for_display(child, child.messages)

assert [m["content"] for m in merged] == ["child starts here"]


def test_webui_lineage_display_keeps_child_tail_after_snapshot_watermark(monkeypatch):
"""A child sidecar watermark must not delete the child's persisted continuation tail."""
parent = SimpleNamespace(
Expand Down
Loading