From df586d3cc9450e2de2f56acc9dc19e084d8592db Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Fri, 2 Oct 2026 16:55:26 +0800 Subject: [PATCH 01/13] feat(codex): display native cross-session messages - Preserve native source and item identities across live delivery and history. - Show outgoing delivery receipts and scoped read-only session links. - Keep the original reading position and reject stale preview responses. - Advance protocol and derived cache versions with regression coverage. --- AGENTS.md | 2 +- cc_remote/protocol.py | 13 +- cc_remote/wrapper/codex_delegation.py | 68 ++++++++ cc_remote/wrapper/codex_external.py | 12 ++ cc_remote/wrapper/codex_history.py | 11 +- cc_remote/wrapper/codex_stream.py | 15 +- cc_remote/wrapper/history_store.py | 28 +++- cc_remote/wrapper/machine.py | 12 +- docs/codex-session-messages.md | 45 +++++ docs/installation.md | 4 +- docs/installation_en.md | 4 +- tests/test_codex_delegation.py | 165 +++++++++++++++++++ tests/test_codex_session_migration.py | 2 +- tests/test_history_store.py | 30 ++-- web/public/cc-remote-build.json | 2 +- web/scripts/check-bundle-budget.mjs | 4 +- web/src/App.tsx | 30 ++++ web/src/cache.ts | 3 +- web/src/components/ChatView.tsx | 13 ++ web/src/components/SessionMessageLinks.tsx | 32 ++++ web/src/components/SessionMessagePreview.tsx | 109 ++++++++++++ web/src/components/session-messages.css | 37 +++++ web/src/domain/conversation.ts | 3 + web/src/history-merge.ts | 2 + web/src/history-page-cache.ts | 3 +- web/src/protocol.ts | 9 +- web/src/reducer.ts | 10 ++ web/src/session-message-reader.ts | 106 ++++++++++++ web/src/session-messages.ts | 43 +++++ web/tests/fixtures/session-messages.tsx | 49 ++++++ web/tests/history-browser.fixture.tsx | 4 +- web/tests/history-browser.spec.ts | 34 ++++ web/tests/reliability.test.ts | 5 +- web/tests/session-messages.test.ts | 83 ++++++++++ 34 files changed, 953 insertions(+), 39 deletions(-) create mode 100644 cc_remote/wrapper/codex_delegation.py create mode 100644 docs/codex-session-messages.md create mode 100644 tests/test_codex_delegation.py create mode 100644 web/src/components/SessionMessageLinks.tsx create mode 100644 web/src/components/SessionMessagePreview.tsx create mode 100644 web/src/components/session-messages.css create mode 100644 web/src/session-message-reader.ts create mode 100644 web/src/session-messages.ts create mode 100644 web/tests/fixtures/session-messages.tsx create mode 100644 web/tests/session-messages.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3ae3a769..10f99261 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ attachment and optional App-control MCP tools are separate user choices. transport, never the caller's Origin. Uvicorn trusts forwarded transport metadata only from loopback Caddy. Never put tokens in URLs or protocol message bodies; logging redacts token/password fields. -- **Protocol version gate**: current wire protocol v73 is declared by +- **Protocol version gate**: current wire protocol v74 is declared by `PROTOCOL_VERSION` in both `protocol.py` and `web/src/protocol.ts`. `deserialize` hard-rejects a version mismatch, and `_Base` is `extra="forbid"`, so ANY protocol change must be deployed to all diff --git a/cc_remote/protocol.py b/cc_remote/protocol.py index 170281a3..b64b1595 100644 --- a/cc_remote/protocol.py +++ b/cc_remote/protocol.py @@ -28,7 +28,7 @@ MAX_SINGLE_ATTACHMENT_BYTES, ) -PROTOCOL_VERSION = 73 +PROTOCOL_VERSION = 74 # Codex Desktop renders a 53-week daily token-activity calendar. Keep the wire # payload to that same bounded window so an account response can never turn a @@ -889,6 +889,7 @@ class UserMsg(_Base): # the later live echo. client_msg_id: Optional[WireId] = None timed_task: Optional[TimedMessage] = None + source_thread_id: Optional[WireId] = None prompt: str images: Optional[list[QueryImage]] = Field(default=None, max_length=MAX_ATTACHMENT_COUNT) # Metadata only: file bodies stay out of replay/cache, while names remain @@ -901,6 +902,7 @@ class TurnSteered(_Base): type: Literal["turn_steered"] = "turn_steered" msg_id: WireId turn_id: WireId + source_thread_id: Optional[WireId] = None prompt: str images: Optional[list[QueryImage]] = Field( default=None, max_length=MAX_ATTACHMENT_COUNT) @@ -2474,12 +2476,21 @@ class GetHistory(_Command): detail: Literal["summary", "full"] = "full" +class SessionMessageReceipt(BaseModel): + model_config = ConfigDict(extra="forbid") + itemId: WireId + threadId: WireId + status: Literal["sending", "sent", "failed"] + + class ConversationTurn(BaseModel): """Canonical lightweight turn rendered without replaying raw events.""" model_config = ConfigDict(extra="forbid") id: WireId clientMsgId: Optional[WireId] = None timedTask: Optional[TimedMessage] = None + sourceThreadId: Optional[WireId] = None + sessionMessages: Optional[list[SessionMessageReceipt]] = Field(default=None, max_length=16) prompt: str = Field(default="", max_length=128 * 1024) blocks: list[dict[str, Any]] = Field(default_factory=list, max_length=32) done: bool = False diff --git a/cc_remote/wrapper/codex_delegation.py b/cc_remote/wrapper/codex_delegation.py new file mode 100644 index 00000000..652f822b --- /dev/null +++ b/cc_remote/wrapper/codex_delegation.py @@ -0,0 +1,68 @@ +"""Codex App cross-thread input envelopes (not sub-agent collaboration). + +Only decode the native envelope. A source id is display/navigation metadata, +never authority to change accounts, machines or execute an action. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +_NATIVE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_ENVELOPE = re.compile( + r"\s*([^<>]+)" + r"\s*([^<>]*)\s*", + re.DOTALL, +) +_TOOLS = frozenset({"create_thread", "send_message_to_thread", "handoff_thread"}) + + +@dataclass(frozen=True) +class CodexDelegation: + source_thread_id: str + prompt: str + + +def parse_codex_delegation(text: object) -> CodexDelegation | None: + if not isinstance(text, str) or len(text) > 1024 * 1024: + return None + match = _ENVELOPE.fullmatch(text.strip()) + if match is None or not _NATIVE_ID.fullmatch(match[1].strip()): + return None + # Match the official App's escaping exactly; do not parse XML/entities. + prompt = match[2].strip().replace("<", "<").replace(">", ">").replace("&", "&") + if not prompt: + return None + return CodexDelegation(match[1].strip(), prompt) + + +def is_codex_delegation_output(item: object) -> bool: + return (isinstance(item, dict) + and str(item.get("type") or "").lower() == "functioncalloutput" + and item.get("namespace") == "codex_app" + and isinstance(item.get("name"), str) + and item.get("name") in _TOOLS) + + +def normalize_codex_delegation_item(item: dict) -> dict: + """Project native turnToolOutput as the same user item used by the App. + + Other function outputs must stay tools. Keep the exact item id for live / + persisted-history reconciliation; never infer identity from equal text. + """ + if (not is_codex_delegation_output(item) + or parse_codex_delegation(item.get("output")) is None): + return item + return {"type": "userMessage", "id": item.get("id"), "clientId": None, + "content": [{"type": "text", "text": item["output"]}]} + + +def codex_message_target(tool: object, arguments: object, server: object = None) -> str | None: + if not isinstance(tool, str) or not isinstance(arguments, dict): + return None + native_tool = tool in {"send_message_to_thread", "codex_app.send_message_to_thread"} + native_server = server == "codex_app" or arguments.get("namespace") == "codex_app" + if not ((native_tool and native_server) or tool == "mcp__codex_app__send_message_to_thread"): + return None + target = arguments.get("threadId") + return target if isinstance(target, str) and _NATIVE_ID.fullmatch(target) else None diff --git a/cc_remote/wrapper/codex_external.py b/cc_remote/wrapper/codex_external.py index a9ba4a62..bb2e66ae 100644 --- a/cc_remote/wrapper/codex_external.py +++ b/cc_remote/wrapper/codex_external.py @@ -20,6 +20,10 @@ from pathlib import Path from typing import Callable, Iterable, Mapping +from cc_remote.wrapper.codex_delegation import ( + normalize_codex_delegation_item, parse_codex_delegation, +) + from cc_remote.protocol import ( MAX_SAFE_WIRE_INTEGER, MAX_SAFE_WIRE_TIMESTAMP_SECONDS, @@ -98,6 +102,7 @@ class CodexRolloutUserMessage: raw_text: str prompt: str | None + source_thread_id: str | None = None message_id: str | None = None client_id: str | None = None turn_id: str | None = None @@ -1043,6 +1048,9 @@ def visible_codex_user_message(message: object) -> str | None: text = message.strip() if not text: return None + delegation = parse_codex_delegation(text) + if delegation is not None: + return delegation.prompt marker = text.rfind(_CODEX_REQUEST_MARKER) if marker >= 0: request = text[marker + len(_CODEX_REQUEST_MARKER):].strip() @@ -1089,6 +1097,8 @@ def codex_rollout_user_message( raw_text = payload.get("message") elif payload_type == "item_completed": item = payload.get("item") + if isinstance(item, dict): + item = normalize_codex_delegation_item(item) if ( not isinstance(item, dict) or str(item.get("type") or "").lower() != "usermessage" @@ -1101,7 +1111,9 @@ def codex_rollout_user_message( return None if not isinstance(raw_text, str): return None + delegation = parse_codex_delegation(raw_text) return CodexRolloutUserMessage( + source_thread_id=delegation.source_thread_id if delegation else None, raw_text=raw_text, prompt=visible_codex_user_message(raw_text), message_id=message_id if isinstance(message_id, str) else None, diff --git a/cc_remote/wrapper/codex_history.py b/cc_remote/wrapper/codex_history.py index b644a8d5..8327b557 100644 --- a/cc_remote/wrapper/codex_history.py +++ b/cc_remote/wrapper/codex_history.py @@ -16,6 +16,10 @@ from dataclasses import dataclass, field, replace from typing import Any, Awaitable, Callable +from cc_remote.wrapper.codex_delegation import ( + normalize_codex_delegation_item, parse_codex_delegation, +) + from cc_remote.protocol import TurnEnd, TurnResult, UserMsg from cc_remote.wrapper.claude_compaction import compact_completion_events from cc_remote.wrapper.codex_history_prefetch import CodexHistoryPrefetch @@ -224,6 +228,7 @@ def _validated_turn( "invalid Codex user content") normalized = dict(value) + normalized["items"] = [normalize_codex_delegation_item(item) for item in items] normalized["startedAt"] = _optional_nonnegative_int( value.get("startedAt"), "startedAt") normalized["completedAt"] = _optional_nonnegative_int( @@ -265,11 +270,14 @@ def _user_message(item: dict[str, Any], *, ts: float | None) -> UserMsg: "invalid Codex image data") images.append({"media_type": media_type, "data": data}) + prompt = "".join(prompt_parts) + delegation = parse_codex_delegation(prompt) kwargs: dict[str, Any] = { + "source_thread_id": delegation.source_thread_id if delegation else item.get("_ccRemoteSourceThreadId"), "msg_id": _wire_id(item.get("id"), "user"), "client_msg_id": _optional_wire_id( item.get("clientId"), "client-message"), - "prompt": "".join(prompt_parts), + "prompt": delegation.prompt if delegation else prompt, "images": images or None, } if ts is not None: @@ -1017,6 +1025,7 @@ async def summary_page( "type": "text", "text": recovered.prompt, }], + "_ccRemoteSourceThreadId": recovered.source_thread_id, "_ccRemoteImages": [ dict(image) for image in recovered.images or [] ], diff --git a/cc_remote/wrapper/codex_stream.py b/cc_remote/wrapper/codex_stream.py index ce638d11..3df6b1e7 100644 --- a/cc_remote/wrapper/codex_stream.py +++ b/cc_remote/wrapper/codex_stream.py @@ -21,6 +21,10 @@ from pydantic import ValidationError +from cc_remote.wrapper.codex_delegation import ( + normalize_codex_delegation_item, parse_codex_delegation, +) + from cc_remote.attachments import ( ALLOWED_IMAGE_TYPES, MAX_IMAGE_DIMENSION, @@ -136,6 +140,7 @@ class CodexLiveUserMessage: turn_id: str prompt: str client_id: str | None = None + source_thread_id: str | None = None def codex_live_user_message(message: object) -> CodexLiveUserMessage | None: @@ -146,6 +151,8 @@ def codex_live_user_message(message: object) -> CodexLiveUserMessage | None: return None params = message.get("params") item = params.get("item") if isinstance(params, dict) else None + if isinstance(item, dict): + item = normalize_codex_delegation_item(item) if not isinstance(item, dict) or item.get("type") != "userMessage": return None message_id = item.get("id") @@ -163,7 +170,9 @@ def codex_live_user_message(message: object) -> CodexLiveUserMessage | None: client_id = item.get("clientId") if not isinstance(client_id, str) or not _SAFE_WIRE_ID.fullmatch(client_id): client_id = None + delegation = parse_codex_delegation(codex_user_item_text(item)) return CodexLiveUserMessage( + source_thread_id=delegation.source_thread_id if delegation else None, message_id=message_id, turn_id=turn_id, prompt=prompt, @@ -675,6 +684,8 @@ def _history_user_cursors( or ( b'"user_message"' not in line and b'"UserMessage"' not in line + and b'"FunctionCallOutput"' not in line + and b'"functionCallOutput"' not in line )): return None try: @@ -1631,7 +1642,7 @@ def codex_history_process_append( if (any(marker in line for marker in ( b'"task_started"', b'"user_message"', b'"session_meta"', b'"thread_goal_updated"', b'"thread_goal_cleared"', - )) or re.search(rb'"usermessage"|"role"\s*:\s*"user"', line, re.I)): + )) or re.search(rb'"usermessage"|"functioncalloutput"|"role"\s*:\s*"user"', line, re.I)): return None if _history_generated_image_record(line): process.observe(None) @@ -2029,6 +2040,7 @@ def codex_history_boundary_user( msg_id=message_id, client_msg_id=client_id, prompt=user.prompt, + source_thread_id=user.source_thread_id, ts=0, ) if pending_images: @@ -4810,6 +4822,7 @@ def close_turn( um = UserMsg( msg_id=uid, client_msg_id=user_client_id, + source_thread_id=user_record.source_thread_id, prompt=msg, ) if pending_images: diff --git a/cc_remote/wrapper/history_store.py b/cc_remote/wrapper/history_store.py index 0ede588c..669515b6 100644 --- a/cc_remote/wrapper/history_store.py +++ b/cc_remote/wrapper/history_store.py @@ -18,6 +18,8 @@ from pathlib import Path from typing import Any, Callable +from cc_remote.wrapper.codex_delegation import codex_message_target + from cc_remote.attachments import ( ALLOWED_IMAGE_TYPES, MAX_IMAGE_DIMENSION, @@ -72,7 +74,8 @@ # v45 restores public AgentMessage records in new Codex rollouts and the # native owner of source-window tails. Old tools-only projections must rebuild. # v46 restores native commands, source clocks and closed segment envelopes. -_SCHEMA_VERSION = 46 +# v47 preserves native cross-thread provenance and outgoing message receipts. +_SCHEMA_VERSION = 47 _FINGERPRINT_SAMPLE_BYTES = 64 * 1024 _DEFAULT_MAX_ENTRIES = 128 _DEFAULT_MAX_BYTES = 64 * 1024 * 1024 @@ -563,6 +566,8 @@ def materialize_history_turns( prompt = "" has_user = False client_msg_id = None + source_thread_id = None + session_messages: dict[str, dict] = {} prompt_truncated = False image_refs: list[dict[str, Any]] = [] deferred_image_count = 0 @@ -697,6 +702,7 @@ def touch_process( started_ms = _event_ms(event.get("ts")) if event_type == "user_msg": has_user = True + source_thread_id = event.get("source_thread_id") if isinstance(event.get("client_msg_id"), str): client_msg_id = event["client_msg_id"] if isinstance(event.get("prompt"), str): @@ -798,6 +804,18 @@ def touch_process( elif event_type == "error": if isinstance(event.get("message"), str): error = _historical_turn_failure(event["message"]) + if event_type == "tool_use" and len(session_messages) < 16: + target = codex_message_target(event.get("tool"), event.get("input"), event.get("server")) + item_id = event.get("tool_use_id") + if target and isinstance(item_id, str): + session_messages.setdefault(item_id, { + "itemId": item_id, "threadId": target, "status": "sending", + }) + elif event_type == "tool_result" and event.get("tool_use_id") in session_messages: + session_messages[event["tool_use_id"]]["status"] = ( + "failed" if event.get("is_error") or event.get("status") in { + "failed", "cancelled", "declined", "interrupted", + } else "sent") if include_live_detail and event_type == "tool_use": tool_id = event.get("tool_use_id") message_id = event.get("message_id") @@ -1217,6 +1235,8 @@ def touch_process( } optional = { "clientMsgId": client_msg_id, + "sourceThreadId": source_thread_id, + "sessionMessages": list(session_messages.values()) or None, "forkPointId": fork_point, "checkpointId": checkpoint_id, "imageRefs": image_refs or None, @@ -1438,6 +1458,10 @@ def _ensure_schema(self) -> None: for table in ("history_pages", "history_turn_details"): connection.execute( f"DELETE FROM {table} WHERE engine='codex'") + if 0 < current < 47: + # v47 projects native Codex cross-thread input envelopes. + for table in ("history_pages", "history_turn_details"): + connection.execute(f"DELETE FROM {table} WHERE engine='codex'") if current in (10, 11, 12, 13, 14, 15, 16): # v16 makes browser/native ownership durable; v17 reuses the # adjacent native response-item id for legacy Codex user rows. @@ -1468,7 +1492,7 @@ def _ensure_schema(self) -> None: for table in ("history_pages", "history_turn_details"): connection.execute( f"DELETE FROM {table} WHERE engine='codex'") - elif current in (21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45): + elif current in (21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46): # The independent v22-v44 invalidations above suffice. pass elif current not in (0, _SCHEMA_VERSION): diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py index f48670a7..2b6ce642 100644 --- a/cc_remote/wrapper/machine.py +++ b/cc_remote/wrapper/machine.py @@ -304,6 +304,7 @@ CodexProcessClockStoreError, ) from cc_remote.wrapper.codex_permissions import codex_permission_profiles +from cc_remote.wrapper.codex_delegation import is_codex_delegation_output from cc_remote.wrapper.codex_stream import ( CodexHistoryImageView, CodexHistoryNativeWitness, CodexHistoryProcessPageWitness, CodexLiveUserMessage, @@ -22402,6 +22403,7 @@ async def publish_live_user(raw: dict) -> bool: msg_id=user.message_id, client_msg_id=user.client_id, prompt=user.prompt, + source_thread_id=user.source_thread_id, )) await self._emit(ctx, TurnBinding( msg_id=user.message_id, @@ -22433,6 +22435,7 @@ async def publish_live_user(raw: dict) -> bool: msg_id=user.message_id, client_msg_id=user.client_id, prompt=user.prompt, + source_thread_id=user.source_thread_id, )) await self._emit(ctx, TurnBinding( msg_id=user.client_id, @@ -22454,6 +22457,7 @@ async def publish_live_user(raw: dict) -> bool: msg_id=user.message_id, turn_id=current_turn_id, prompt=user.prompt, + source_thread_id=user.source_thread_id, )) return True @@ -22462,7 +22466,8 @@ def raw_is_user_item(raw: dict) -> bool: return False params = raw.get("params") item = params.get("item") if isinstance(params, dict) else None - return isinstance(item, dict) and item.get("type") == "userMessage" + return (isinstance(item, dict) and item.get("type") == "userMessage" + or is_codex_delegation_output(item)) def raw_proves_automatic_output(raw: dict) -> bool: method = raw.get("method") @@ -37925,7 +37930,8 @@ async def publish_codex_user(raw: dict) -> None: initial = bool(codex_initial_msg_id and codex_initial_msg_id in { user.message_id, user.client_id, }) - if not initial and not user.client_id and not codex_initial_user_seen: + if (not initial and not user.client_id and not user.source_thread_id + and not codex_initial_user_seen): # A missed initial echo cannot turn an unlabelled first item # into a second input. Never guess from equal prompt text. return @@ -37951,6 +37957,7 @@ async def publish_codex_user(raw: dict) -> None: msg_id=msg_id, turn_id=user.turn_id, prompt=user.prompt, + source_thread_id=user.source_thread_id, )) if user.client_id is not None: # Reconcile the canonical history id only after the boundary; @@ -37959,6 +37966,7 @@ async def publish_codex_user(raw: dict) -> None: msg_id=user.message_id, client_msg_id=user.client_id, prompt=user.prompt, + source_thread_id=user.source_thread_id, )) async def emit_codex_event(event) -> None: diff --git a/docs/codex-session-messages.md b/docs/codex-session-messages.md new file mode 100644 index 00000000..e9e810e3 --- /dev/null +++ b/docs/codex-session-messages.md @@ -0,0 +1,45 @@ +# Codex cross-session messages + +cc-remote displays the native Codex App cross-thread envelope. Sending continues +through the optional official `codex_app.send_message_to_thread` tool; this +feature does not install/enable the adapter, grant tool permissions, invent a +second message transport, or submit prompts itself. See +[codex-app-tools.md](codex-app-tools.md) for the shared account/daemon prerequisite. + +Incoming messages show **来自会话 · name**. The Wrapper recognizes both the older +`userMessage` envelope and the newer `functionCallOutput` from the `codex_app` +namespace (`create_thread`, `send_message_to_thread`, `handoff_thread`). It keeps +the native item id and source thread id across live delivery, steering, official +history, rollout compatibility reads and browser caches. Unknown/malformed +markup remains ordinary text. Provenance is native display metadata, not an +independent authentication claim. + +Native send calls show a compact outgoing receipt with the actual tool outcome. +A failed call does not become “sent”. The small receipt survives summary +materialization when the source includes that tool; for an older summary that +omits tool items, expand the process to read them. Tool bodies stay deferred. + +A source/recipient link resolves against the current device's Codex Code catalog +and the current account routing prefix. Missing, deleted, cross-account and +Work targets remain labelled but disabled. The link opens a read-only history +view in the same window. It only uses `GetHistory` / `GetTurnDetail`; it does not +change Wrapper focus, resume an engine, send a message or navigate Codex App. +The original conversation stays mounted with output-follow paused. Closing the +view (Back or Escape) restores focus without scrolling the original chat. +The preview reads bounded pages, rejects mismatched cursors/revisions, and offers +retry after timeout or history invalidation. + +The native envelope supplies a source thread id, not the sending call/turn id. +The link therefore opens that session, without guessing a corresponding record +from matching text. Codex App keeps its own native rendering and source links. +End-to-end sending still requires a running eligible App and its tool adapter. + +This change uses protocol **v74**. Deploy Web, Relay and Wrapper together. +Derived Codex history pages and browser projections rebuild on upgrade; native +transcripts, credentials and original tool outputs are unchanged. + +Validation uses native-shape fixtures without model calls: incoming format and +escaping, exact ids, live/history replay races, same-text separate messages, +steering, sender failure receipts, account/device routing, history pagination, +and desktop/mobile browser return-position checks. Live App/MCP delivery is a +separate acceptance check; fixture success alone does not prove it. diff --git a/docs/installation.md b/docs/installation.md index 4032ddb2..4914cf76 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -236,7 +236,7 @@ npm --prefix web run build # 产出 web/dist/ 网页构建不需要任何登录密钥。 **所有目标先 staging,再改动线上服务。** 下文分别描述 Relay 和 Wrapper, -不能在 Wrapper staging 未验证时先激活 Relay。协议 v73 不允许混用旧客户端: +不能在 Wrapper staging 未验证时先激活 Relay。协议 v74 不允许混用旧客户端: 停止不兼容的旧 Wrapper,激活 Relay + Web,再激活 Wrapper 并硬刷新网页。 Wrapper 激活须通过 `deploy/work_registry_snapshot.py` 保存 Work SQLite 与私有账号 控制状态,不再按“是否来自某个旧协议”决定是否保护。回滚先恢复匹配状态,再启动 @@ -294,7 +294,7 @@ sudo bash ~/cc-remote-upload/deploy/setup-vps.sh \ 脚本会:装 `python3-venv` + Caddy、建 `ccremote` 系统用户、创建不可变 release 和 release-local venv、合并 Caddy 配置、原子切换 `current`,再重启 relay。若新 relay 重启或健康检查失败,`current`、Caddyfile、systemd unit 会作为一个事务全部 -恢复,并验证旧 release 的 `/healthz`。成功后再启动 v73 wrapper。 +恢复,并验证旧 release 的 `/healthz`。成功后再启动 v74 wrapper。 验证: diff --git a/docs/installation_en.md b/docs/installation_en.md index 97f5be79..7b4970a1 100644 --- a/docs/installation_en.md +++ b/docs/installation_en.md @@ -276,7 +276,7 @@ as described in the deployment contract. No browser secret is needed for a build **Stage every target before changing live services.** The commands below describe the Relay and Wrapper separately; do not activate Relay until every Wrapper stage -has passed validation. Protocol v73 cannot be mixed with older clients. Stop old +has passed validation. Protocol v74 cannot be mixed with older clients. Stop old incompatible Wrappers, activate Relay + Web, then activate Wrappers and hard-refresh browser tabs. Wrapper activation must snapshot Work SQLite and private profile control state with `deploy/work_registry_snapshot.py`; this is not limited to @@ -338,7 +338,7 @@ The script installs `python3-venv` + Caddy, creates the `ccremote` service user, builds an immutable release and its venv, merges Caddy configuration, atomically switches `current`, and restarts the relay. If restart/readiness fails, `current`, the Caddyfile, and the systemd unit roll back as one transaction and the previous -release's `/healthz` is verified. Start the v73 wrapper after success. +release's `/healthz` is verified. Start the v74 wrapper after success. Verify: diff --git a/tests/test_codex_delegation.py b/tests/test_codex_delegation.py new file mode 100644 index 00000000..08c3b563 --- /dev/null +++ b/tests/test_codex_delegation.py @@ -0,0 +1,165 @@ +"""Native Codex App cross-thread inputs: live, official history and rollout.""" +import asyncio +import json +from types import SimpleNamespace + +import pytest + +from cc_remote.protocol import UserMsg, TurnBinding, ConversationTurn, serialize, deserialize +from cc_remote.wrapper.codex_delegation import parse_codex_delegation, codex_message_target +from cc_remote.wrapper.codex_external import visible_codex_user_message +from cc_remote.wrapper.codex_handle import CodexHandle +from cc_remote.wrapper.codex_history import CodexOfficialHistory +from cc_remote.wrapper.codex_stream import ( + codex_live_user_message, codex_translate_history, codex_history_window_info, +) +from cc_remote.wrapper.history_store import materialize_history_turns +from tests.test_codex_history import _agent, _turn +from tests.test_codex_spontaneous_stream import _notification +from tests.test_multisession import _mk_ctx, _mk_machine + +SOURCE = "01a01234-1234-7890-abcd-123456789abc" +ENVELOPE = (f"\n{SOURCE}\n" + "Check <code> & &lt;literal&gt;\n") +PROMPT = "Check & <literal>" + + +def _input(kind, item_id="incoming"): + if kind == "legacy": + return {"type": "userMessage", "id": item_id, + "content": [{"type": "text", "text": ENVELOPE}]} + return {"type": "functionCallOutput", "id": item_id, "namespace": "codex_app", + "name": "send_message_to_thread", "output": ENVELOPE} + + +@pytest.mark.parametrize("kind", ["legacy", "native"]) +def test_same_native_identity_and_provenance_in_live_history_and_rollout(tmp_path, kind): + item = _input(kind) + user = codex_live_user_message(_notification("item/completed", "task", item=item)) + assert (user.message_id, user.prompt, user.source_thread_id) == ("incoming", PROMPT, SOURCE) + + async def run(): + async def rpc(method, params, *_): + assert method == "thread/turns/list" + return {"data": [_turn("task", [item, _agent("answer", "Checked.")])], "nextCursor": None} + page = await CodexOfficialHistory(65536, rpc=rpc).summary_page( + "receiver", before=None, limit=4) + assert len(page.turns) == 1 + assert page.turns[0]["id"] == user.message_id + assert page.turns[0]["sourceThreadId"] == SOURCE + assert page.turns[0]["prompt"] == PROMPT + ConversationTurn.model_validate(page.turns[0]) + asyncio.run(run()) + + rollout_item = {**item, "type": "UserMessage" if kind == "legacy" else "FunctionCallOutput"} + rows = [ + {"type": "event_msg", "payload": {"type": "task_started", "turn_id": "task"}}, + {"type": "event_msg", "payload": {"type": "item_completed", "turn_id": "task", "item": rollout_item}}, + {"type": "event_msg", "payload": {"type": "agent_message", "message": "Checked."}}, + {"type": "event_msg", "payload": {"type": "task_complete", "turn_id": "task"}}, + ] + path = tmp_path / "rollout.jsonl" + path.write_text("".join(json.dumps(row) + "\n" for row in rows)) + events, _ = codex_translate_history(str(path), 65536) + users = [e for e in events if isinstance(e, UserMsg)] + assert [(u.msg_id, u.prompt, u.source_thread_id) for u in users] == [("incoming", PROMPT, SOURCE)] + turn = materialize_history_turns([e.model_dump(mode="json") for e in events])[0] + assert turn["sourceThreadId"] == SOURCE + assert deserialize(serialize(users[0])).source_thread_id == SOURCE + window = codex_history_window_info(str(path), before=None, limit=1) + assert window is not None + + +@pytest.mark.parametrize("kind", ["legacy", "native", "native-partial"]) +def test_spontaneous_delivery_publishes_one_native_user_with_no_placeholder(kind): + async def run(): + machine, transport = _mk_machine() + ctx = _mk_ctx("thread-spontaneous", "thread-spontaneous") + ctx.engine = "codex" + handle = CodexHandle(machine.cfg) + handle.thread_id = ctx.session_id + handle.proc = SimpleNamespace(returncode=None) + ctx.sdk = handle + machine.sessions[ctx.key] = ctx + handle.turn_lifecycle_callback = lambda phase, turn_id: machine._on_codex_turn_lifecycle(ctx, phase, turn_id) + item = _input(kind) + for message in [ + _notification("turn/started", "task", turn={"id": "task"}), + _notification("item/started", "task", item={**item, "output": ""} if kind == "native-partial" else item), + _notification("item/completed", "task", item=item), + _notification("item/completed", "task", item=_agent("answer", "Checked.")), + _notification("turn/completed", "task", turn={"id": "task", "status": "completed"}), + ]: + await handle._dispatch(message) + await asyncio.wait_for(ctx.codex_spontaneous_task, 1) + users = [e for e in transport.sent if isinstance(e, UserMsg)] + assert [(u.msg_id, u.prompt, u.source_thread_id) for u in users] == [("incoming", PROMPT, SOURCE)] + assert [(e.msg_id, e.turn_id) for e in transport.sent if isinstance(e, TurnBinding)] == [("incoming", "task")] + asyncio.run(run()) + + +def test_unknown_markup_and_foreign_tool_outputs_remain_unattributed(): + for text in [ENVELOPE + "extra", ENVELOPE.replace(SOURCE, "other@thread"), + ENVELOPE.replace("<code>", ""), "literal"]: + assert parse_codex_delegation(text) is None + assert visible_codex_user_message(text) == text + for override in [{"namespace": "foreign"}, {"name": "read_thread"}, {"name": []}, {"id": "../bad"}]: + assert codex_live_user_message(_notification("item/completed", "task", item={**_input("native"), **override})) is None + assert parse_codex_delegation( + f"{SOURCE}" + + " " * (512 * 1024)) is None + + +def test_sender_receipt_requires_native_tool_and_retains_failure(): + assert codex_message_target("send_message_to_thread", {"threadId": SOURCE}, "foreign") is None + events = [ + {"type": "user_msg", "msg_id": "user", "prompt": "review"}, + {"type": "tool_use", "message_id": "assistant", "tool_use_id": "send", "tool": "send_message_to_thread", + "server": "codex_app", "input": {"threadId": SOURCE, "input": "private tool body"}}, + {"type": "tool_result", "tool_use_id": "send", "content": "denied", "is_error": True}, + ] + turn = materialize_history_turns(events)[0] + assert turn["sessionMessages"] == [{"itemId": "send", "threadId": SOURCE, "status": "failed"}] + assert "private tool body" not in json.dumps(turn) + + +@pytest.mark.parametrize("kind", ["mcpToolCall", "dynamicToolCall"]) +@pytest.mark.parametrize("success", [True, False]) +def test_official_sender_tool_outcome_becomes_summary_receipt(kind, success): + tool = { + "id": "send-call", "type": kind, "tool": "send_message_to_thread", + "arguments": {"threadId": SOURCE, "input": "Check the code"}, + "status": "completed" if success else "failed", + } + if kind == "mcpToolCall": + tool.update(server="codex_app", result={"content": []} if success else None, + error=None if success else {"message": "Target unavailable"}) + else: + tool.update(namespace="codex_app", success=success, contentItems=[]) + + async def run(): + async def rpc(method, params, *_): + return {"data": [_turn("task", [ + _input("legacy"), tool, _agent("answer", "Finished."), + ])], "nextCursor": None} + page = await CodexOfficialHistory(65536, rpc=rpc).summary_page( + "receiver", before=None, limit=4) + assert page.turns[0]["sessionMessages"] == [{ + "itemId": "send-call", "threadId": SOURCE, + "status": "sent" if success else "failed", + }] + asyncio.run(run()) + + +def test_native_steers_keep_separate_item_identities_and_sources(): + async def run(): + async def rpc(method, params, *_): + return {"data": [_turn("task", [ + _input("legacy", "first"), _agent("comment", "Working.", phase="commentary"), + _input("native", "second"), _agent("answer", "Done."), + ])], "nextCursor": None} + page = await CodexOfficialHistory(65536, rpc=rpc).summary_page("receiver", before=None, limit=4) + assert [turn["id"] for turn in page.turns] == ["first", "second"] + assert [turn["prompt"] for turn in page.turns] == [PROMPT, PROMPT] + assert [turn["sourceThreadId"] for turn in page.turns] == [SOURCE, SOURCE] + asyncio.run(run()) diff --git a/tests/test_codex_session_migration.py b/tests/test_codex_session_migration.py index 8fe93173..36a38da5 100644 --- a/tests/test_codex_session_migration.py +++ b/tests/test_codex_session_migration.py @@ -91,7 +91,7 @@ async def list_sessions(_cmd): def test_session_migration_protocol_roundtrips_as_control_frames(): - assert PROTOCOL_VERSION == 73 + assert PROTOCOL_VERSION == 74 command = deserialize(serialize(_command("/tmp/new-cwd"))) assert command.type == "migrate_session" assert command.session_id == "thread-1" diff --git a/tests/test_history_store.py b/tests/test_history_store.py index 44190822..25ef83c5 100644 --- a/tests/test_history_store.py +++ b/tests/test_history_store.py @@ -42,7 +42,7 @@ def test_public_codex_item_migration_invalidates_only_codex_projections(tmp_path connection.execute(f"PRAGMA user_version={old_version}") migrated = HistoryIndexStore(tmp_path / "state") with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 for table in ("history_pages", "history_turn_details"): assert sorted(connection.execute(f"SELECT engine FROM {table}").fetchall()) == [ ("claude",), ("dsh",)] @@ -485,7 +485,7 @@ def test_paged_file_migration_rebuilds_summaries_once_without_removing_assets(tm connection.execute(f"PRAGMA user_version={old_version}") migrated = HistoryIndexStore(tmp_path / "state") with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='codex'" @@ -514,7 +514,7 @@ def test_compact_migration_rebuilds_claude_details_and_preserves_assets(tmp_path connection.execute(f"PRAGMA user_version={old_version}") migrated = HistoryIndexStore(tmp_path / "state") with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 assert connection.execute( "SELECT engine FROM history_pages").fetchall() == [] assert connection.execute( @@ -589,7 +589,7 @@ def test_v19_migration_rebuilds_history_and_adds_agent_details(tmp_path): assert migrated.get_page( "session-1", "claude", source, before=None, limit=4) is None with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 assert connection.execute( "SELECT COUNT(*) FROM history_agent_details").fetchone()[0] == 0 @@ -617,7 +617,7 @@ def test_v20_migration_rebuilds_codex_and_claude_identity_projections(tmp_path): migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -678,7 +678,7 @@ def test_v21_migration_rebuilds_claude_alias_and_codex_process_projections( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -735,7 +735,7 @@ def test_v22_migration_applies_codex_and_claude_projection_repairs( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -797,7 +797,7 @@ def test_recent_migration_applies_codex_and_claude_projection_repairs( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -868,7 +868,7 @@ def test_async_question_migration_preserves_assets( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -977,7 +977,7 @@ def test_legacy_migration_rebuilds_all_derived_history_rows( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 for table in ( "history_pages", "history_turn_details", @@ -1024,7 +1024,7 @@ def test_v10_migration_invalidates_changed_projection_rows(tmp_path): migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 for table in ( "history_pages", "history_turn_details", "history_image_assets", ): @@ -1075,7 +1075,7 @@ def test_v11_migration_invalidates_claude_pages_and_adds_compact_index( "claude-session", "claude", source, before=None, limit=4, ) is None with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 tables = { row[0] for row in connection.execute( "SELECT name FROM sqlite_master WHERE type='table'" @@ -1121,7 +1121,7 @@ def test_recent_migration_invalidates_changed_projection_rows( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 for table in ( "history_pages", "history_turn_details", "history_image_assets", ): @@ -1163,7 +1163,7 @@ def test_owner_and_interrupt_alias_migration_invalidates_both_projections( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -1227,7 +1227,7 @@ def test_recent_summary_migration_rebuilds_pages_but_preserves_source_assets( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 46 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 47 assert connection.execute( "SELECT COUNT(*) FROM history_pages" ).fetchone()[0] == 0 diff --git a/web/public/cc-remote-build.json b/web/public/cc-remote-build.json index 5ad04af6..bed7db75 100644 --- a/web/public/cc-remote-build.json +++ b/web/public/cc-remote-build.json @@ -1,4 +1,4 @@ { "version": "4.0.7", - "protocol": 73 + "protocol": 74 } diff --git a/web/scripts/check-bundle-budget.mjs b/web/scripts/check-bundle-budget.mjs index fbdf438e..a8732d51 100644 --- a/web/scripts/check-bundle-budget.mjs +++ b/web/scripts/check-bundle-budget.mjs @@ -33,7 +33,9 @@ const DIST = resolve(import.meta.dirname, "../dist"); // startup JS. Keep entry, compressed-size and request-count limits unchanged. const MAX_ENTRY_BYTES = 537 * 1024; const MAX_INITIAL_BYTES = 938 * 1024; -const MAX_INITIAL_GZIP_BYTES = 280 * 1024; +// Cross-thread provenance, scoped navigation and receipts add <2 KiB gzip. +// The history reader and link UI load on demand; other caps stay unchanged. +const MAX_INITIAL_GZIP_BYTES = 282 * 1024; const MAX_INITIAL_JS_FILES = 4; const html = readFileSync(resolve(DIST, "index.html"), "utf8"); diff --git a/web/src/App.tsx b/web/src/App.tsx index 7df2055c..bda2a2eb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,3 +1,5 @@ +import type { ServerEvent } from "./protocol"; +import { resolveRelatedSession, relatedSessionTitle } from "./session-messages"; import { lazy, Suspense, @@ -32,6 +34,8 @@ import { TurnFilePageRequests, type LoadTurnFilePage } from "./turn-file-pages"; import { Icon } from "./icons"; import { ChatView } from "./components/ChatView"; import { Composer } from "./components/Composer"; +const SessionMessagePreview = lazy(() => import("./components/SessionMessagePreview").then((m) => ({ default: m.SessionMessagePreview }))); + const BackgroundTaskControl = lazy(() => import("./components/BackgroundTaskControl")); import type { QueuedQueryEditor } from "./components/QueuedQueryDialog"; import { ReconnectBanner } from "./components/ReconnectBanner"; @@ -477,6 +481,10 @@ export default function App() { const stateRef = useRef(state); stateRef.current = state; const wsRef = useRef(null); + const [sessionMessagePreview, setSessionMessagePreview] = useState<{ + scope: string; originSid: string; session: SessionInfo; returnLabel: string; + } | null>(null); + const sessionMessagePreviewEvents = useRef<((event: ServerEvent) => boolean) | null>(null); const goalApiRef = useRef(null); const getGoalApi = useCallback(async () => { const transport = wsRef.current; @@ -2208,6 +2216,7 @@ export default function App() { const ws = new RelayWs({ onEvent: (msg, ownership) => { if (!acceptsLifecycle()) return; + if (sessionMessagePreviewEvents.current?.(msg)) return; if (turnFileRequestsRef.current.accept(msg)) return; if (goalApiRef.current?.accept(msg)) return; const settlesContextRequest = !!( @@ -5784,8 +5793,29 @@ export default function App() { onSend={sendFirstMessage} /> ) : ( <> + {sessionMessagePreview?.scope === activeScopeKey + && sessionMessagePreview.originSid === focusedSid + && state.sessions.some((s) => s.session_id === sessionMessagePreview.session.session_id) + && setSessionMessagePreview(null)} + api={{ receive: sessionMessagePreviewEvents, + history: (sid, before, cwd) => wsRef.current?.sendGetHistory(sid, before, 12, cwd) ?? false, + detail: (sid, turnId, revision, before) => wsRef.current?.sendGetTurnDetail(sid, turnId, revision, before) ?? false, + }} />} { + const session = resolveRelatedSession(nativeId, focusedSid, state.sessions); + return { title: relatedSessionTitle(session, nativeId), available: !!session }; + }} + onOpenSession={focusedEngine === "codex" && space === "code" ? (nativeId) => { + const session = resolveRelatedSession(nativeId, focusedSid, state.sessions); + if (!session || !focusedSid) return; + setSessionMessagePreview({ scope: activeScopeKey, originSid: focusedSid, + session, returnLabel: relatedSessionTitle(focusedSession, focusedSid) }); + } : undefined} loading={!!rt.loading || historyView.recovering} surface={space} engine={focusedEngine} forkingPointId={forkingPointId} diff --git a/web/src/cache.ts b/web/src/cache.ts index 756172a8..bc0e9ade 100644 --- a/web/src/cache.ts +++ b/web/src/cache.ts @@ -68,7 +68,8 @@ const SCHEMA = 1; // v25 reprojects native async questions instead of preserving plain-answer shells. // v26 discards summaries where those questions hid ordinary unphased replies. // v27 removes Claude turns split by native isMeta recovery prompts. -const CACHE_VER = 27; +// v28 discards projections missing native cross-thread message provenance. +const CACHE_VER = 28; const MAX_CACHE_SESSIONS = 64; const MAX_CACHE_TURNS = 100; const MAX_CACHE_BYTES = 2 * 1024 * 1024; diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 0d132ea3..a5894341 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -88,6 +88,7 @@ import { mergeDetailWithLiveTail } from "../history-merge"; import { presentAsyncQuestionReplies } from "../async-question-presentation"; import type { QueryAcceptanceResult } from "../outbox"; +const SessionMessageLinks = lazy(() => import("./SessionMessageLinks")); const TurnUsageIndicator = lazy(() => import("./TurnUsageIndicator").then(m => ({ default: m.TurnUsageIndicator }))); const AsyncQuestionCard = lazy(() => import("./AsyncQuestionCard")); const AsyncQuestionHost = lazy(() => import("./AsyncQuestionDialog")); @@ -326,9 +327,12 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud onTextSelectionGuardChange, externalPlanProgress, onOpenAgent, + sessionLink, onOpenSession, activeTurnId = null, ambiguousActiveTurnIds = [], surface = "code" }: { + sessionLink?: (nativeId: string) => { title: string; available: boolean }; + onOpenSession?: (nativeId: string) => void; turnUsage?: TurnUsageReadings; sid: string | null; turns: Turn[]; @@ -2928,6 +2932,10 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud {(t.prompt || (t.images && t.images.length) || (t.imageRefs && t.imageRefs.length) || (t.files && t.files.length)) && (
{t.prompt &&
+ {engine === "codex" && t.sourceThreadId && + { pauseOutputFollow(); onOpenSession(id); } : undefined} /> + } {t.timedTask && } {supplemental.replies.has(t.id) ?
@@ -3002,6 +3010,11 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud
)} {showProcessTimeline && renderProcess()} + {engine === "codex" && (!!t.sessionMessages?.length || t.blocks.some((b) => + b.kind === "tool" && b.tool.endsWith("send_message_to_thread"))) && + { pauseOutputFollow(); onOpenSession(id); } : undefined} /> + } {modelNotices.map((notice) =>
{notice.summary} diff --git a/web/src/components/SessionMessageLinks.tsx b/web/src/components/SessionMessageLinks.tsx new file mode 100644 index 00000000..650caebf --- /dev/null +++ b/web/src/components/SessionMessageLinks.tsx @@ -0,0 +1,32 @@ +import type { Turn } from "../domain/conversation"; +import { outgoingSessionMessages } from "../session-messages"; +import { Icon } from "../icons"; +import "./session-messages.css"; + +export default function SessionMessageLinks({ turn, source, resolve, onOpen }: { + turn: Turn; + source?: boolean; + resolve?: (nativeId: string) => { title: string; available: boolean }; + onOpen?: (nativeId: string) => void; +}) { + const link = (id: string, label: string, key: string) => { + const target = resolve?.(id); + const enabled = !!onOpen && !!target?.available; + return ; + }; + if (source) return turn.sourceThreadId + ? link(turn.sourceThreadId, "来自会话 ·", "source") : null; + return outgoingSessionMessages(turn).map((receipt) => link( + receipt.threadId, + receipt.status === "sent" ? "已发送给" : receipt.status === "failed" + ? "未能发送给" : turn.done ? "发送状态未确认 ·" : "正在发送给", + receipt.itemId, + )); +} diff --git a/web/src/components/SessionMessagePreview.tsx b/web/src/components/SessionMessagePreview.tsx new file mode 100644 index 00000000..333671af --- /dev/null +++ b/web/src/components/SessionMessagePreview.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useReducer, useRef } from "react"; +import type { ServerEvent, SessionInfo } from "../protocol"; +import { SessionMessageReader } from "../session-message-reader"; +import { relatedSessionTitle } from "../session-messages"; +import { ChatView } from "./ChatView"; +import { Icon } from "../icons"; +import "./session-messages.css"; + +export interface SessionMessagePreviewApi { + history: (sid: string, before: string | null, cwd?: string | null) => boolean; + detail: (sid: string, turnId: string, revision: string, before: string | null) => boolean; + receive: { current: ((event: ServerEvent) => boolean) | null }; +} + +export function SessionMessagePreview({ session, returnSid, returnLabel, api, onClose }: { + session: SessionInfo; + returnLabel: string; + returnSid: string; + api: SessionMessagePreviewApi; + onClose: () => void; +}) { + const dialog = useRef(null); + const readerRef = useRef(null); + if (!readerRef.current) readerRef.current = new SessionMessageReader(session.session_id); + const reader = readerRef.current; + const [, repaint] = useReducer((n: number) => n + 1, 0); + const timers = useRef(new Set>()); + const apiRef = useRef(api); + apiRef.current = api; + + const armTimeout = useCallback(() => { + const timer = setTimeout(() => { + timers.current.delete(timer); + reader.fail("会话读取超时,请重试。"); + repaint(); + }, 15_000); + timers.current.add(timer); + }, [reader]); + const clearTimers = useCallback(() => { + for (const timer of timers.current) clearTimeout(timer); + timers.current.clear(); + }, []); + const load = useCallback((page: number) => { + const request = reader.requestPage(page); + if (!request) return; + clearTimers(); + if (!apiRef.current.history(reader.sid, request.before, session.cwd)) reader.fail(); + else armTimeout(); + repaint(); + }, [reader, session.cwd, clearTimers, armTimeout]); + useEffect(() => { + const node = dialog.current; + const previousFocus = document.activeElement instanceof HTMLElement + ? document.activeElement : null; + node?.showModal(); + const receive = (event: ServerEvent) => { + if (!reader.accept(event)) return false; + clearTimers(); + repaint(); + return event.type !== "history_invalidated"; + }; + const receiveRef = apiRef.current.receive; + receiveRef.current = receive; + load(0); + return () => { + clearTimers(); + // Also cancel the private pending state: StrictMode may immediately + // set this effect up again with the same reader instance. + reader.fail(); + if (receiveRef.current === receive) receiveRef.current = null; + node?.close(); + previousFocus?.focus({ preventScroll: true }); + }; + }, [reader, load, clearTimers]); + + return event.stopPropagation()} onCancel={(event) => { event.preventDefault(); event.stopPropagation(); onClose(); }}> +
+ + {relatedSessionTitle(session, session.session_id)} +
+ {reader.error &&
+ {reader.error} +
} + id === returnSid.slice(returnSid.indexOf("@") + 1) + ? { title: returnLabel, available: true } + : { title: id.slice(0, 8), available: false }} + onOpenSession={onClose} + historyRevision={reader.revision} historyGeneration={reader.generation} + onLoadDetail={(turnId, before) => { + if (!reader.requestDetail(turnId, before ?? null)) return false; + if (!apiRef.current.detail(reader.sid, turnId, reader.revision!, before ?? null)) reader.fail(); + else armTimeout(); + repaint(); + return true; + }} /> +
+ + 只读查看 + +
+
; +} diff --git a/web/src/components/session-messages.css b/web/src/components/session-messages.css new file mode 100644 index 00000000..53ababc4 --- /dev/null +++ b/web/src/components/session-messages.css @@ -0,0 +1,37 @@ +.session-message-source, .session-message-sent { + display: flex; align-items: center; gap: 7px; + border: 0; background: transparent; color: var(--accent); + font: inherit; font-size: 12px; text-align: left; + padding: 0; cursor: pointer; max-width: 100%; +} +.session-message-source { margin-bottom: 8px; } +.session-message-source > span:first-of-type, .session-message-sent > span:first-of-type { + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; +} +.session-message-source:disabled, .session-message-sent:disabled { cursor: default; opacity: .72; } +.session-message-sent { margin: 10px 0; } +.session-message-source:not(:disabled):hover, .session-message-sent:not(:disabled):hover { filter: brightness(1.15); } +.session-message-preview { + width: min(1100px, 96vw); height: min(900px, 94dvh); max-width: 100vw; max-height: 100dvh; + padding: 0; border: 1px solid var(--border); border-radius: 16px; + background: var(--bg); color: var(--text); +} +.session-message-preview[open] { display: flex; flex-direction: column; } +.session-message-preview::backdrop { background: rgb(0 0 0 / .55); } +.session-message-preview-header { display: flex; align-items: center; gap: 20px; padding: 16px 20px; border-bottom: 1px solid var(--border); } +.session-message-preview-header strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.session-message-back { display: flex; align-items: center; gap: 5px; max-width: 50%; flex-shrink: 0; } +.session-message-back span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.session-message-preview-header button, .session-message-preview-pages button, .session-message-error button { + color: inherit; background: transparent; border: 0; cursor: pointer; font: inherit; +} +.session-message-preview button:disabled { opacity: .45; cursor: default; } +.session-message-preview .thread-shell { flex: 1; min-height: 0; } +.session-message-preview-pages { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; border-top: 1px solid var(--border); font-size: 13px; } +.session-message-preview-pages span { opacity: .55; } +.session-message-error { padding: 12px 20px; display: flex; gap: 16px; } +@media (max-width: 700px) { + .session-message-preview { width: 100vw; height: 100dvh; border: 0; border-radius: 0; } + .session-message-preview-header { padding-top: max(16px, env(safe-area-inset-top)); } + .session-message-preview-pages { padding-bottom: max(12px, env(safe-area-inset-bottom)); } +} diff --git a/web/src/domain/conversation.ts b/web/src/domain/conversation.ts index 52085966..5ac155c8 100644 --- a/web/src/domain/conversation.ts +++ b/web/src/domain/conversation.ts @@ -11,6 +11,7 @@ import type { ToolCategory, TurnChangeSummary, TimedMessage, + SessionMessageReceipt, } from "../protocol"; /** Browser-only fallback used when an authoritative idle History snapshot @@ -130,6 +131,8 @@ export interface TurnDetailProjection { export interface Turn { id: string; timedTask?: TimedMessage | null; + sourceThreadId?: string | null; + sessionMessages?: SessionMessageReceipt[] | null; fileChanges?: TurnChangeSummary | null; fileChangesTurnId?: string; /** Codex turn/steer's browser id persisted beside a distinct history cursor. */ diff --git a/web/src/history-merge.ts b/web/src/history-merge.ts index 13118d04..8341ed8b 100644 --- a/web/src/history-merge.ts +++ b/web/src/history-merge.ts @@ -1065,6 +1065,8 @@ function mergeTurn( id: live.id, clientMsgId: history.clientMsgId ?? live.clientMsgId, timedTask: history.timedTask ?? live.timedTask, + sourceThreadId: history.sourceThreadId ?? live.sourceThreadId, + sessionMessages: history.sessionMessages ?? live.sessionMessages, historyTurnId, forkPointId: history.forkPointId ?? live.forkPointId, checkpointId: history.checkpointId ?? live.checkpointId, diff --git a/web/src/history-page-cache.ts b/web/src/history-page-cache.ts index d5331487..dfa8ea1a 100644 --- a/web/src/history-page-cache.ts +++ b/web/src/history-page-cache.ts @@ -22,7 +22,8 @@ const DEFAULT_HISTORY_PAGE_CACHE_BYTES = 64 * 1024 * 1024; // v8 preserves native model-fallback notices and immutable turn-change metadata. // v9 discards old official-summary pages which omitted their file lists. // v10 removes internal Claude recovery prompts cached as human page boundaries. -const RECORD_VERSION = 10; +// v11 preserves native Codex cross-thread provenance and sender receipts. +const RECORD_VERSION = 11; export interface HistoryPageCacheSessionScope { machineId: string; diff --git a/web/src/protocol.ts b/web/src/protocol.ts index 7ce2b7e2..61e8750c 100644 --- a/web/src/protocol.ts +++ b/web/src/protocol.ts @@ -199,8 +199,8 @@ export interface TimedTaskInfo { task_id: string; title: string; next_message_at: number; interval_seconds: number; sent_count: number; total_count: number; valid_until: number; } -export interface UserMsg extends Base { type: "user_msg"; msg_id: string; client_msg_id?: string | null; timed_task?: TimedMessage | null; prompt: string; images?: QueryImg[] | null; files?: { filename: string }[] | null } -export interface TurnSteered extends Base { type: "turn_steered"; msg_id: string; turn_id: string; prompt: string; images?: QueryImg[] | null; files?: { filename: string }[] | null } +export interface UserMsg extends Base { type: "user_msg"; source_thread_id?: string | null; msg_id: string; client_msg_id?: string | null; timed_task?: TimedMessage | null; prompt: string; images?: QueryImg[] | null; files?: { filename: string }[] | null } +export interface TurnSteered extends Base { type: "turn_steered"; source_thread_id?: string | null; msg_id: string; turn_id: string; prompt: string; images?: QueryImg[] | null; files?: { filename: string }[] | null } export interface AssistantMsgStart extends Base { type: "assistant_msg_start"; message_id: string; turn_id?: string | null; background?: boolean | null; channel?: AssistantChannel } export interface Delta extends Base { replace?: boolean; type: "delta"; message_id: string; turn_id?: string | null; background?: boolean | null; text: string; channel?: AssistantChannel } export interface ToolUse extends Base { @@ -492,7 +492,8 @@ export interface GetHistory extends Base { type: "get_history"; session_id: stri export interface ConversationImageRef { image_id: string; media_type: QueryImg["media_type"]; width: number; height: number; byte_size: number } export type ProcessDetailState = "none" | "present" | "unknown"; export type TurnDetailReason = "process" | "prompt_truncated" | "answer_truncated" | "image_deferred"; -export interface ConversationTurn { id: string; timedTask?: TimedMessage | null; clientMsgId?: string | null; prompt: string; blocks: unknown[]; done: boolean; forkPointId?: string | null; checkpointId?: string | null; interrupted?: boolean | null; error?: string | null; images?: QueryImg[] | null; imageRefs?: ConversationImageRef[] | null; files?: QueryFile[] | null; ts?: number | null; doneTs?: number | null; durationMs?: number | null; processDetailState?: ProcessDetailState; detailReasons?: TurnDetailReason[]; processStartedTs?: number | null; processDoneTs?: number | null; detailEventCount: number; detailLoaded: boolean; fileChanges?: TurnChangeSummary | null } +export interface SessionMessageReceipt { itemId: string; threadId: string; status: "sending" | "sent" | "failed" } +export interface ConversationTurn { sessionMessages?: SessionMessageReceipt[] | null; id: string; sourceThreadId?: string | null; timedTask?: TimedMessage | null; clientMsgId?: string | null; prompt: string; blocks: unknown[]; done: boolean; forkPointId?: string | null; checkpointId?: string | null; interrupted?: boolean | null; error?: string | null; images?: QueryImg[] | null; imageRefs?: ConversationImageRef[] | null; files?: QueryFile[] | null; ts?: number | null; doneTs?: number | null; durationMs?: number | null; processDetailState?: ProcessDetailState; detailReasons?: TurnDetailReason[]; processStartedTs?: number | null; processDoneTs?: number | null; detailEventCount: number; detailLoaded: boolean; fileChanges?: TurnChangeSummary | null } export interface CodexTerminalFence { turn_id: string; status: "completed" | "interrupted" | "failed"; duration_ms?: number | null; completed_at?: number | null } export interface History extends Base { type: "history"; session_id: string; revision: string; generation?: string | null; continuity_revision?: string | null; build_seq?: number; live_seq?: number | null; authoritative?: boolean; error?: string | null; events: ServerEvent[]; turns?: ConversationTurn[]; detail?: "summary" | "full"; has_more: boolean; oldest_id?: string | null; newest_id?: string | null; before?: string | null; control?: SessionControl | null; external?: boolean; takeover_pending?: boolean; in_progress?: boolean; compaction_continuation_turn_ids?: string[]; terminal_fences?: CodexTerminalFence[]; reset?: boolean } export interface GetTurnDetail extends Base { type: "get_turn_detail"; session_id: string; turn_id: string; client_id?: string | null; revision?: string | null; before?: string | null; limit?: number | null } @@ -724,7 +725,7 @@ export type ServerEvent = FilesListed | CodexContext | ProcessEvent | BackgroundProcessSync | TurnPlan | TurnDiff | TurnFileChanges | TurnBinding | TurnUsage | TurnEnd | ErrorMsg | WrapperDisconnected | WrapperReconnected | Hello; -export const PROTOCOL_VERSION = 73; +export const PROTOCOL_VERSION = 74; export const MIN_AUTO_COMPACT_TOKENS = 100_000; export const MAX_AUTO_COMPACT_TOKENS = 1_000_000; diff --git a/web/src/reducer.ts b/web/src/reducer.ts index f9f0a4f1..469f35d1 100644 --- a/web/src/reducer.ts +++ b/web/src/reducer.ts @@ -6032,6 +6032,10 @@ function reduceEvent( if (existing) { if (!existing.prompt && e.prompt) existing.prompt = e.prompt; if (e.timed_task) existing.timedTask = e.timed_task; + if (e.source_thread_id) { + existing.sourceThreadId = e.source_thread_id; + existing.prompt = e.prompt; + } if (!existing.images && imgs) existing.images = imgs; if (fileMeta) existing.files = fileMeta; else if (existing.files) existing.files = existing.files.map( @@ -6046,6 +6050,7 @@ function reduceEvent( id: e.msg_id, clientMsgId: e.client_msg_id ?? undefined, timedTask: e.timed_task ?? undefined, + sourceThreadId: e.source_thread_id ?? undefined, prompt: e.prompt, images: imgs, files: fileMeta, @@ -6113,6 +6118,10 @@ function reduceEvent( // Reliable-command replay can deliver the correlated narrative frame // again after reconnect. Other duplicates only refresh metadata. existing.prompt ||= e.prompt; + if (e.source_thread_id) { + existing.sourceThreadId = e.source_thread_id; + existing.prompt = e.prompt; + } existing.images ??= imgs; if (fileMeta) existing.files = fileMeta; existing.ts ??= stamp; @@ -6148,6 +6157,7 @@ function reduceEvent( id: e.msg_id, clientMsgId: e.msg_id, liveTaskId: e.turn_id, + sourceThreadId: e.source_thread_id ?? undefined, prompt: e.prompt, images: imgs, files: fileMeta, diff --git a/web/src/session-message-reader.ts b/web/src/session-message-reader.ts new file mode 100644 index 00000000..f2bc5b89 --- /dev/null +++ b/web/src/session-message-reader.ts @@ -0,0 +1,106 @@ +import { createRuntime, initialState, reduce, type AppState } from "./reducer"; +import { PROTOCOL_VERSION, type History, type ServerEvent } from "./protocol"; + +/** A private read-only projection. It never changes the focused chat, starts an + * engine, or installs a page into the live conversation's state. */ +export class SessionMessageReader { + state: AppState = initialState; + revision: string | null = null; + generation: string | null = null; + page = 0; + cursors: (string | null)[] = [null]; + hasMore = false; + oldestId: string | null = null; + loading = false; + error: string | null = null; + private pendingPage: number | null = null; + private detailRequests = new Map(); + + readonly sid: string; + constructor(sid: string) { this.sid = sid; } + + get turns() { return this.state.runtimes[this.sid]?.turns ?? []; } + + requestPage(page: number): { before: string | null } | null { + if (this.loading || page < 0 || page > this.cursors.length - 1) return null; + this.loading = true; + this.error = null; + this.pendingPage = page; + // A late detail response belongs to the page being left, not the pending + // page request (and must not settle its timeout). + this.detailRequests.clear(); + return { before: this.cursors[page] }; + } + + requestDetail(turnId: string, before: string | null = null): boolean { + if (this.loading || !this.revision || this.detailRequests.size > 0 + || !this.turns.some((turn) => turn.id === turnId)) return false; + this.detailRequests.set(turnId, before); + this.state = reduce(this.state, { + type: "turn_detail_requested", sid: this.sid, turnId, before, + }); + return true; + } + + fail(message = "会话读取失败,请重试。"): void { + this.error = message; + this.loading = false; + this.pendingPage = null; + for (const [turnId, before] of this.detailRequests) { + this.state = reduce(this.state, { type: "event", event: { + v: PROTOCOL_VERSION, type: "turn_detail", ts: 0, session_id: this.sid, + turn_id: turnId, revision: this.revision ?? "", before, + authoritative: false, error: message, events: [], + } }); + } + this.detailRequests.clear(); + } + + accept(event: ServerEvent): boolean { + if (event.type === "history_invalidated" && event.session_id === this.sid) { + this.fail("会话历史已更新,请重新读取。"); + this.revision = null; + this.cursors = [null]; + return true; + } + if (event.type === "history" && event.session_id === this.sid) { + const page = this.pendingPage; + if (page === null || (event.before ?? null) !== this.cursors[page]) return false; + if (event.authoritative === false || event.error || !Array.isArray(event.turns)) { + this.fail(event.error || "会话历史暂时不可用,请重试。"); + return true; + } + if (page > 0 && (event.revision !== this.revision + || (event.generation ?? null) !== this.generation)) { + this.fail("会话历史已更新,请重新读取。"); + this.cursors = [null]; + return true; + } + // Every page is its own bounded snapshot. Details and stale responses + // from the previous page cannot be merged into the new one. + const snapshot: History = { ...event, before: null }; + this.state = reduce({ ...initialState, focusedSid: this.sid, + runtimes: { [this.sid]: createRuntime() } }, { type: "event", event: snapshot }); + this.revision = event.revision; + this.generation = event.generation ?? null; + this.page = page; + this.hasMore = event.has_more && !!event.oldest_id; + this.oldestId = event.oldest_id ?? null; + this.cursors = this.cursors.slice(0, page + 1); + if (this.hasMore) this.cursors.push(this.oldestId); + this.loading = false; + this.pendingPage = null; + this.detailRequests.clear(); + return true; + } + if (event.type === "turn_detail" && event.session_id === this.sid + && event.revision === this.revision + && this.detailRequests.has(event.turn_id) + && this.detailRequests.get(event.turn_id) === (event.before ?? null)) { + this.detailRequests.delete(event.turn_id); + this.state = reduce(this.state, { type: "event", event }); + return true; + } + return false; + } +} diff --git a/web/src/session-messages.ts b/web/src/session-messages.ts new file mode 100644 index 00000000..d722a181 --- /dev/null +++ b/web/src/session-messages.ts @@ -0,0 +1,43 @@ +import type { SessionInfo, SessionMessageReceipt } from "./protocol"; +import type { Turn } from "./domain/conversation"; + +const NATIVE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +/** An envelope cannot nominate another machine/account. Resolve only against + * the current device catalog in the current routing namespace. */ +export function resolveRelatedSession( + nativeId: string, currentSid: string | null, sessions: readonly SessionInfo[], +): SessionInfo | undefined { + if (!currentSid || !NATIVE_ID.test(nativeId)) return undefined; + const separator = currentSid.indexOf("@"); + const target = separator < 0 ? nativeId : `${currentSid.slice(0, separator)}@${nativeId}`; + if (target === currentSid) return undefined; + return sessions.find((session) => session.session_id === target + && session.engine === "codex" && session.space !== "work"); +} + +export function relatedSessionTitle(session: SessionInfo | undefined, nativeId: string): string { + return session?.summary || session?.first_prompt || nativeId.slice(0, 8); +} + +/** Only the native send tool carries an outgoing link, never arbitrary prose. */ +export function outgoingSessionMessages(turn: Turn): SessionMessageReceipt[] { + const receipts = new Map((turn.sessionMessages ?? []).map((r) => [r.itemId, r])); + for (const block of turn.blocks) { + if (block.kind !== "tool") continue; + const native = (block.tool === "send_message_to_thread" + || block.tool === "codex_app.send_message_to_thread") + && (block.server === "codex_app" || block.input.namespace === "codex_app"); + if (!native && block.tool !== "mcp__codex_app__send_message_to_thread") continue; + const target = block.input.threadId; + if (typeof target !== "string" || !NATIVE_ID.test(target)) continue; + const result = block.result; + receipts.set(block.tool_use_id, { + itemId: block.tool_use_id, threadId: target, + status: !result ? receipts.get(block.tool_use_id)?.status ?? "sending" + : result.is_error || ["failed", "cancelled", "declined", "interrupted"].includes(result.status ?? "") + ? "failed" : "sent", + }); + } + return [...receipts.values()].slice(0, 16); +} diff --git a/web/tests/fixtures/session-messages.tsx b/web/tests/fixtures/session-messages.tsx new file mode 100644 index 00000000..c5b23be0 --- /dev/null +++ b/web/tests/fixtures/session-messages.tsx @@ -0,0 +1,49 @@ +import { StrictMode, useEffect, useRef, useState } from "react"; +import { ChatView } from "../../src/components/ChatView"; +import { SessionMessagePreview } from "../../src/components/SessionMessagePreview"; +import { resolveRelatedSession, relatedSessionTitle } from "../../src/session-messages"; +import { PROTOCOL_VERSION, type ServerEvent, type SessionInfo } from "../../src/protocol"; +import type { Turn } from "../../src/domain/conversation"; + +const sessions: SessionInfo[] = [ + { session_id: "nyx@source", engine: "codex", space: "code", summary: "cc-remote 开发" }, + { session_id: "nyx@receiver", engine: "codex", space: "code", summary: "代码审查" }, +]; +const turns: Turn[] = Array.from({ length: 5 }, (_, index) => ({ + id: `review-${index}`, prompt: "请检查跨会话消息的来源标识,以及历史重载后是否会重复。", + sourceThreadId: "source", done: true, + blocks: [{ kind: "text", message_id: `answer-${index}`, done: true, channel: "final", + text: "收到。我会检查来源识别、历史去重和会话跳转。\n\n" + "保持原生消息身份,刷新后只显示一次。\n\n".repeat(5) }], +})); + +export function SessionMessagesFixture() { + const [preview, setPreview] = useState(null); + const receive = useRef<((event: ServerEvent) => boolean) | null>(null); + const [requests, setRequests] = useState([]); + useEffect(() => { + document.documentElement.dataset.theme = "dark"; + document.documentElement.dataset.engine = "codex"; + }, []); + return
+
cc·remote / 代码审查
+ ({ title: relatedSessionTitle(resolveRelatedSession(id, "nyx@receiver", sessions), id), + available: !!resolveRelatedSession(id, "nyx@receiver", sessions) })} + onOpenSession={(id) => setPreview(resolveRelatedSession(id, "nyx@receiver", sessions) ?? null)} /> + {JSON.stringify(requests)} + {preview && setPreview(null)} + api={{ receive, detail: () => false, history: (sid, before) => { + setRequests((commands) => [...commands, "get_history"]); + setTimeout(() => receive.current?.({ v: PROTOCOL_VERSION, type: "history", ts: 10, sid, + session_id: sid, revision: "r", generation: "g", before, detail: "summary", events: [], + has_more: !before, oldest_id: before ? "old" : "send", turns: [{ + id: before ? "old" : "send", prompt: before ? "更早的开发记录" : "让代码审查会话帮忙检查这次改动。", + done: true, detailEventCount: 0, detailLoaded: false, + sessionMessages: before ? [] : [{ itemId: "call", threadId: "receiver", status: "sent" }], + blocks: [{ kind: "text", message_id: "answer", done: true, channel: "final", text: "已请审查会话核对来源识别、历史去重和跳转。" }], + }], + }), 25); + return true; + } }} />} +
; +} diff --git a/web/tests/history-browser.fixture.tsx b/web/tests/history-browser.fixture.tsx index 89118864..983d900a 100644 --- a/web/tests/history-browser.fixture.tsx +++ b/web/tests/history-browser.fixture.tsx @@ -1,3 +1,4 @@ +import { SessionMessagesFixture } from "./fixtures/session-messages"; import { useCallback, useEffect, @@ -3082,7 +3083,8 @@ function CodexFileCitationFixture() { const rootParams = new URLSearchParams(window.location.search); createRoot(document.getElementById("root")!).render( - rootParams.has("turn-usage") ? + rootParams.has("session-messages") ? + : rootParams.has("turn-usage") ? : rootParams.has("artifact-mermaid") ? : rootParams.has("artifact-audio") diff --git a/web/tests/history-browser.spec.ts b/web/tests/history-browser.spec.ts index 762eef27..c3ba020f 100644 --- a/web/tests/history-browser.spec.ts +++ b/web/tests/history-browser.spec.ts @@ -10993,3 +10993,37 @@ test("live token usage updates immediately and supports explicit keyboard activa await page.locator(".composer").dispatchEvent("pointerdown"); await expect(card).toBeHidden(); }); + + +test("session message source preview returns to the same reading position", async ({ page }, testInfo) => { + await page.goto("/tests/history-browser.html?session-messages=1"); + const thread = page.locator(".thread").first(); + await expect(page.getByRole("button", { name: /来自会话/ }).last()).toBeVisible(); + const source = page.getByRole("button", { name: /来自会话/ }).last(); + await source.scrollIntoViewIfNeeded(); + const before = await thread.evaluate((node) => node.scrollTop); + await source.click(); + const preview = page.getByRole("dialog", { name: "关联会话历史" }); + await expect(preview).toBeVisible(); + await expect(preview.getByText("让代码审查会话帮忙检查这次改动。")).toBeVisible(); + await expect(preview.getByRole("button", { name: /已发送给/ })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("session-source.png") }); + await preview.getByRole("button", { name: "更早记录", exact: true }).click(); + await expect(preview.getByText("更早的开发记录")).toBeVisible(); + await preview.getByRole("button", { name: "较新记录", exact: true }).click(); + await expect(preview.getByText("让代码审查会话帮忙检查这次改动。")).toBeVisible(); + await preview.getByRole("button", { name: "返回代码审查" }).click(); + await expect(preview).toHaveCount(0); + expect(Math.abs(await thread.evaluate((node) => node.scrollTop) - before)).toBeLessThan(3); + await source.click(); + await preview.getByRole("button", { name: /已发送给 代码审查/ }).click(); + await expect(preview).toHaveCount(0); + await source.click(); + await expect(preview).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(preview).toHaveCount(0); + expect(Math.abs(await thread.evaluate((node) => node.scrollTop) - before)).toBeLessThan(3); + // StrictMode reattaches effects on each open; every command remains read-only. + expect(JSON.parse(await page.getByTestId("session-read-commands").textContent() ?? "[]")) + .toEqual(Array(8).fill("get_history")); +}); diff --git a/web/tests/reliability.test.ts b/web/tests/reliability.test.ts index ba79e304..ecce7d32 100644 --- a/web/tests/reliability.test.ts +++ b/web/tests/reliability.test.ts @@ -1,3 +1,4 @@ +import "./session-messages.test.ts"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -1387,8 +1388,8 @@ assert.match(historyAppSource, assert.match(historyAppSource, /requestHistoryTurnDetail = useCallback\([\s\S]{0,120}autoLoad = false/, "every detail entry point must default to one bounded page"); -assert.match(cacheSource, /const CACHE_VER = 27/, - "native recovery repair must invalidate browser summaries split by internal prompts"); +assert.match(cacheSource, /const CACHE_VER = 28/, + "native message provenance must invalidate browser summaries missing source metadata"); assert.match(cacheSource, /objectStore\(STORE\)\.delete\(sessionId\)/); assert.match(cacheSource, /job\.epoch !== sessionEpoch\(job\.sid\)/, "a debounced pre-marker write must not recreate the deleted cache row"); diff --git a/web/tests/session-messages.test.ts b/web/tests/session-messages.test.ts new file mode 100644 index 00000000..fa3db16f --- /dev/null +++ b/web/tests/session-messages.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { createServer } from "vite"; +import type { ServerEvent, SessionInfo } from "../src/protocol.ts"; +import { PROTOCOL_VERSION } from "../src/protocol.ts"; +import { resolveRelatedSession, outgoingSessionMessages } from "../src/session-messages.ts"; + +const sessions: SessionInfo[] = [ + { session_id: "nyx@sender", engine: "codex", space: "code", summary: "开发" }, + { session_id: "iris@sender", engine: "codex", space: "code", summary: "Other account" }, + { session_id: "nyx@claude", engine: "claude", space: "code" }, + { session_id: "nyx@work", engine: "codex", space: "work" }, +]; +assert.equal(resolveRelatedSession("sender", "nyx@receiver", sessions)?.summary, "开发"); +for (const target of ["iris@sender", "../sender", "https://example.com", "missing", "claude", "work", "receiver"]) { + assert.equal(resolveRelatedSession(target, "nyx@receiver", sessions), undefined); +} +assert.equal(resolveRelatedSession("sender", "other@receiver", sessions), undefined); +assert.equal(resolveRelatedSession("sender", "receiver", sessions), undefined); +const receiptTurn = { id: "t", prompt: "send", blocks: [], done: true, + sessionMessages: [{ itemId: "call", threadId: "sender", status: "sent" as const }] }; +assert.deepEqual(outgoingSessionMessages(receiptTurn), receiptTurn.sessionMessages); +assert.deepEqual(outgoingSessionMessages({ ...receiptTurn, blocks: [{ + kind: "tool", message_id: "m", tool_use_id: "call", tool: "send_message_to_thread", + server: "codex_app", input: { threadId: "sender" }, done: true, + result: { content: "rejected", is_error: true }, +}] }), [{ itemId: "call", threadId: "sender", status: "failed" }]); + +const harness = await createServer({ root: process.cwd(), appType: "custom", logLevel: "silent", + server: { middlewareMode: true, watch: null } }); +try { + const { createRuntime, initialState, reduce } = await harness.ssrLoadModule("/src/reducer.ts"); + const { SessionMessageReader } = await harness.ssrLoadModule("/src/session-message-reader.ts"); + const event = (body: Record): ServerEvent => ({ + v: PROTOCOL_VERSION, ts: 10, ...body, + } as ServerEvent); + const sid = "nyx@receiver"; + const user = event({ type: "user_msg", sid, msg_id: "native-input", prompt: "检查代码", source_thread_id: "sender" }); + const turn = { id: "native-input", prompt: "检查代码", sourceThreadId: "sender", blocks: [], + done: true, detailEventCount: 0, detailLoaded: false, sessionMessages: receiptTurn.sessionMessages }; + const history = event({ type: "history", sid, session_id: sid, revision: "r", generation: "g", + detail: "summary", events: [], turns: [turn], has_more: false, oldest_id: turn.id }); + for (const events of [[user, history, user], [history, user], [user, user, history]]) { + let state = { ...initialState, focusedSid: sid, sessions, runtimes: { [sid]: createRuntime() } }; + for (const e of events) state = reduce(state, { type: "event", event: e }); + assert.equal(state.runtimes[sid].turns.length, 1, "live/history races use native identity, never append a duplicate"); + assert.equal(state.runtimes[sid].turns[0].sourceThreadId, "sender"); + } + let state = { ...initialState, focusedSid: sid, sessions, runtimes: { [sid]: createRuntime() } }; + for (const e of [user, event({ ...user, msg_id: "different-native-id" })]) { + state = reduce(state, { type: "event", event: e }); + } + assert.equal(state.runtimes[sid].turns.length, 2, "identical words in separate native messages remain separate"); + state = reduce(state, { type: "event", event: event({ type: "turn_steered", sid, + msg_id: "steer", turn_id: "native-task", source_thread_id: "sender", prompt: "跟进" }) }); + assert.equal(state.runtimes[sid].turns.at(-1).sourceThreadId, "sender"); + + const reader = new SessionMessageReader(sid); + assert.deepEqual(reader.requestPage(0), { before: null }); + assert.equal(reader.accept(event({ ...history, session_id: "iris@receiver" })), false); + assert.equal(reader.accept(event({ ...history, has_more: true })), true); + assert.equal(reader.turns[0].sourceThreadId, "sender"); + assert.equal(reader.state.focusedSid, sid); + assert.deepEqual(reader.requestPage(1), { before: "native-input" }); + assert.equal(reader.accept(history), false, "late initial page cannot replace an older page request"); + assert.equal(reader.accept(event({ ...history, before: "native-input", turns: [{ ...turn, id: "old" }], oldest_id: "old" })), true); + assert.deepEqual(reader.turns.map((t: { id: string }) => t.id), ["old"]); + assert.deepEqual(reader.requestPage(0), { before: null }); + reader.accept(history); + assert.equal(reader.requestDetail("wrong-turn"), false); + assert.equal(reader.requestDetail("native-input"), true); + reader.fail("timeout"); + assert.equal(reader.turns[0].detailLoading, false); + assert.equal(reader.turns[0].detailError, "timeout"); + assert.equal(reader.requestDetail("native-input"), true); + assert.deepEqual(reader.requestPage(0), { before: null }); + assert.equal(reader.accept(event({ type: "turn_detail", session_id: sid, + turn_id: "native-input", revision: "r", before: null, events: [] })), false, + "a late detail from the previous page cannot settle the pending page read"); + assert.equal(reader.loading, true); + assert.equal(reader.accept(history), true); + assert.equal(initialState.focusedSid, null, "read-only preview never mutates the live state singleton"); +} finally { await harness.close(); } +console.log("session message provenance, identity, routing and read-only history: passed"); From 8fe0be0df49d79e601ee388a6408d89a2fde546b Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Fri, 2 Oct 2026 17:19:45 +0800 Subject: [PATCH 02/13] docs(deploy): retain one rollback generation --- .agents/skills/cc-remote-deploy/SKILL.md | 13 +++++++ deploy/README.md | 49 +++++++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/.agents/skills/cc-remote-deploy/SKILL.md b/.agents/skills/cc-remote-deploy/SKILL.md index 93082e04..183fbf6c 100644 --- a/.agents/skills/cc-remote-deploy/SKILL.md +++ b/.agents/skills/cc-remote-deploy/SKILL.md @@ -37,6 +37,19 @@ migration, remaining in-process `/btw` turns, and deferred queues separately; wait for them to drain instead of interrupting work. Do not restart or replace an active SDK service to satisfy a version/readiness check. +## Keep only one rollback generation + +Apply [deployment backup retention](../../../deploy/README.md#deployment-backup-retention) +on every in-scope host/install. Before creating another deployment backup, remove +confirmed older, unreferenced backup generations, keeping the latest complete +rollback set. After coordinated activation passes acceptance, replace that set +with the version just superseded and its matching state/configuration snapshot. +The active installation is not a backup. Do not accumulate dated copies, duplicate +archives or Trash entries between deployments. Preserve active dependencies and +unresolved transaction recovery files; report any deferred cleanup and recheck it +on the next deployment. Include retained paths and actual space reclaimed in the +handoff. This is an agent workflow requirement, not automatic installer pruning. + ## Codex CLI sharing is an acceptance check For every enabled Codex **Code** account, follow diff --git a/deploy/README.md b/deploy/README.md index fa6a697e..53402aff 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -24,6 +24,8 @@ Before changing a live service: 1. Inspect the source worktree, target installation, current release, service manager, and health. Preserve unrelated changes; do not normalize a dirty worktree or silently replace a custom installation layout. + Apply [backup retention](#deployment-backup-retention) before creating another + deployment backup or staging copy. 2. Select the matching supported path. Prefer a tested source snapshot for current features using the [source deployment guide](../docs/installation_en.md#source-install). Use `install.sh` when the operator selects a published release that includes @@ -76,8 +78,51 @@ After these checks, offer the [optional Codex App attachment](#optional-codex-ap on eligible desktops. Its consent/availability is reported separately and never turns a healthy core deployment into a failure. On failure, use the installer-owned rollback or the retained previous -release and matching state snapshot; do not delete old releases during the -deployment. +release and matching state snapshot. Never prune the active transaction's +rollback set during activation or recovery; remove older unreferenced generations +beforehand and finalize retention after coordinated acceptance as specified below. + +### Deployment backup retention + +Agent-led deployments must leave **the active installation plus at most one +complete previous rollback generation per installation on each in-scope host**. +A generation includes the matching release code/runtime, configuration copies +and private state snapshot needed to restore it; these are one recovery set, +not separate allowances for multiple historical copies. An intact immutable +release can serve as the code backup without another archive of the same tree. + +1. Before creating the next backup or staging copy, identify the active release, + the newest complete known-good rollback set, and any unresolved deployment + transaction. Remove only confirmed older, superseded deployment backups and + unused duplicate uploads/archives. Determine generations from release and + transaction records, not filename age alone; check resolved paths before + deletion and keep cleanup inside the verified installation/backup locations. +2. Create and validate the new pre-upgrade snapshot using the normal transaction + procedure. Keep the existing valid rollback set until coordinated acceptance + succeeds. Temporary coexistence during this transaction must not become + permanent retention; never delete the sole usable backup to make room for + an unverified replacement. +3. After all protocol tiers pass acceptance and the transaction is committed, + retain only the version just superseded and its matching recovery files. + Remove the older rollback generation and completed, unneeded staging/upload + copies. On failure or unknown outcome, preserve the exact transaction's + recovery set and settle it before further cleanup or deployment attempts. + +Do not delete native transcripts, credentials, current private state, project +files or unrelated user backups under this policy. A release still referenced +by a running service (including the independent Claude service), a shared venv, +an active job or the retained rollback set is a live dependency, not an unused +backup. If these references temporarily prevent the retention limit, retain the +referenced files, report the exact reason and recheck on the next deployment; +do not stop active work to meet the limit. + +Report retained rollback paths, deleted generations, measured space reclaimed +and any deferred paths. Moving old copies into another backup directory or Trash +does not reclaim disk space or satisfy this policy. This is an agent-operated +cleanup step: the existing installers and `cc-remote update` do not automatically +prune releases or backups. + +### Deployment entrypoints - `install.sh` — versioned GitHub Release bootstrap. It requires an explicit `relay` or `wrapper` role, detects OS/CPU, downloads that one role archive, From b5c5a0d936874ddc0cea13dab587a2b1bb0574cb Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Fri, 2 Oct 2026 18:28:55 +0800 Subject: [PATCH 03/13] docs(deploy): align development protocol references with v74 --- CHANGELOG.md | 8 ++++++++ CHANGELOG_zh.md | 6 ++++++ CLAUDE.md | 2 +- README.md | 2 +- README_en.md | 2 +- deploy/README.md | 8 ++++---- 6 files changed, 21 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6f0655..bc019913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ [中文](CHANGELOG_zh.md) +## Unreleased + +- Display native Codex cross-session messages and open their source conversations + in a scoped read-only history view. Wire protocol v74 requires a coordinated + Relay, Web, and Wrapper upgrade. +- Keep at most one complete previous deployment rollback generation, preserving + active dependencies and unresolved transaction recovery files. + ## v4.0.7 Add model-specific Codex speed selection and repair Claude recovery and private diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index ac653ccf..3d45815c 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -2,6 +2,12 @@ [English](CHANGELOG.md) +## 未发布 + +- 展示 Codex 原生跨会话消息,可在只读历史面板中查看对应来源会话,并保留设备与账号边界。 + 通信协议升级到 v74,需协调更新 Relay、Web 和 Wrapper。 +- 每次部署至多保留一套完整的上一版回滚备份;仍被运行服务引用的文件和未完成事务的恢复文件除外。 + ## v4.0.7 新增按模型选择 Codex 速度,修复 Claude 会话恢复和临时侧聊清理。 diff --git a/CLAUDE.md b/CLAUDE.md index 91248f8a..a8230bc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ a separate choice; sharing alone does not authorize them. `useLayoutEffect` is deliberately dependency-free — late virtualizer/image measurements settle without a React render, and constraining it to its read set reintroduces a full-viewport jump on touch release. -- **Protocol version gate**: current wire protocol v73 is declared by +- **Protocol version gate**: current wire protocol v74 is declared by `PROTOCOL_VERSION` in both `protocol.py` and `web/src/protocol.ts`. `deserialize` hard-rejects a version mismatch, and `_Base` is `extra="forbid"`, so ANY protocol change must be deployed to all diff --git a/README.md b/README.md index edcfa048..8e64734e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 自托管 · 多会话 · 多设备 · 实时工具过程 · Code / Work · Web / PWA / TUI -**产品版本:v4.0.7** · Wire protocol v73 +**产品版本:v4.0.7** · 当前开发版 Wire protocol v74 [English](README_en.md) · [功能对照](#引擎与功能) · [快速开始](#快速开始) · [终端工作台](#terminal-workspace) · [安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md) diff --git a/README_en.md b/README_en.md index 702771ff..82ac3482 100644 --- a/README_en.md +++ b/README_en.md @@ -4,7 +4,7 @@ Self-hosted · Multiple sessions and devices · Live tool activity · Code / Work · Web / PWA / TUI -**Product version: v4.0.7** · Wire protocol v73 +**Product version: v4.0.7** · Current development wire protocol v74 [中文](README.md) · [Engine comparison](#engines-and-features) · [Quick start](#quick-start) · [Terminal workspace](#terminal-workspace) · [Install and upgrade](#install-and-upgrade) · [Documentation](#documentation) · [Changelog](CHANGELOG.md) diff --git a/deploy/README.md b/deploy/README.md index 53402aff..839d11d7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -238,11 +238,11 @@ prune releases or backups. migration transaction, restores matching pre-release data before an older wrapper is restarted, and verifies both engines' Work ownership backfills. -Protocol v73 is a coordinated upgrade: publish freshly built Relay/Web and +Protocol v74 is a coordinated upgrade: publish freshly built Relay/Web and Wrapper artifacts from the same tagged commit. The strict protocol gate is intentional and mixed protocol versions will not communicate. `setup-vps.sh` rejects a missing or mismatched web build manifest. Stop the wrapper first; -activate the v73 relay/web release; then start the v73 wrapper. +activate the v74 relay/web release; then start the v74 wrapper. The wrapper installer treats local Work data and versioned private control state as part of the release @@ -255,8 +255,8 @@ the previous code. If data restoration fails, it leaves the wrapper stopped instead of running old code against a new schema. A manual or legacy-layout deployment must use the same order: stop the wrapper, run `work_registry_snapshot.py snapshot` from the new staging tree, activate and -verify v73, and retain that snapshot with the previous release. To roll back, -stop v73, run `work_registry_snapshot.py restore`, then switch and start the old +verify v74, and retain that snapshot with the previous release. To roll back, +stop v74, run `work_registry_snapshot.py restore`, then switch and start the old release. Never copy only `registry.sqlite3` while the wrapper is live because committed state may still be in its WAL file. Restoring a pre-release snapshot also restores pre-release Work metadata: sessions, projects, or schedule state From 89fe4b53859a5eee1151ad56e8e123efcdf02ba2 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Sat, 3 Oct 2026 13:20:26 +0800 Subject: [PATCH 04/13] feat(web): add interactive mobile sidebar reveal - Move the full-width chat with touch-driven sidebar progress and distance or velocity settling. - Preserve native scrolling, editor gestures, selection, keyboard sizing and desktop layout. - Load the gesture controller with the sidebar and replace legacy swipe checks with Chromium and WebKit coverage. --- web/playwright.config.ts | 2 +- web/src/App.tsx | 35 +--- web/src/components/SessionsSidebar.tsx | 10 +- web/src/index.css | 21 +++ web/src/responsive-layout.ts | 27 +--- web/src/use-mobile-sidebar.ts | 190 ++++++++++++++++++++++ web/tests/fixtures/mobile-sidebar.tsx | 63 ++++++++ web/tests/history-browser.fixture.tsx | 4 +- web/tests/mobile-sidebar.spec.ts | 211 +++++++++++++++++++++++++ web/tests/reliability.test.ts | 9 +- 10 files changed, 508 insertions(+), 64 deletions(-) create mode 100644 web/src/use-mobile-sidebar.ts create mode 100644 web/tests/fixtures/mobile-sidebar.tsx create mode 100644 web/tests/mobile-sidebar.spec.ts diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 43fc82f4..0d70eb4d 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -25,7 +25,7 @@ const WEBKIT_GENERAL_EXCLUSIONS = [ export default defineConfig({ testDir: "./tests", - testMatch: ["history-browser.spec.ts", "background-tasks.spec.ts", "themes.spec.ts"], + testMatch: ["history-browser.spec.ts", "background-tasks.spec.ts", "themes.spec.ts", "mobile-sidebar.spec.ts"], fullyParallel: false, workers: 1, retries: process.env.CI ? 2 : 0, diff --git a/web/src/App.tsx b/web/src/App.tsx index bda2a2eb..43ae9486 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,7 +8,6 @@ import { useReducer, useRef, useState, - type TouchEvent, } from "react"; import { RelayWs, sessionScopeKey, type EventOwnership } from "./ws"; import type { QueryAcceptanceResult } from "./outbox"; @@ -129,7 +128,6 @@ import { matchesBtwRequest, import type { EngineCapabilities, EngineCapabilityItem, EngineCapabilityKind, WorkArtifactInfo, WorkDashboard } from "./protocol"; import { isMarkdownPath } from "./preview-path"; import { parseGitDiff } from "./diff"; -import { resolveSidebarSwipe } from "./responsive-layout"; import { bumpSessionActivity, mergeSessionActivityState, @@ -316,8 +314,8 @@ interface QueuedQueryEditorState extends QueuedQueryEditor { const MAX_TERMINAL_HISTORY_REPAIR_ATTEMPTS = 2; -// The sidebar is an overlay on mobile (<980px, matches index.css) but a -// persistent grid column on desktop. So auto-close it after picking a session +// The sidebar pushes the page on mobile (<980px, matches index.css) but is a +// persistent column on desktop. So auto-close it after picking a session // ONLY on mobile; on desktop keep it open. const isMobile = () => window.matchMedia("(max-width: 979px)").matches; @@ -659,9 +657,6 @@ export default function App() { const notificationListRequestRef = useRef(null); const notificationOriginRef = useRef(null); const pendingNotificationErrorRef = useRef(null); - const touchStartX = useRef(0); - const touchStartY = useRef(0); - const touchSwipeLocked = useRef(false); const artifactDirtyRef = useRef(false); const setArtifactDirty = useCallback((dirty: boolean) => { artifactDirtyRef.current = dirty; @@ -1627,29 +1622,6 @@ export default function App() { ); }, [authed, machineId, notificationMode]); - // Swipe right -> open sidebar, swipe left -> close (mobile). Interactive - // vertical scrollers opt out so a diagonal scroll never becomes navigation. - const onTouchStart = (e: TouchEvent) => { - const touch = e.touches[0]; - touchStartX.current = touch.clientX; - touchStartY.current = touch.clientY; - touchSwipeLocked.current = e.target instanceof Element - && !!e.target.closest("[data-lock-horizontal-swipe]"); - }; - const onTouchEnd = (e: TouchEvent) => { - const touch = e.changedTouches[0]; - const action = resolveSidebarSwipe( - touchStartX.current, - touchStartY.current, - touch.clientX, - touch.clientY, - window.innerWidth, - touchSwipeLocked.current, - ); - if (action === "open") setSidebarOpen(true); - else if (action === "close") setSidebarOpen(false); - }; - useEffect(() => { try { sessionStorage.setItem( @@ -5567,9 +5539,10 @@ export default function App() { -
+
import("./SessionCardMenu").then(module => ({ default: module.SessionCardMenu }))); const TimedTaskIndicator = lazy(() => import("./TimedTaskIndicator").then(module => ({ default: module.TimedTaskIndicator }))); @@ -43,6 +44,7 @@ interface Props { onNew: (profileId?: string) => void; onNewInDir: (cwd: string) => void; onClose: () => void; + onOpenChange?: (open: boolean) => void; onRename: (id: string, title: string) => void; onArchive: (id: string, archived: boolean) => void; onPin: (session: SessionInfo, pinned: boolean) => void; @@ -77,8 +79,9 @@ export function SessionsSidebar({ open, engine, space, profileScopeKey, machineId, claudeProfiles = [], defaultClaudeProfileId, codexProfiles = [], defaultCodexProfileId, onSpaceChange, sessions, liveStates, - completionBadges, activeSessionId, onSelect, onNew, onNewInDir, onClose, + completionBadges, activeSessionId, onSelect, onNew, onNewInDir, onClose, onOpenChange, onRename, onArchive, onPin, onDelete, onForkWorktree, onMigrate }: Props) { + const sidebarRef = useMobileSidebar(open, onOpenChange); const manualUnread = useManualUnread(); const [q, setQ] = useState(""); const [menuCardId, setMenuCardId] = useState(null); @@ -457,9 +460,8 @@ export function SessionsSidebar({ open, engine, space, return ( <>
-