diff --git a/web/src/components/SidebarToggle.tsx b/web/src/components/SidebarToggle.tsx
new file mode 100644
index 00000000..56c17578
--- /dev/null
+++ b/web/src/components/SidebarToggle.tsx
@@ -0,0 +1,38 @@
+import { useSyncExternalStore, type ReactNode } from "react";
+import { sidebarOpenFeedback } from "../sidebar-feedback";
+
+const MOBILE_TOUCH = "(max-width: 979px) and (any-pointer: coarse)";
+const subscribe = (notify: () => void) => {
+ const media = window.matchMedia(MOBILE_TOUCH);
+ media.addEventListener("change", notify);
+ return () => media.removeEventListener("change", notify);
+};
+const nativeSwitchAvailable = () => typeof navigator.vibrate !== "function"
+ && window.matchMedia(MOBILE_TOUCH).matches
+ && "switch" in document.createElement("input");
+const serverSnapshot = () => false;
+const markSwitch = (input: HTMLInputElement | null) => input?.setAttribute("switch", "");
+
+/** Safari's feedback needs a real user-operated switch, not a scripted click.
+ * Its checked state represents sidebar visibility, including swipe changes.
+ * https://webkit.org/blog/15865/webkit-features-in-safari-18-0/
+ */
+export function SidebarToggle({ open, onOpenChange, className = "", children, testId }: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ className?: string;
+ children: ReactNode;
+ testId?: string;
+}) {
+ const native = useSyncExternalStore(subscribe, nativeSwitchAvailable, serverSnapshot);
+ if (native) return
;
+ return
;
+}
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/index.css b/web/src/index.css
index e2768758..08503973 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -957,10 +957,9 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.ptab.on{ color:var(--accent-text,var(--accent)); background:var(--accent-weak); }
.ptab:not(.on):hover{ color:var(--text); }
-/* desktop: an open right panel PUSHES the chat left (split), not covers it.
- mobile keeps the full-width bottom-sheet overlay (panels' max-width media
- query), so this only applies >=981px. */
-.pane{ transition:padding-right .26s var(--ease); }
+/* Reserve the final right-panel width in one layout. Animating this padding
+ repeatedly wraps paragraphs and remeasures virtual history while opening.
+ The floating panel keeps its own slide-in animation; mobile stays overlay. */
@media (min-width:981px){
.shell.panel-open .pane{ padding-right:calc(var(--panel-w) + 28px); }
}
@@ -1722,6 +1721,12 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.tt{ position:fixed; top:16px; right:16px; z-index:61; }
/* SESSIONS SIDEBAR */
+.sidebar-native-toggle{ position:relative; cursor:pointer; -webkit-tap-highlight-color:transparent; }
+/* Keep the genuine switch in the tap path for Safari haptics. It is the only
+ accessible control; the existing title remains its visual presentation. */
+.sidebar-native-toggle > input{ position:absolute; inset:0; width:100%; height:100%; margin:0;
+ opacity:0; cursor:inherit; }
+.sidebar-native-toggle:has(> input:focus-visible){ outline:2px solid var(--accent); outline-offset:2px; border-radius:6px; }
.scrim-side{ position:fixed; inset:0; z-index:25; background:rgba(30,22,12,.34); opacity:0; pointer-events:none; transition:.2s; }
.scrim-side.show{ opacity:1; pointer-events:auto; }
:root[data-theme="dark"] .scrim-side{ background:rgba(0,0,0,.55); }
@@ -1874,13 +1879,53 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.sgroup-toggle:focus-visible~.sgroup-path-tip{ opacity:1; visibility:visible; transform:translateY(0); }
.sgroup-toggle .label{ font-size:11.5px; font-weight:var(--font-weight-600); letter-spacing:.06em; text-transform:uppercase; color:var(--faint); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sgroup-toggle .count{ font-size:10.5px; color:var(--faint); font-weight:var(--font-weight-500); flex:none; }
+@media (max-width:979px){
+ .shell{ --mobile-sidebar-width:min(352px,82vw);
+ /* The drawer is the continuous lower surface, including the areas revealed
+ behind BOTH rounded page corners beyond the drawer's content width. */
+ background:var(--sidebar); isolation:isolate; }
+ /* Translate the full-width chat without reflowing history or its composer.
+ Closed/resting panes have no transform, preserving fixed popover geometry. */
+ .shell > .pane{ width:100%; margin-left:0; transform:none;
+ border-radius:0; transition:none; }
+ .shell:is(.sidebar-open,[data-sidebar-motion]) > .pane{
+ z-index:1; transform:translate3d(0,0,0); will-change:transform;
+ transition:transform .26s var(--ease);
+ /* Direct filtering also works in WebKit's translated sibling layers.
+ Resting/closed panes keep filter:none for viewport-fixed popovers. */
+ /* Keep the raster/clip stable while moving it. Re-filtering and reshaping
+ a long conversation on every finger position defeats the compositor. */
+ filter:blur(.7px); border-radius:clamp(36px,12vw,48px);
+ box-shadow:-12px 0 36px -8px rgba(0,0,0,.32),0 0 0 1px rgba(255,255,255,.04); }
+ .shell.sidebar-open > .pane{ transform:translate3d(var(--mobile-sidebar-width),0,0); }
+ .shell > .sessions{ position:absolute; top:0; width:var(--mobile-sidebar-width); height:100%; z-index:0;
+ visibility:hidden; transform:translate3d(-22%,0,0);
+ transition:transform .26s var(--ease),visibility .26s; }
+ .shell:is(.sidebar-open,[data-sidebar-motion]) > .sessions{ visibility:visible; }
+ .shell.sidebar-open > .sessions{ transform:translate3d(0,0,0); }
+ .shell > .scrim-side{ position:absolute; top:0; height:100%; z-index:2;
+ transform:translate3d(0,0,0); opacity:0; border-radius:0;
+ background:rgba(0,0,0,.12);
+ transition:transform .26s var(--ease),opacity .26s var(--ease); }
+ .shell:is(.sidebar-open,[data-sidebar-motion]) > .scrim-side{ border-radius:clamp(36px,12vw,48px); }
+ .shell.sidebar-open > .scrim-side{ transform:translate3d(var(--mobile-sidebar-width),0,0); opacity:1; }
+ :root[data-theme="dark"] .shell > .scrim-side{ background:rgba(0,0,0,.18); }
+ /* Light palettes need a gentle separation, not a gray sheet over the chat. */
+ :root:not([data-theme="dark"]) .shell > .scrim-side{
+ background:color-mix(in srgb,var(--dim) 3%,transparent); }
+ :root:not([data-theme="dark"]) .shell:is(.sidebar-open,[data-sidebar-motion]) > .pane{
+ box-shadow:-12px 0 36px -8px color-mix(in srgb,var(--dim) 24%,transparent),
+ 0 0 0 1px color-mix(in srgb,var(--dim) 7%,transparent); }
+ .shell:is([data-sidebar-motion="dragging"],[data-sidebar-motion="holding"]) > :is(.pane,.sessions,.scrim-side){ transition:none; }
+ .shell[data-sidebar-motion] > .scrim-side{ pointer-events:auto; }
+}
@media (min-width:980px){
/* Animate two ordinary lengths instead of grid-template-columns. Chromium
can strand an interpolated 0px/1fr grid track; a fixed sidebar transform
plus a matching pane offset keeps the slide smooth without that failure. */
.shell{ display:block; height:var(--app-height,100dvh); }
.pane{ width:100%; margin-left:0;
- transition:margin-left .3s var(--ease),width .3s var(--ease),padding-right .26s var(--ease); }
+ transition:margin-left .3s var(--ease),width .3s var(--ease); }
.shell.sidebar-open .pane{ width:calc(100% - 352px); margin-left:352px; }
.sessions{ position:fixed; width:352px; height:var(--app-height,100dvh); overflow:hidden; }
.s-inner{ width:100%; }
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..adcab826 100644
--- a/web/src/reducer.ts
+++ b/web/src/reducer.ts
@@ -714,6 +714,11 @@ function allocateLiveOrder(turn: Turn): number {
}
function appendLiveBlock
(turn: Turn, block: T): T {
+ // A Claude continuation can append to a completed summary loaded before
+ // this browser saw any stream frames. Establish one chronology for that
+ // source prefix and the new tail now, not only when the row spills. Otherwise
+ // a later answer-only summary can put the retained tools after their answer.
+ ensureLiveBlockOrder(turn);
if (block.liveOrder == null) block.liveOrder = allocateLiveOrder(turn);
turn.blocks.push(block);
return block;
@@ -6032,6 +6037,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 +6055,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 +6123,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 +6162,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/responsive-layout.ts b/web/src/responsive-layout.ts
index 4a0118bc..ed95fb09 100644
--- a/web/src/responsive-layout.ts
+++ b/web/src/responsive-layout.ts
@@ -1,23 +1,12 @@
-export type SidebarSwipeAction = "open" | "close" | null;
-
-const SWIPE_THRESHOLD_PX = 50;
-const HORIZONTAL_INTENT_RATIO = 1.25;
+export function sidebarDragIntent(dx: number, dy: number): "pending" | "horizontal" | "vertical" {
+ if (Math.max(Math.abs(dx), Math.abs(dy)) < 10) return "pending";
+ return Math.abs(dx) > Math.abs(dy) * 1.25 ? "horizontal" : "vertical";
+}
-export function resolveSidebarSwipe(
- startX: number,
- startY: number,
- endX: number,
- endY: number,
- viewportWidth: number,
- locked: boolean,
-): SidebarSwipeAction {
- if (locked) return null;
- const dx = endX - startX;
- const dy = endY - startY;
- if (Math.abs(dx) <= SWIPE_THRESHOLD_PX
- || Math.abs(dx) <= Math.abs(dy) * HORIZONTAL_INTENT_RATIO) return null;
- if (dx > 0 && startX < viewportWidth / 3) return "open";
- return dx < 0 ? "close" : null;
+export function sidebarReleaseOpen(offset: number, width: number, velocity: number): boolean {
+ // A recent flick carries the drawer; a held/slow drag settles by distance.
+ if (Math.abs(velocity) >= 0.45) return velocity > 0;
+ return offset >= width / 2;
}
const PANEL_MIN_WIDTH_PX = 360;
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/src/sidebar-feedback.ts b/web/src/sidebar-feedback.ts
new file mode 100644
index 00000000..fe0e13ff
--- /dev/null
+++ b/web/src/sidebar-feedback.ts
@@ -0,0 +1,12 @@
+/** Call synchronously from the user's opening tap/touchend, never an effect. */
+export function sidebarOpenFeedback(): void {
+ if (typeof window === "undefined" || typeof navigator === "undefined"
+ || !window.matchMedia("(max-width: 979px)").matches
+ || navigator.maxTouchPoints < 1) return;
+ // Haptics are optional: browser policy or OS settings may suppress them.
+ try {
+ navigator.vibrate?.(10);
+ } catch {
+ // Failure to provide feedback must never prevent opening the drawer.
+ }
+}
diff --git a/web/src/use-mobile-sidebar.ts b/web/src/use-mobile-sidebar.ts
new file mode 100644
index 00000000..b71bc93c
--- /dev/null
+++ b/web/src/use-mobile-sidebar.ts
@@ -0,0 +1,254 @@
+import { useLayoutEffect, useRef, useState } from "react";
+import { sidebarDragIntent, sidebarReleaseOpen } from "./responsive-layout";
+import { sidebarOpenFeedback } from "./sidebar-feedback";
+
+const MOBILE = "(max-width: 979px)";
+const LOCKED_TARGET = "[data-lock-horizontal-swipe], input, textarea, select, "
+ + "[contenteditable]:not([contenteditable=false]), [role=slider], [role=dialog], [role=menu], [role=listbox], pre";
+
+interface Drag {
+ id: number;
+ x: number;
+ y: number;
+ base: number;
+ offset: number;
+ width: number;
+ wasOpen: boolean;
+ claimed: boolean;
+ interrupted: boolean;
+ samples: { x: number; time: number }[];
+}
+
+/** Move compositor layers, not React's streaming conversation, on each touch. */
+export function useMobileSidebar(open: boolean, onOpenChange?: (open: boolean) => void) {
+ const [sidebar, ref] = useState(null);
+ const openRef = useRef(open);
+ const syncRef = useRef<(() => void) | null>(null);
+ useLayoutEffect(() => {
+ openRef.current = open;
+ syncRef.current?.();
+ }, [open]);
+
+ useLayoutEffect(() => {
+ const shell = sidebar?.closest(".shell");
+ if (!sidebar || !shell || !onOpenChange) return;
+ const media = window.matchMedia(MOBILE);
+ const page = shell.querySelector(":scope > .pane");
+ const scrim = shell.querySelector(":scope > .scrim-side");
+ if (!page || !scrim) return;
+ let viewportWidth = window.innerWidth;
+ let drag: Drag | null = null;
+ let frame = 0;
+ let timer = 0;
+ let expectedOpen: boolean | null = null;
+ let settleOffset = 0;
+ let suppressClickUntil = 0;
+ const write = (offset: number, width: number) => {
+ // Non-inherited properties invalidate these three layers only. Shell-level
+ // custom properties used to invalidate the conversation on every frame.
+ page.style.transform = `translate3d(${offset}px,0,0)`;
+ sidebar.style.transform = `translate3d(${(offset - width) * .22}px,0,0)`;
+ scrim.style.transform = `translate3d(${offset}px,0,0)`;
+ scrim.style.opacity = `${offset / width}`;
+ };
+ const clearMotion = () => {
+ cancelAnimationFrame(frame);
+ clearTimeout(timer);
+ frame = timer = 0;
+ drag = null;
+ expectedOpen = null;
+ delete shell.dataset.sidebarMotion;
+ page.style.removeProperty("transform");
+ sidebar.style.removeProperty("transform");
+ scrim.style.removeProperty("transform");
+ scrim.style.removeProperty("opacity");
+ };
+ const syncFocus = () => {
+ const active = document.activeElement;
+ if (media.matches && openRef.current && active instanceof HTMLElement
+ && page.contains(active)) active.blur();
+ // The controller lives with the lazy sidebar; only it owns pane inertness.
+ page.inert = media.matches && openRef.current;
+ };
+ const finishLater = () => {
+ // transitionend normally finishes first; this covers reduced motion and
+ // missing/canceled events without leaving an untouchable closing layer.
+ timer = window.setTimeout(clearMotion,
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 320);
+ };
+ const settle = (next: boolean, gesture: Drag, commit = true) => {
+ cancelAnimationFrame(frame);
+ clearTimeout(timer);
+ // Flush the final finger position before enabling the settle transition.
+ write(gesture.offset, gesture.width);
+ void getComputedStyle(page).transform;
+ drag = null;
+ frame = 0;
+ expectedOpen = commit ? next : null;
+ settleOffset = next ? gesture.width : 0;
+ shell.dataset.sidebarMotion = "settling";
+ write(settleOffset, gesture.width);
+ if (commit) onOpenChange(next);
+ finishLater();
+ };
+ syncRef.current = () => {
+ if (expectedOpen === openRef.current) expectedOpen = null;
+ else {
+ clearMotion();
+ // Button/selection changes also keep effects alive through the closing
+ // transition, so the next swipe can grab it just like a gesture settle.
+ if (media.matches) {
+ settleOffset = openRef.current ? sidebar.getBoundingClientRect().width : 0;
+ shell.dataset.sidebarMotion = "settling";
+ finishLater();
+ }
+ }
+ syncFocus();
+ };
+ const abandon = () => {
+ if (drag?.interrupted) settle(drag.wasOpen, drag, false);
+ else drag = null;
+ };
+ const cancel = () => {
+ if (drag?.claimed) settle(drag.wasOpen, drag);
+ else abandon();
+ };
+ const locked = (target: Element) => {
+ if (target.closest(LOCKED_TARGET) || window.getSelection()?.isCollapsed === false) return true;
+ // Preserve nested horizontal scrollers, including account filters/tables.
+ for (let node: Element | null = target; node && node !== shell; node = node.parentElement) {
+ if (node.scrollWidth > node.clientWidth + 2
+ && /auto|scroll/.test(getComputedStyle(node).overflowX)) return true;
+ }
+ return false;
+ };
+ const start = (event: TouchEvent) => {
+ if (event.touches.length !== 1) { cancel(); return; }
+ suppressClickUntil = 0;
+ if (!media.matches || !(event.target instanceof Element)
+ || (event.target !== shell && !event.target.closest(".pane, .sessions, .scrim-side"))
+ || locked(event.target)) return;
+ const touch = event.touches[0];
+ const width = sidebar.getBoundingClientRect().width;
+ if (!width) return;
+ const base = Math.max(0, Math.min(width,
+ page.getBoundingClientRect().left - shell.getBoundingClientRect().left));
+ const interrupted = shell.dataset.sidebarMotion === "settling";
+ // A new press owns the previous animation immediately, even before its
+ // direction is known. Its old cleanup must never discard this touch.
+ cancelAnimationFrame(frame);
+ clearTimeout(timer);
+ frame = timer = 0;
+ if (interrupted) {
+ shell.dataset.sidebarMotion = "holding";
+ write(base, width);
+ }
+ drag = { id: touch.identifier, x: touch.clientX, y: touch.clientY,
+ base, offset: base, width, wasOpen: openRef.current, claimed: false,
+ interrupted,
+ samples: [{ x: touch.clientX, time: event.timeStamp }] };
+ };
+ const move = (event: TouchEvent) => {
+ if (!drag) return;
+ if (event.touches.length !== 1) { cancel(); return; }
+ const touch = event.touches[0];
+ if (touch.identifier !== drag.id) return;
+ const dx = touch.clientX - drag.x;
+ if (!drag.claimed) {
+ const intent = sidebarDragIntent(dx, touch.clientY - drag.y);
+ if (intent === "pending") return;
+ if (intent === "vertical" || (!drag.wasOpen && drag.base === 0 && dx < 0)) {
+ abandon(); return;
+ }
+ // Once native scrolling owns the touch, do not turn it into navigation.
+ if (!event.cancelable || window.getSelection()?.isCollapsed === false) { abandon(); return; }
+ clearTimeout(timer);
+ timer = 0;
+ shell.dataset.sidebarMotion = "dragging";
+ drag.claimed = true;
+ }
+ if (event.cancelable) event.preventDefault();
+ drag.offset = Math.max(0, Math.min(drag.width, drag.base + dx));
+ drag.samples = drag.samples.filter(sample => event.timeStamp - sample.time <= 100);
+ drag.samples.push({ x: touch.clientX, time: event.timeStamp });
+ if (!frame) frame = requestAnimationFrame(() => {
+ frame = 0;
+ if (drag?.claimed) write(drag.offset, drag.width);
+ });
+ };
+ const end = (event: TouchEvent) => {
+ if (!drag) return;
+ const gesture = drag;
+ const touch = Array.from(event.changedTouches).find(value => value.identifier === gesture.id);
+ if (!touch) return;
+ if (!gesture.claimed) { abandon(); return; }
+ if (event.cancelable) event.preventDefault();
+ suppressClickUntil = performance.now() + 400;
+ gesture.offset = Math.max(0, Math.min(gesture.width, gesture.base + touch.clientX - gesture.x));
+ // Touchend can be delivered a frame or two after the last move. Repeating
+ // its unchanged coordinate must not dilute an otherwise deliberate flick.
+ // A genuine hold beyond the recent-motion window still settles by distance.
+ const last = gesture.samples.at(-1);
+ const final = last?.x === touch.clientX ? last : { x: touch.clientX, time: event.timeStamp };
+ const sample = event.timeStamp - final.time <= 100
+ ? gesture.samples.find(value => final.time - value.time <= 100 && value.time < final.time)
+ : undefined;
+ const elapsed = sample ? final.time - sample.time : 0;
+ const velocity = sample && elapsed > 0 ? (final.x - sample.x) / elapsed : 0;
+ const next = sidebarReleaseOpen(gesture.offset, gesture.width, velocity);
+ if (next && !gesture.wasOpen) sidebarOpenFeedback();
+ settle(next, gesture);
+ };
+ const click = (event: MouseEvent) => {
+ if (event.detail && performance.now() < suppressClickUntil) {
+ event.preventDefault(); event.stopImmediatePropagation(); suppressClickUntil = 0;
+ }
+ };
+ const pointerDown = (event: PointerEvent) => {
+ // A separate mouse/pen press is not the compatibility click of a touch
+ // drag. Genuine new touch presses reset suppression in start() above.
+ if (event.pointerType !== "touch") suppressClickUntil = 0;
+ };
+ const transitionEnd = (event: TransitionEvent) => {
+ if (event.target === page && event.propertyName === "transform"
+ && !drag && shell.dataset.sidebarMotion === "settling") {
+ // An earlier transition's queued event must not finish its replacement.
+ const transform = getComputedStyle(page).transform;
+ const offset = transform === "none" ? 0 : new DOMMatrixReadOnly(transform).m41;
+ if (Math.abs(offset - settleOffset) < 1) clearMotion();
+ }
+ };
+ const resize = () => {
+ syncFocus();
+ // Keyboard/visual viewport height changes must not reset a horizontal drag.
+ if (viewportWidth !== window.innerWidth) {
+ viewportWidth = window.innerWidth;
+ clearMotion();
+ }
+ };
+ syncFocus();
+ shell.addEventListener("touchstart", start, { passive: true });
+ shell.addEventListener("touchmove", move, { passive: false });
+ shell.addEventListener("touchend", end, { passive: false });
+ shell.addEventListener("touchcancel", cancel);
+ shell.addEventListener("click", click, true);
+ shell.addEventListener("pointerdown", pointerDown, true);
+ shell.addEventListener("transitionend", transitionEnd);
+ window.addEventListener("resize", resize);
+ media.addEventListener("change", resize);
+ return () => {
+ clearMotion(); syncRef.current = null;
+ page.inert = false;
+ shell.removeEventListener("touchstart", start);
+ shell.removeEventListener("touchmove", move);
+ shell.removeEventListener("touchend", end);
+ shell.removeEventListener("touchcancel", cancel);
+ shell.removeEventListener("click", click, true);
+ shell.removeEventListener("pointerdown", pointerDown, true);
+ shell.removeEventListener("transitionend", transitionEnd);
+ window.removeEventListener("resize", resize);
+ media.removeEventListener("change", resize);
+ };
+ }, [sidebar, onOpenChange]);
+ return ref;
+}
diff --git a/web/src/use-panel-width.ts b/web/src/use-panel-width.ts
new file mode 100644
index 00000000..81da3eb4
--- /dev/null
+++ b/web/src/use-panel-width.ts
@@ -0,0 +1,28 @@
+import { useLayoutEffect } from "react";
+import { clampPanelWidth } from "./responsive-layout";
+
+// Retain the historical preview-panel preference for every right-side panel.
+export const PANEL_WIDTH_KEY = "cc_remote_artifact_panel_width";
+export const DESKTOP_PANEL_QUERY = "(min-width: 981px)";
+
+/** Restore before the shell paints, including before a lazy panel is loaded. */
+export function usePanelWidthPreference() {
+ useLayoutEffect(() => {
+ const root = document.documentElement;
+ let saved = Number.NaN;
+ try { saved = Number.parseFloat(localStorage.getItem(PANEL_WIDTH_KEY) || ""); }
+ catch { /* A blocked preference store keeps the CSS default. */ }
+ const fit = (width: number) => {
+ if (window.matchMedia(DESKTOP_PANEL_QUERY).matches && Number.isFinite(width)) {
+ root.style.setProperty("--panel-w", `${clampPanelWidth(width, window.innerWidth)}px`);
+ }
+ };
+ fit(saved);
+ const resize = () => {
+ const current = Number.parseFloat(root.style.getPropertyValue("--panel-w"));
+ fit(Number.isFinite(current) ? current : saved);
+ };
+ window.addEventListener("resize", resize);
+ return () => window.removeEventListener("resize", resize);
+ }, []);
+}
diff --git a/web/tests/claude-background-process.test.ts b/web/tests/claude-background-process.test.ts
index 2406d5da..9abd032b 100644
--- a/web/tests/claude-background-process.test.ts
+++ b/web/tests/claude-background-process.test.ts
@@ -333,6 +333,84 @@ try {
send({ type: "state", state: "idle", seq: 21 });
assert.equal(continuationState.runtimes[sid].liveOwner, null);
assert.equal(isComposerBusy(continuationState.runtimes[sid].state), false);
+
+ // A resident Claude can continue a settled turn after the browser has only
+ // loaded its lightweight summary. A subsequent summary omits the Bash item,
+ // but must not move that already-observed work behind its own final answer.
+ const summarySid = "claude-summary-continuation";
+ const originalAnswer = {
+ kind: "text", message_id: "original-answer", channel: "final",
+ text: "Waiting for CI.", done: true, startedTs: 2_000,
+ };
+ const continuedAnswer = {
+ kind: "text", message_id: "continued-answer", channel: "final",
+ text: "CI passed.", done: true, background: true,
+ startedTs: 30_000, doneTs: 33_000,
+ };
+ let summaryState = {
+ ...initialState, focusedSid: summarySid,
+ sessions: [{ session_id: summarySid, engine: "claude" }],
+ runtimes: { [summarySid]: createRuntime() },
+ };
+ const summarySend = (body: Record) => {
+ summaryState = reduce(summaryState, { type: "event", event: event({
+ sid: summarySid, ...body,
+ }) });
+ };
+ const refreshSummary = (continued: boolean) => summarySend({
+ type: "history", session_id: summarySid, revision: "summary-r1",
+ detail: "summary", in_progress: false, has_more: true, events: [],
+ turns: [{ id: "summary-parent", prompt: "check CI", done: true,
+ ts: 1_000, doneTs: 3_000, processDetailState: "present",
+ blocks: continued ? [originalAnswer, continuedAnswer] : [originalAnswer] }],
+ });
+ refreshSummary(false);
+ summarySend({ type: "state", state: "running", msg_id: "summary-parent", continuation: true });
+ summarySend({ type: "assistant_msg_start", turn_id: "summary-parent",
+ message_id: "check-message", channel: "commentary", background: true, ts: 28 });
+ summarySend({ type: "tool_use", turn_id: "summary-parent", message_id: "check-message",
+ tool_use_id: "check-ci", tool: "Bash", input: { command: "check CI" }, background: true, ts: 28 });
+ summarySend({ type: "assistant_msg_end", turn_id: "summary-parent",
+ message_id: "check-message", channel: "commentary", background: true, ts: 28 });
+ summarySend({ type: "tool_result", turn_id: "summary-parent", tool_use_id: "check-ci",
+ content: "passed", is_error: false, background: true, ts: 29 });
+ summarySend({ type: "assistant_msg_start", turn_id: "summary-parent",
+ message_id: "continued-answer", channel: "final", background: true, ts: 30 });
+ summarySend({ type: "delta", turn_id: "summary-parent", message_id: "continued-answer",
+ channel: "final", text: "CI passed.", background: true, ts: 31 });
+ summarySend({ type: "assistant_msg_end", turn_id: "summary-parent",
+ message_id: "continued-answer", channel: "final", background: true, ts: 33 });
+ summarySend({ type: "state", state: "idle", ts: 33 });
+ const summaryMarkup = () => renderToStaticMarkup(createElement(ChatView, {
+ sid: summarySid, engine: "claude", turns: summaryState.runtimes[summarySid].turns,
+ }));
+ assert.equal(summaryMarkup().split("Claude 继续处理").length - 1, 1);
+ refreshSummary(true);
+ assert.equal(summaryMarkup().split("Claude 继续处理").length - 1, 1,
+ "a summary refresh keeps the observed tool before its continuation answer");
+ assert.match(summaryMarkup(), /Claude 继续处理[\s\S]*已处理[\s\S]*CI passed\./);
+ assert.equal(summaryState.runtimes[summarySid].turns[0].done, true);
+ assert.equal(summaryState.runtimes[summarySid].liveOwner, null,
+ "repairing presentation order does not reopen the completed parent");
+
+ summarySend({ type: "state", state: "running", msg_id: "summary-parent", continuation: true });
+ summarySend({ type: "assistant_msg_start", turn_id: "summary-parent",
+ message_id: "next-thought", channel: "thinking", background: true, ts: 40 });
+ assert.equal(summaryMarkup().split("Claude 继续处理").length - 1, 2,
+ "a genuinely new continuation still appears immediately, even before text arrives");
+ summarySend({ type: "delta", turn_id: "summary-parent", message_id: "next-thought",
+ channel: "thinking", text: "Checking the next task.", background: true, ts: 41 });
+ summarySend({ type: "assistant_msg_end", turn_id: "summary-parent",
+ message_id: "next-thought", channel: "thinking", background: true, ts: 42 });
+ summarySend({ type: "assistant_msg_start", turn_id: "summary-parent",
+ message_id: "next-answer", channel: "final", background: true, ts: 43 });
+ summarySend({ type: "delta", turn_id: "summary-parent", message_id: "next-answer",
+ channel: "final", text: "Next task passed.", background: true, ts: 44 });
+ summarySend({ type: "assistant_msg_end", turn_id: "summary-parent",
+ message_id: "next-answer", channel: "final", background: true, ts: 45 });
+ summarySend({ type: "state", state: "idle", ts: 45 });
+ assert.equal(summaryMarkup().split("Claude 继续处理").length - 1, 2);
+ assert.match(summaryMarkup(), /CI passed\.[\s\S]*Claude 继续处理[\s\S]*Next task passed\./);
} finally {
await harness.close();
}
diff --git a/web/tests/fixtures/mobile-sidebar.tsx b/web/tests/fixtures/mobile-sidebar.tsx
new file mode 100644
index 00000000..b1eb3fd9
--- /dev/null
+++ b/web/tests/fixtures/mobile-sidebar.tsx
@@ -0,0 +1,64 @@
+import { StrictMode, useEffect, useState } from "react";
+import { SessionsSidebar } from "../../src/components/SessionsSidebar";
+import { ChatView } from "../../src/components/ChatView";
+import { useMobileViewport } from "../../src/use-mobile-viewport";
+import { SidebarToggle } from "../../src/components/SidebarToggle";
+import type { SessionInfo } from "../../src/protocol";
+import type { Turn } from "../../src/reducer";
+
+const noop = () => {};
+const sessions: SessionInfo[] = Array.from({ length: 28 }, (_, index) => ({
+ session_id: `sidebar-${index}`, summary: ["cc-remote", "手机侧栏动画", "模型与会话管理"][index % 3],
+ cwd: `/workspace/project-${Math.floor(index / 4)}`, state: "idle", engine: "codex", space: "code",
+}));
+
+function SidebarScene() {
+ const [open, setOpen] = useState(false);
+ const [revision, setRevision] = useState(0);
+ const [selected, setSelected] = useState("sidebar-0");
+ useMobileViewport();
+ useEffect(() => {
+ document.documentElement.dataset.theme = "dark";
+ document.documentElement.dataset.engine = "codex";
+ const update = () => setRevision(value => value + 1);
+ window.addEventListener("sidebar-fixture-stream", update);
+ return () => window.removeEventListener("sidebar-fixture-stream", update);
+ }, []);
+ const turns: Turn[] = [{ id: "sidebar-chat", prompt: "侧边栏应该是什么效果?", done: true,
+ blocks: [{ kind: "text", message_id: "sidebar-answer", done: true, channel: "final",
+ text: "聊天页面跟随手指整体平移,侧栏从左侧露出来。\n\n松开后自然停靠,也可以向左滑动收回。" }],
+ }, ...Array.from({ length: 12 }, (_, index): Turn => ({
+ id: `history-${index}`, prompt: `历史消息 ${index + 1}`, done: true,
+ blocks: [{ kind: "text", message_id: `answer-${index}`, done: true, channel: "final",
+ text: "保留聊天内容的原始宽度,标题、消息和输入框一起移动。上下滚动仍然浏览历史。" }],
+ }))];
+ return
+
setOpen(false)}
+ engine="codex" space="code" profileScopeKey="sidebar-fixture" sessions={sessions}
+ activeSessionId={selected} onSelect={id => { setSelected(id); setOpen(false); }}
+ onNew={noop} onNewInDir={noop} onSpaceChange={noop} onRename={noop} onArchive={noop}
+ onPin={noop} onDelete={noop} onForkWorktree={noop} onMigrate={noop} />
+
+
+
+ 面板手势保留
+
+
+
+
+
Full Access · GPT-6.1 Sol
+
+
+ ;
+}
+
+export function MobileSidebarFixture() {
+ return ;
+}
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
+
+ ({ title: relatedSessionTitle(resolveRelatedSession(id, "nyx@receiver", sessions), id),
+ available: !!resolveRelatedSession(id, "nyx@receiver", sessions) })}
+ onOpenSession={(id) => setPreview(resolveRelatedSession(id, "nyx@receiver", sessions) ?? null)} />
+
+ {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..5a2ea19a 100644
--- a/web/tests/history-browser.fixture.tsx
+++ b/web/tests/history-browser.fixture.tsx
@@ -1,3 +1,5 @@
+import { SessionMessagesFixture } from "./fixtures/session-messages";
+import { MobileSidebarFixture } from "./fixtures/mobile-sidebar";
import {
useCallback,
useEffect,
@@ -3082,7 +3084,9 @@ function CodexFileCitationFixture() {
const rootParams = new URLSearchParams(window.location.search);
createRoot(document.getElementById("root")!).render(
- rootParams.has("turn-usage") ?
+ rootParams.has("mobile-sidebar") ?
+ : 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..b50b06e5 100644
--- a/web/tests/history-browser.spec.ts
+++ b/web/tests/history-browser.spec.ts
@@ -210,7 +210,9 @@ async function mockRightPanelRelay(
localStorage.setItem("cc_remote_engine", engine);
localStorage.setItem("cc_remote_machine", "layout-machine");
// Preserve a user's resize preference while opening/closing the slot.
- localStorage.setItem("cc_remote_artifact_panel_width", "520");
+ if (localStorage.getItem("cc_remote_artifact_panel_width") === null) {
+ localStorage.setItem("cc_remote_artifact_panel_width", "520");
+ }
if (sessionStorage.getItem("cc-remote:btw-panel-scopes-v1") === null) {
sessionStorage.setItem("cc-remote:btw-panel-scopes-v1", JSON.stringify(visible
? [JSON.stringify(["layout-machine", "code", engine, "layout-parent"])] : []));
@@ -2903,14 +2905,14 @@ for (const sideChat of [false, true]) {
}
async function expectRightPanelSpace(
- page: import("@playwright/test").Page, open: boolean, desktop = true,
+ page: import("@playwright/test").Page, open: boolean, desktop = true, panelWidth = 520,
) {
const shell = page.locator(".shell");
if (open) await expect(shell).toHaveClass(/\bpanel-open\b/);
else await expect(shell).not.toHaveClass(/\bpanel-open\b/);
// CSS transitions and persisted resize width must settle to actual geometry.
await expect(page.locator(".pane")).toHaveCSS(
- "padding-right", open && desktop ? "548px" : "0px");
+ "padding-right", open && desktop ? `${panelWidth + 28}px` : "0px");
}
const VIEWER_LINK_SITE = { id: "robot", label: "机器人结构", machine_id: "layout-machine",
@@ -3222,6 +3224,67 @@ test("side chat scope keeps late fork creation and sibling visibility with the o
expect(relay.commands.filter((c) => c.type === "close_btw")).toHaveLength(0);
});
+test("right panel layout avoids repeated paragraph reflow while side chat opens and closes", async ({ page }, testInfo) => {
+ await page.setViewportSize({ width: 1160, height: 881 });
+ await mockRightPanelRelay(page, { seedTurns: [{
+ id: "layout-reflow", prompt: "查看长段落", done: true,
+ blocks: [{ kind: "text", message_id: "layout-reflow-answer", channel: "final", done: true,
+ text: Array.from({ length: 14 }, (_, i) => `${i + 1}. ${"打开侧边聊天时,正文应稳定地显示在分配好的区域里,保留当前阅读位置。".repeat(5)}`).join("\n\n") }],
+ }] });
+ await page.goto("/");
+ const thread = page.locator(".pane .thread");
+ await expect(thread.locator(".prose")).toBeVisible();
+ await page.evaluate(() => document.fonts.ready);
+ await page.waitForTimeout(500);
+ for (const open of [true, false, true]) {
+ const frames = await page.evaluate(async () => {
+ const thread = document.querySelector(".pane .thread")!;
+ const prose = thread.querySelector(".prose")!;
+ const frames: { width: number; height: number; top: number; scroll: number }[] = [];
+ const record = () => {
+ const rect = prose.getBoundingClientRect();
+ frames.push({ width: Math.round(rect.width), height: Math.round(rect.height),
+ top: Math.round(rect.top), scroll: thread.scrollTop });
+ };
+ record();
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "K", ctrlKey: true, shiftKey: true, bubbles: true }));
+ const start = performance.now();
+ await new Promise(resolve => {
+ const frame = () => {
+ record();
+ if (performance.now() - start >= 650) resolve();
+ else requestAnimationFrame(frame);
+ };
+ requestAnimationFrame(frame);
+ });
+ return frames;
+ });
+ await testInfo.attach(`side-chat-${open ? "open" : "close"}-${Date.now()}.json`, {
+ body: JSON.stringify(frames), contentType: "application/json",
+ });
+ await expectRightPanelSpace(page, open);
+ if (open) {
+ expect(await page.locator(".btw-panel").evaluate(node => node.getBoundingClientRect().width)).toBe(520);
+ }
+ // Text gets one final layout; only the floating panel animates into place.
+ expect(new Set(frames.map(frame => frame.width)).size).toBeLessThanOrEqual(2);
+ expect(new Set(frames.map(frame => frame.height)).size).toBeLessThanOrEqual(2);
+ }
+ // A deliberate resize remains responsive and survives hide/show and reload.
+ const handle = page.getByRole("button", { name: "调整 BTW 面板宽度", exact: true });
+ await handle.focus();
+ await handle.press("ArrowLeft");
+ await expectRightPanelSpace(page, true, true, 544);
+ expect(await page.locator(".btw-panel").evaluate(node => node.getBoundingClientRect().width)).toBe(544);
+ await page.keyboard.press("Control+Shift+k");
+ await expectRightPanelSpace(page, false);
+ await page.keyboard.press("Control+Shift+k");
+ expect(await page.locator(".btw-panel").evaluate(node => node.getBoundingClientRect().width)).toBe(544);
+ await page.reload();
+ await expectRightPanelSpace(page, true, true, 544);
+ expect(await page.locator(".btw-panel").evaluate(node => node.getBoundingClientRect().width)).toBe(544);
+});
+
test("right panel layout releases collapsed and reload-restored side chat space", async ({
page,
}) => {
@@ -10993,3 +11056,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/mobile-sidebar.spec.ts b/web/tests/mobile-sidebar.spec.ts
new file mode 100644
index 00000000..d1b6ba5c
--- /dev/null
+++ b/web/tests/mobile-sidebar.spec.ts
@@ -0,0 +1,449 @@
+import { expect, test, type Page } from "@playwright/test";
+
+async function touch(page: Page, type: string, x: number, y: number, time: number,
+ selector = ".thread", count = 1) {
+ return page.locator(selector).evaluate((target, args) => {
+ const point = { identifier: 1, target, clientX: args.x, clientY: args.y };
+ const touches = args.type === "touchend" || args.type === "touchcancel" ? []
+ : args.count === 2 ? [point, { identifier: 2, target,
+ clientX: args.x + 30, clientY: args.y + 30 }] : [point];
+ // WebKit does not expose a constructible Touch; use the native event shape.
+ const event = new Event(args.type, { bubbles: true, cancelable: true });
+ Object.defineProperties(event, {
+ touches: { value: touches }, targetTouches: { value: touches },
+ changedTouches: { value: [point] }, timeStamp: { value: args.time },
+ });
+ target.dispatchEvent(event);
+ return event.defaultPrevented;
+ }, { type, x, y, time, count });
+}
+
+async function bounds(page: Page) {
+ return page.evaluate(() => {
+ const rect = (selector: string) => {
+ const r = document.querySelector(selector)!.getBoundingClientRect();
+ return { x: r.x, width: r.width };
+ };
+ return { pane: rect(".pane"), sidebar: rect(".sessions"),
+ header: rect(".c-head"), composer: rect(".composer"),
+ scroll: document.querySelector(".thread")!.scrollTop };
+ });
+}
+
+async function settled(page: Page, open: boolean) {
+ const shell = page.locator(".shell");
+ if (open) await expect(shell).toHaveClass(/sidebar-open/);
+ else await expect(shell).not.toHaveClass(/sidebar-open/);
+ await expect(shell).not.toHaveAttribute("data-sidebar-motion");
+ await expect.poll(async () => {
+ const b = await bounds(page);
+ return Math.abs(b.pane.x - (open ? b.sidebar.width : 0));
+ }).toBeLessThan(1);
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/tests/history-browser.html?mobile-sidebar=1");
+ await expect(page.locator(".thread")).toBeVisible();
+ // ChatView first restores its latest-page position after font/layout frames.
+ await page.waitForTimeout(500);
+ await page.locator(".thread").dispatchEvent("wheel", { deltaY: -240 });
+ await page.locator(".thread").evaluate(node => { node.scrollTop = 240; });
+ await expect.poll(() => page.locator(".thread").evaluate(node => node.scrollTop)).toBe(240);
+});
+
+test("mobile sidebar tracks the finger without reflow and survives streaming renders", async ({ page }) => {
+ const before = await bounds(page);
+ await touch(page, "touchstart", 60, 280, 1000);
+ expect(await touch(page, "touchmove", 170, 283, 1200)).toBe(true);
+ await expect.poll(async () => (await bounds(page)).pane.x).toBe(110);
+ const during = await bounds(page);
+ expect(during.pane.width).toBe(before.pane.width);
+ expect(during.header.x - before.header.x).toBe(110);
+ expect(during.composer.x - before.composer.x).toBe(110);
+ expect(during.scroll).toBe(before.scroll);
+ expect(during.sidebar.x).toBeLessThan(0);
+ // The shell class changes just as App rerenders during streaming/panel changes.
+ await page.evaluate(() => window.dispatchEvent(new Event("sidebar-fixture-stream")));
+ await expect(page.locator(".shell")).toHaveAttribute("data-revision", "1");
+ await expect(page.locator(".shell")).toHaveAttribute("data-sidebar-motion", "dragging");
+ expect((await bounds(page)).pane.x).toBe(110);
+ await touch(page, "touchmove", 280, 283, 1400);
+ await touch(page, "touchend", 280, 283, 1600);
+ await settled(page, true);
+ expect((await bounds(page)).pane.width).toBeCloseTo(390, 2);
+ expect(await page.locator(".pane").evaluate(node => (node as HTMLElement).inert)).toBe(true);
+ await page.screenshot({ path: test.info().outputPath("sidebar-open.png") });
+});
+
+test("mobile sidebar settles short drags, directional flicks and interrupted gestures", async ({ page }) => {
+ await touch(page, "touchstart", 50, 280, 1000);
+ await touch(page, "touchmove", 140, 280, 1200);
+ await touch(page, "touchend", 140, 280, 1400);
+ await settled(page, false);
+ // A short fast flick opens even before halfway.
+ await touch(page, "touchstart", 50, 280, 2000);
+ await touch(page, "touchmove", 80, 280, 2020);
+ await touch(page, "touchend", 110, 280, 2040);
+ await settled(page, true);
+ // Reverse flick from the drawer closes, preserving the normal short-drag case.
+ await touch(page, "touchstart", 250, 280, 3000, ".sessions");
+ await touch(page, "touchmove", 220, 280, 3020, ".sessions");
+ await touch(page, "touchend", 190, 280, 3040, ".sessions");
+ await settled(page, false);
+ for (const cancelType of ["touchcancel", "multitouch"]) {
+ await touch(page, "touchstart", 50, 280, 4000);
+ await touch(page, "touchmove", 250, 280, 4200);
+ if (cancelType === "touchcancel") await touch(page, "touchcancel", 250, 280, 4250);
+ else await touch(page, "touchstart", 250, 280, 4250, ".thread", 2);
+ await settled(page, false);
+ }
+});
+
+test("mobile sidebar reopens from the middle of the chat after a reverse swipe", async ({ page }) => {
+ await page.getByTestId("sidebar-toggle").click();
+ await settled(page, true);
+ await touch(page, "touchstart", 280, 280, 1000, ".sessions");
+ await touch(page, "touchmove", 210, 280, 1020, ".sessions");
+ await touch(page, "touchend", 180, 280, 1040, ".sessions");
+ await settled(page, false);
+ // The next opening swipe starts where the previous closing swipe finished.
+ await touch(page, "touchstart", 180, 280, 1100);
+ expect(await touch(page, "touchmove", 240, 280, 1120)).toBe(true);
+ await touch(page, "touchend", 290, 280, 1140);
+ await settled(page, true);
+});
+
+test("mobile sidebar keeps a new touch when the previous settle would finish", async ({ page }) => {
+ await touch(page, "touchstart", 50, 280, 1000);
+ await touch(page, "touchmove", 100, 280, 1200);
+ await touch(page, "touchend", 100, 280, 1400);
+ // Press during the closing animation; hold across the previous cleanup timer.
+ await touch(page, "touchstart", 55, 280, 1500);
+ await page.waitForTimeout(420);
+ expect(await touch(page, "touchmove", 180, 280, 1520)).toBe(true);
+ await touch(page, "touchend", 240, 280, 1540);
+ await settled(page, true);
+});
+
+test("mobile sidebar keeps recent flick velocity when touchend repeats the last coordinate", async ({ page }) => {
+ await touch(page, "touchstart", 180, 280, 1000);
+ await touch(page, "touchmove", 205, 280, 1040);
+ await touch(page, "touchmove", 230, 280, 1080);
+ await touch(page, "touchend", 230, 280, 1150);
+ await settled(page, true);
+ await touch(page, "touchstart", 280, 280, 2000, ".sessions");
+ await touch(page, "touchmove", 255, 280, 2040, ".sessions");
+ await touch(page, "touchmove", 230, 280, 2080, ".sessions");
+ await touch(page, "touchend", 230, 280, 2150, ".sessions");
+ await settled(page, false);
+ // Holding after a short drag cancels its momentum; distance decides instead.
+ await touch(page, "touchstart", 180, 280, 3000);
+ await touch(page, "touchmove", 205, 280, 3040);
+ await touch(page, "touchmove", 230, 280, 3080);
+ await touch(page, "touchend", 230, 280, 3300);
+ await settled(page, false);
+});
+
+test("mobile sidebar repeatedly reverses before settling and releases a vertical interruption", async ({ page }) => {
+ for (let index = 0; index < 12; index++) {
+ const opening = index % 2 === 0;
+ const start = opening ? 180 : 280;
+ const end = opening ? 280 : 180;
+ const time = 1000 + index * 100;
+ const selector = opening ? ".thread" : ".sessions";
+ await touch(page, "touchstart", start, 280, time, selector);
+ expect(await touch(page, "touchmove", (start + end) / 2, 280, time + 20, selector)).toBe(true);
+ await touch(page, "touchend", end, 280, time + 40, selector);
+ await expect(page.locator(".shell")).toHaveClass(opening ? /sidebar-open/ : /^shell(?: fixture-streaming)?$/);
+ await page.waitForTimeout(20);
+ }
+ await settled(page, false);
+ await touch(page, "touchstart", 180, 280, 3000);
+ await touch(page, "touchmove", 250, 280, 3020);
+ await touch(page, "touchend", 280, 280, 3040);
+ await touch(page, "touchstart", 200, 280, 3100, ".sessions");
+ expect(await touch(page, "touchmove", 200, 320, 3120, ".sessions")).toBe(false);
+ await touch(page, "touchend", 200, 320, 3140, ".sessions");
+ await settled(page, true);
+});
+
+test("mobile sidebar native touches reverse across the exposed closing surface", async ({ page, browserName }) => {
+ test.skip(browserName !== "chromium", "Trusted swipe injection uses Chromium CDP; WebKit checks the same rapid state transitions separately.");
+ const input = await page.context().newCDPSession(page);
+ await input.send("Emulation.setTouchEmulationEnabled", { enabled: true });
+ // Keep the source cadence explicit: waiting for each CDP acknowledgement can
+ // otherwise turn a 16ms flick into a slow drag on a busy test runner.
+ let timestamp = Date.now() / 1000;
+ const send = (type: "touchStart" | "touchMove" | "touchEnd", x: number) =>
+ input.send("Input.dispatchTouchEvent", { type, timestamp: timestamp += .016,
+ touchPoints: type === "touchEnd" ? [] : [{ x, y: 300, id: 1 }] });
+ for (let index = 0; index < 10; index++) {
+ const opening = index % 2 === 0;
+ const start = opening ? 180 : 280;
+ const end = opening ? 280 : 180;
+ await send("touchStart", start);
+ for (let step = 1; step <= 4; step++) {
+ await send("touchMove", start + (end - start) * step / 4);
+ await page.waitForTimeout(16);
+ }
+ await send("touchEnd", end);
+ if (opening) await expect(page.locator(".shell")).toHaveClass(/sidebar-open/);
+ else await expect(page.locator(".shell")).not.toHaveClass(/sidebar-open/);
+ await page.waitForTimeout(16);
+ }
+ // While closing, the exposed sidebar is inert. Hit testing reaches the shell
+ // itself; a swipe from this surface must still be able to reverse the drawer.
+ await page.locator(".shell").evaluate(shell => {
+ shell.addEventListener("touchstart", event => {
+ shell.setAttribute("data-native-touch-target", event.target === shell ? "shell" : "child");
+ }, { once: true });
+ });
+ await send("touchStart", 20);
+ await expect(page.locator(".shell")).toHaveAttribute("data-native-touch-target", "shell");
+ await send("touchMove", 70);
+ await send("touchEnd", 120);
+ await expect(page.locator(".shell")).toHaveClass(/sidebar-open/);
+ await send("touchStart", 280);
+ await send("touchMove", 230);
+ await send("touchEnd", 180);
+ await settled(page, false);
+ await input.detach();
+});
+
+test("mobile sidebar preserves vertical scrolling, horizontal scrollers, editors and selection", async ({ page }) => {
+ await touch(page, "touchstart", 50, 280, 1000);
+ expect(await touch(page, "touchmove", 60, 310, 1020)).toBe(false);
+ expect(await touch(page, "touchmove", 270, 315, 1200)).toBe(false);
+ await touch(page, "touchend", 280, 315, 1400);
+ await settled(page, false);
+ for (const selector of ['textarea', '[data-testid="sidebar-locked"]', '[data-testid="sidebar-horizontal"]']) {
+ await touch(page, "touchstart", 50, 280, 2000, selector);
+ expect(await touch(page, "touchmove", 280, 280, 2200, selector)).toBe(false);
+ await touch(page, "touchend", 280, 280, 2400, selector);
+ await settled(page, false);
+ }
+ await page.locator(".thread").evaluate(node => {
+ const range = document.createRange();
+ range.selectNodeContents(node);
+ window.getSelection()!.removeAllRanges(); window.getSelection()!.addRange(range);
+ });
+ await touch(page, "touchstart", 50, 280, 3000);
+ expect(await touch(page, "touchmove", 280, 280, 3200)).toBe(false);
+ await touch(page, "touchend", 280, 280, 3400);
+ await settled(page, false);
+ expect(await page.evaluate(() => window.getSelection()!.isCollapsed)).toBe(false);
+});
+
+test("mobile sidebar menu and exposed chat strip work, while drag does not select a session", async ({ page }) => {
+ await page.getByTestId("sidebar-toggle").click();
+ await settled(page, true);
+ const card = page.locator(".scard").nth(1);
+ await card.evaluate(node => { node.setAttribute("data-testid", "drag-card"); });
+ await touch(page, "touchstart", 200, 320, 1000, '[data-testid="drag-card"]');
+ await touch(page, "touchmove", 175, 320, 1200, '[data-testid="drag-card"]');
+ await touch(page, "touchend", 175, 320, 1400, '[data-testid="drag-card"]');
+ await card.dispatchEvent("click", { bubbles: true, cancelable: true, detail: 1 });
+ await expect(page.locator(".shell")).toHaveAttribute("data-selected", "sidebar-0");
+ await settled(page, true);
+ // A new tap, unlike the preceding drag's synthetic click, closes immediately.
+ await touch(page, "touchstart", 365, 320, 2000, ".scrim-side");
+ await touch(page, "touchend", 365, 320, 2050, ".scrim-side");
+ await page.locator(".scrim-side").click({ position: { x: 25, y: 320 } });
+ await settled(page, false);
+ await expect.poll(() => page.locator(".pane").evaluate(node => (node as HTMLElement).inert)).toBe(false);
+ await page.getByTestId("sidebar-toggle").click();
+ await settled(page, true);
+ await card.click();
+ await settled(page, false);
+ await expect(page.locator(".shell")).toHaveAttribute("data-selected", "sidebar-1");
+});
+
+test("mobile sidebar handles keyboard height, rotation, reduced motion and desktop layout", async ({ page }) => {
+ await page.locator("textarea").focus();
+ await touch(page, "touchstart", 50, 280, 1000);
+ await touch(page, "touchmove", 190, 280, 1200);
+ await expect.poll(async () => (await bounds(page)).pane.x).toBe(140);
+ await page.setViewportSize({ width: 390, height: 520 });
+ expect((await bounds(page)).pane.x).toBe(140);
+ await touch(page, "touchmove", 280, 280, 1400);
+ await touch(page, "touchend", 280, 280, 1600);
+ await settled(page, true);
+ await expect(page.locator("textarea")).not.toBeFocused();
+ await page.setViewportSize({ width: 844, height: 390 });
+ await settled(page, true);
+ await page.getByRole("button", { name: "收起", exact: true }).click();
+ await settled(page, false);
+ expect(await page.locator(".pane").evaluate(node => getComputedStyle(node).transform)).toBe("none");
+ // A resting filter creates a containing block and breaks fixed popovers.
+ await expect.poll(() => page.locator(".pane").evaluate(node => getComputedStyle(node).filter)).toBe("none");
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await touch(page, "touchstart", 50, 280, 2000);
+ await touch(page, "touchmove", 300, 280, 2200);
+ await touch(page, "touchend", 300, 280, 2400);
+ await settled(page, true);
+ await page.setViewportSize({ width: 1200, height: 800 });
+ await expect.poll(async () => (await bounds(page)).pane.x).toBe(352);
+ expect((await bounds(page)).pane.width).toBe(848);
+ expect(await page.locator(".pane").evaluate(node => getComputedStyle(node).filter)).toBe("none");
+ await expect.poll(() => page.locator(".pane").evaluate(node => (node as HTMLElement).inert)).toBe(false);
+ await touch(page, "touchstart", 500, 280, 3000);
+ expect(await touch(page, "touchmove", 700, 280, 3200)).toBe(false);
+ await touch(page, "touchend", 700, 280, 3400);
+ await expect(page.locator(".shell")).not.toHaveAttribute("data-sidebar-motion");
+});
+
+test("mobile sidebar native touch input preserves browser scroll ownership", async ({ page, browserName }) => {
+ test.skip(browserName !== "chromium", "Native touch injection uses Chromium's CDP; WebKit covers event/layout contracts above.");
+ const input = await page.context().newCDPSession(page);
+ await input.send("Emulation.setTouchEmulationEnabled", { enabled: true });
+ const send = (type: "touchStart" | "touchMove" | "touchEnd", x: number, y: number) =>
+ input.send("Input.dispatchTouchEvent", { type,
+ touchPoints: type === "touchEnd" ? [] : [{ x, y, id: 1 }] });
+ const before = await bounds(page);
+ await send("touchStart", 55, 300);
+ for (let x = 80; x <= 280; x += 25) {
+ await send("touchMove", x, 300);
+ await page.waitForTimeout(20);
+ }
+ await expect.poll(async () => (await bounds(page)).pane.x).toBeGreaterThan(200);
+ expect((await bounds(page)).scroll).toBe(before.scroll);
+ await send("touchEnd", 280, 300);
+ await settled(page, true);
+ await page.getByRole("button", { name: "收起", exact: true }).click();
+ await settled(page, false);
+ // A vertical drag begins in the same opening region but remains native scroll.
+ await send("touchStart", 55, 500);
+ for (let y = 480; y >= 320; y -= 20) {
+ await send("touchMove", 55, y);
+ await page.waitForTimeout(20);
+ }
+ await send("touchEnd", 55, 320);
+ await expect.poll(async () => (await bounds(page)).scroll).toBeGreaterThan(before.scroll + 80);
+ await settled(page, false);
+ await input.detach();
+});
+
+test("mobile sidebar reveals one lower surface behind both rounded page corners", async ({ page }) => {
+ for (const theme of ["light", "dark"]) {
+ await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, theme);
+ await page.getByTestId("sidebar-toggle").click();
+ await settled(page, true);
+ const layers = await page.evaluate(() => {
+ const shell = document.querySelector(".shell")!;
+ const pane = document.querySelector(".pane")!;
+ const sidebar = document.querySelector(".sessions")!;
+ const scrim = document.querySelector(".scrim-side")!;
+ const rect = pane.getBoundingClientRect();
+ const cornerTarget = (y: number) => document.elementFromPoint(rect.left + 2, y) === shell;
+ return {
+ shellBg: getComputedStyle(shell).backgroundColor,
+ sidebarBg: getComputedStyle(sidebar).backgroundColor,
+ pageZ: Number(getComputedStyle(pane).zIndex), sidebarZ: Number(getComputedStyle(sidebar).zIndex),
+ top: parseFloat(getComputedStyle(pane).borderTopLeftRadius),
+ bottom: parseFloat(getComputedStyle(pane).borderBottomLeftRadius),
+ scrimBottom: getComputedStyle(scrim).borderBottomLeftRadius,
+ pageBottom: getComputedStyle(pane).borderBottomLeftRadius,
+ topCutout: cornerTarget(rect.top + 2), bottomCutout: cornerTarget(rect.bottom - 2),
+ };
+ });
+ expect(layers.shellBg).toBe(layers.sidebarBg);
+ expect(layers.pageZ).toBeGreaterThan(layers.sidebarZ);
+ expect(layers.top).toBeGreaterThanOrEqual(36);
+ expect(layers.bottom).toBe(layers.top);
+ expect(layers.scrimBottom).toBe(layers.pageBottom);
+ expect(layers.topCutout).toBe(true);
+ expect(layers.bottomCutout).toBe(true);
+ await page.screenshot({ path: test.info().outputPath(`sidebar-layered-${theme}.png`) });
+ await page.getByRole("button", { name: "收起", exact: true }).click();
+ await settled(page, false);
+ await expect.poll(() => page.locator(".pane").evaluate(node => getComputedStyle(node).borderRadius)).toBe("0px");
+ }
+});
+
+test("mobile sidebar opening feedback fires once and ignores canceled drags and desktop", async ({ page }) => {
+ await page.addInitScript(() => {
+ Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
+ Object.defineProperty(navigator, "vibrate", { configurable: true, value: (duration: number) => {
+ document.body.dataset.pulses = String(Number(document.body.dataset.pulses ?? 0) + 1);
+ document.body.dataset.pulseDuration = String(duration);
+ return true;
+ } });
+ });
+ await page.reload();
+ await expect(page.locator(".thread")).toBeVisible();
+ const count = () => page.evaluate(() => Number(document.body.dataset.pulses ?? 0));
+ expect(await count()).toBe(0);
+ await touch(page, "touchstart", 50, 280, 1000);
+ await touch(page, "touchmove", 140, 280, 1200);
+ await touch(page, "touchend", 140, 280, 1400);
+ await settled(page, false);
+ expect(await count()).toBe(0);
+ await touch(page, "touchstart", 50, 280, 2000);
+ await touch(page, "touchmove", 280, 280, 2200);
+ await touch(page, "touchcancel", 280, 280, 2400);
+ await settled(page, false);
+ expect(await count()).toBe(0);
+ await page.getByTestId("sidebar-toggle").click();
+ await settled(page, true);
+ expect(await count()).toBe(1);
+ await page.evaluate(() => window.dispatchEvent(new Event("sidebar-fixture-stream")));
+ expect(await count()).toBe(1);
+ await page.getByRole("button", { name: "收起", exact: true }).click();
+ await settled(page, false);
+ await touch(page, "touchstart", 50, 280, 3000);
+ await touch(page, "touchmove", 280, 280, 3200);
+ await touch(page, "touchend", 280, 280, 3400);
+ await settled(page, true);
+ expect(await count()).toBe(2);
+ await page.getByRole("button", { name: "收起", exact: true }).click();
+ await settled(page, false);
+ await page.setViewportSize({ width: 1200, height: 800 });
+ await page.getByTestId("sidebar-toggle").click();
+ expect(await count()).toBe(2);
+});
+
+test("mobile sidebar still opens when the browser rejects feedback", async ({ page }) => {
+ await page.addInitScript(() => {
+ Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
+ Object.defineProperty(navigator, "vibrate", { configurable: true, value: () => { throw new Error("Unavailable"); } });
+ });
+ await page.reload();
+ await expect(page.locator(".thread")).toBeVisible();
+ await page.getByTestId("sidebar-toggle").click();
+ await settled(page, true);
+});
+
+test("mobile sidebar Safari toggle uses a genuine native switch and follows swipe state", async ({ page }) => {
+ test.skip(!await page.evaluate(() => "switch" in document.createElement("input")),
+ "Native switch feedback is a WebKit capability; physical vibration still needs an iPhone.");
+ await page.evaluate(() => {
+ Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
+ Object.defineProperty(navigator, "vibrate", { configurable: true, value: undefined });
+ document.addEventListener("click", event => {
+ if (event.target instanceof HTMLInputElement && event.target.hasAttribute("switch")) {
+ document.body.dataset.nativeFeedback = JSON.stringify({
+ trusted: event.isTrusted, active: navigator.userActivation.isActive,
+ });
+ }
+ }, true);
+ });
+ await page.getByTestId("sidebar-toggle").click();
+ await settled(page, true);
+ expect(await page.evaluate(() => JSON.parse(document.body.dataset.nativeFeedback ?? "null"))).toEqual({
+ trusted: true, active: true,
+ });
+ const control = page.getByRole("switch", { name: "显示会话侧栏", includeHidden: true });
+ expect(await control.isChecked()).toBe(true);
+ await page.getByRole("button", { name: "收起", exact: true }).click();
+ await settled(page, false);
+ expect(await control.isChecked()).toBe(false);
+ await touch(page, "touchstart", 50, 280, 1000);
+ await touch(page, "touchmove", 280, 280, 1200);
+ await touch(page, "touchend", 280, 280, 1400);
+ await settled(page, true);
+ expect(await control.isChecked()).toBe(true);
+ await page.setViewportSize({ width: 1200, height: 800 });
+ await expect(page.getByTestId("sidebar-toggle")).toHaveAttribute("aria-expanded", "true");
+ await expect(page.locator('input[switch]')).toHaveCount(0);
+});
diff --git a/web/tests/reliability.test.ts b/web/tests/reliability.test.ts
index ba79e304..c2de7c0a 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";
@@ -144,7 +145,7 @@ import type {
ThreadGoal,
} from "../src/protocol.ts";
import type { Block, Turn } from "../src/reducer.ts";
-import { clampPanelWidth, resolveSidebarSwipe } from "../src/responsive-layout.ts";
+import { clampPanelWidth } from "../src/responsive-layout.ts";
import {
classifyTurnNotification,
turnNotificationBody,
@@ -1094,12 +1095,6 @@ const derivedWorkContext = workContextMetrics({
assert.equal(derivedWorkContext.sessionTokens, 194);
assert.ok(Math.abs(derivedWorkContext.sessionPercentage - 194 / 353_400 * 100) < 1e-9);
-assert.equal(resolveSidebarSwipe(12, 200, 84, 205, 390, false), "open");
-assert.equal(resolveSidebarSwipe(300, 200, 230, 205, 390, false), "close");
-assert.equal(resolveSidebarSwipe(12, 100, 78, 240, 390, false), null,
- "a mostly vertical gesture must not navigate");
-assert.equal(resolveSidebarSwipe(12, 200, 84, 205, 390, true), null,
- "an interactive vertical scroller must own its gesture");
assert.equal(clampPanelWidth(200, 1440), 360);
assert.equal(clampPanelWidth(2_000, 1440), 1_020);
assert.equal(clampPanelWidth(600, 1_000), 580);
@@ -1387,8 +1382,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");
@@ -18075,7 +18070,6 @@ for (const optimisticAction of ["set_model", "set_effort", "set_perm", "set_coll
assert.match(appSource,
/const \{[\s\S]{0,160}cwd,[\s\S]{0,160}autoCompactMode,[\s\S]{0,120}\} = state\.newChat/,
"new-session autocompact must be captured with cwd/model before the atomic create");
-assert.match(appSource, /data-lock-horizontal-swipe/);
assert.match(appSource, /surface=\{space\}/);
assert.match(appSource, /draftKey=\{focusedComposerDraftKey\}/);
assert.match(appSource, /composerDraftsRef\.current\.rekey/,
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");