diff --git a/changelog.d/waiting-baseline-disconnect.md b/changelog.d/waiting-baseline-disconnect.md new file mode 100644 index 0000000000..5799be2218 --- /dev/null +++ b/changelog.d/waiting-baseline-disconnect.md @@ -0,0 +1,3 @@ +### Fixed +- Chat drops a sent message's faded copy when its transcript turn arrives, even after another + pane disconnected and the browser clock is ahead of the PC. diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index c33c571b89..31f5e246b2 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -45,7 +45,7 @@ import { dropPendingListScroll, listEndPadded } from "../lib/followScroll.ts"; import { useMediaQuery } from "../lib/useMediaQuery.ts"; import { statusEdgeRead } from "../lib/status.ts"; import { askedQuestions } from "../../shared/askQuestions.ts"; -import { settleWaitingSends, waitingKey } from "../lib/sentWaiting.ts"; +import { forgetWaitingBaseline, settleWaitingSends, waitingKey } from "../lib/sentWaiting.ts"; import { SentWaitingList, useWaitingSends } from "./SentWaiting.tsx"; import { ConversationRefresh } from "../lib/conversationRefresh.ts"; import { teammateMessageParts, type TeammateMessage } from "../lib/teammateMessage.ts"; @@ -1724,7 +1724,7 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish const between = await turnsBetween(held, conversation.cursor, signal); if (cancelled || generation !== olderGeneration.current) return; if (between === null) { - dropOlder(); setState(EMPTY_STATE); setLoaded(false); signature.current = ""; lastAnswer.current = null; + dropOlder(); setState(EMPTY_STATE); setLoaded(false); forgetWaitingBaseline(waitingKey(machineId, paneId)); signature.current = ""; lastAnswer.current = null; setPollKey((key) => key + 1); return; } else { moved = between; heldFrom.current = conversation.cursor; } @@ -1825,7 +1825,7 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish if (shownPane.current !== paneId || olderGeneration.current !== generation) return; if (autoTabNames.current) learnFromConversation(machineId, paneId, page); if (page.history_id !== history.current) { - dropOlder(); setState(EMPTY_STATE); setLoaded(false); signature.current = ""; lastAnswer.current = null; + dropOlder(); setState(EMPTY_STATE); setLoaded(false); forgetWaitingBaseline(waitingKey(machineId, paneId)); signature.current = ""; lastAnswer.current = null; setPollKey((key) => key + 1); return; } // a bridge without pages answers with its newest turns: nothing older to add @@ -1847,7 +1847,7 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish } catch (cause) { if (shownPane.current !== paneId || olderGeneration.current !== generation) return; if (cause instanceof ApiError && cause.status === 409) { - dropOlder(); setState(EMPTY_STATE); setLoaded(false); signature.current = ""; lastAnswer.current = null; + dropOlder(); setState(EMPTY_STATE); setLoaded(false); forgetWaitingBaseline(waitingKey(machineId, paneId)); signature.current = ""; lastAnswer.current = null; setPollKey((key) => key + 1); } else setOlderState("failed"); diff --git a/src/components/SentWaiting.test.tsx b/src/components/SentWaiting.test.tsx index bd8d676a0e..4d203874b8 100644 --- a/src/components/SentWaiting.test.tsx +++ b/src/components/SentWaiting.test.tsx @@ -3,8 +3,10 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import type { Machine } from "../../shared/machines.ts"; +import { paneLifetimes } from "../lib/paneLifetime.ts"; import { SettingsProvider } from "../lib/settings.ts"; -import { forgetWaitingSends, rememberWaitingSend, WAITING_HINT_MS, waitingKey, type WaitingSend } from "../lib/sentWaiting.ts"; +import { currentWaitingSends, forgetWaitingSends, rememberWaitingSend, settleWaitingSends, WAITING_HINT_MS, waitingBaseline, waitingKey, type WaitingSend } from "../lib/sentWaiting.ts"; import { SentWaitingList, useWaitingSends } from "./SentWaiting.tsx"; const at = Date.parse("2026-10-29T03:00:00Z"); @@ -124,4 +126,24 @@ describe("mounted", () => { expect(shown).toEqual([]); } finally { forgetWaitingSends(key); } }); + + test("a disconnected sibling pane preserves the baseline so a phone's send does not duplicate its transcript turn", () => { + // a PC whose snapshots name their herdr: a reconnect to it is the same herdr + paneLifetimes.observe([{ id: "skewed-PC", snapshot: { panes: [], herdr_lifetime: "skewed-herdr" } } as unknown as Machine]); + const key = waitingKey("skewed-PC", "baseline-owner"); + const user = (text: string, ts: number) => ({ role: "user" as const, ts: new Date(ts).toISOString(), parts: [{ kind: "text" as const, text }] }); + // The phone is two seconds ahead of the PC. Its loaded chat already accounts for that skew. + const earlier = user("earlier", at - 10_000); + settleWaitingSends(key, [earlier], at, true); + function Sibling() { + useWaitingSends("skewed-PC", "another-pane", false); + return null; + } + try { + mount(createElement(Sibling), false); + rememberWaitingSend(key, "follow up while working", at + 2_000, waitingBaseline(key)); + settleWaitingSends(key, [earlier, user("follow up while working", at + 100)], at + 2_100, true); + expect(currentWaitingSends(key)).toEqual([]); + } finally { forgetWaitingSends(key); } + }); }); diff --git a/src/components/SentWaiting.tsx b/src/components/SentWaiting.tsx index 4332f2987b..20b6b991bd 100644 --- a/src/components/SentWaiting.tsx +++ b/src/components/SentWaiting.tsx @@ -3,7 +3,7 @@ import { Clock } from "lucide-react"; import { useT } from "../lib/i18n.ts"; import { shouldFoldUserMessage } from "../lib/userMessageFold.ts"; -import { currentWaitingSends, forgetMachineWaitingSends, subscribeWaitingSends, WAITING_HINT_MS, waitingKey, type WaitingSend } from "../lib/sentWaiting.ts"; +import { clearMachineWaitingSends, currentWaitingSends, subscribeWaitingSends, WAITING_HINT_MS, waitingKey, type WaitingSend } from "../lib/sentWaiting.ts"; /** * The sends this browser made to a pane that its transcript does not hold yet. A lost connection @@ -13,7 +13,7 @@ import { currentWaitingSends, forgetMachineWaitingSends, subscribeWaitingSends, export function useWaitingSends(machineId: string, paneId: string, connected: boolean): readonly WaitingSend[] { const key = waitingKey(machineId, paneId); useLayoutEffect(() => { - if (!connected) forgetMachineWaitingSends(machineId); + if (!connected) clearMachineWaitingSends(machineId); }, [connected, machineId]); return useSyncExternalStore(subscribeWaitingSends, () => currentWaitingSends(key)); } diff --git a/src/lib/sentWaiting.test.ts b/src/lib/sentWaiting.test.ts index 3d8c1c2d6a..ea6de6c090 100644 --- a/src/lib/sentWaiting.test.ts +++ b/src/lib/sentWaiting.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { currentWaitingSends, expireWaitingSends, forgetMachineWaitingSends, forgetWaitingSends, rememberWaitingSend, settleWaitingSends, WAITING_SEND_MS, waitingBaseline, waitingKey } from "./sentWaiting.ts"; +import { clearMachineWaitingSends, currentWaitingSends, expireWaitingSends, forgetMachineWaitingSends, forgetWaitingBaseline, forgetWaitingSends, pruneWaitingSends, rememberWaitingSend, settleWaitingSends, WAITING_SEND_MS, waitingBaseline, waitingKey } from "./sentWaiting.ts"; import type { ConversationTurn } from "../../shared/protocol.ts"; import type { Machine } from "../../shared/machines.ts"; import { paneLifetimes } from "./paneLifetime.ts"; @@ -317,6 +317,118 @@ describe("waitingText", () => { } finally { for (const key of [here, other, away]) forgetWaitingSends(key); } }); + it("connection cleanup clears that PC's waiting copies while retaining loaded transcript baselines", () => { + const here = waitingKey("cleanup-PC", "cleanup-owner"), other = waitingKey("cleanup-PC", "cleanup-sibling"), away = waitingKey("cleanup-away", "cleanup-owner"); + const keys = [here, other, away]; + paneLifetimes.observe(["cleanup-PC", "cleanup-away"].map((id) => ({ id, snapshot: { panes: [], herdr_lifetime: `${id}-herdr` } } as unknown as Machine))); + try { + for (const key of keys) { + settleWaitingSends(key, [user("earlier", 1)], 10, true); + rememberWaitingSend(key, "next", 20, waitingBaseline(key)); + } + const baselines = keys.map(waitingBaseline); + clearMachineWaitingSends("cleanup-PC"); + expect(keys.map((key) => currentWaitingSends(key).length)).toEqual([0, 0, 1]); + for (const [index, key] of keys.entries()) expect(waitingBaseline(key)).toBe(baselines[index]!); + // A replaced Herdr must still forget those baselines before its pane IDs are reused. + forgetMachineWaitingSends("cleanup-PC"); + expect(keys.map((key) => waitingBaseline(key) === null)).toEqual([true, true, false]); + } finally { for (const key of keys) forgetWaitingSends(key); } + }); + + it("prunes only panes proved closed by a connected PC's fresh snapshot", () => { + const keys = [waitingKey("local", "closed"), waitingKey("local", "active"), waitingKey("local", "sibling"), waitingKey("remote", "closed"), waitingKey("local", "not-listed-yet")]; + const roster = (state: Machine["state"], snapshot: Machine["snapshot"]) => [{ id: "local", state, snapshot } as Machine]; + const snapshot = { panes: [{ pane_id: "active" }, { pane_id: "sibling" }] } as Machine["snapshot"]; + try { + for (const key of keys) { + settleWaitingSends(key, [user("earlier", 1)], 10, true); + rememberWaitingSend(key, "next", 20, waitingBaseline(key)); + } + const baselines = keys.map(waitingBaseline); + pruneWaitingSends(roster("connected", { panes: [{ pane_id: "closed" }, { pane_id: "active" }, { pane_id: "sibling" }] } as Machine["snapshot"])); + expect(waitingBaseline(keys[4]!)).toBe(baselines[4]!); + for (const machines of [[], roster("disconnected", snapshot), roster("connecting", snapshot), roster("reconnecting", snapshot), roster("error", snapshot), roster("connected", null)]) { + pruneWaitingSends(machines); + expect(keys.map(waitingBaseline)).toEqual(baselines); + expect(keys.map((key) => currentWaitingSends(key).length)).toEqual([1, 1, 1, 1, 1]); + } + pruneWaitingSends(roster("connected", snapshot)); + expect(keys.map((key) => currentWaitingSends(key).length)).toEqual([0, 1, 1, 1, 1]); + expect(waitingBaseline(keys[0]!)).toBeNull(); + for (const index of [1, 2, 3, 4]) expect(waitingBaseline(keys[index]!)).toBe(baselines[index]!); + pruneWaitingSends(roster("connected", { panes: [...snapshot!.panes, { pane_id: "not-listed-yet" }] } as Machine["snapshot"])); + pruneWaitingSends(roster("connected", snapshot)); + expect(waitingBaseline(keys[4]!)).toBeNull(); + expect(currentWaitingSends(keys[4]!)).toEqual([]); + } finally { for (const key of keys) forgetWaitingSends(key); } + }); + + it("forgets roster closure evidence when a PC's Herdr is retired", () => { + const key = waitingKey("retired-PC", "reused"); + const roster = (panes: { pane_id: string }[]) => [{ id: "retired-PC", state: "connected", snapshot: { panes } } as Machine]; + try { + pruneWaitingSends(roster([{ pane_id: "reused" }])); + forgetMachineWaitingSends("retired-PC"); + settleWaitingSends(key, [user("new lifetime", 1)], 10, true); + const baseline = waitingBaseline(key); + pruneWaitingSends(roster([])); + expect(waitingBaseline(key)).toBe(baseline); + } finally { forgetMachineWaitingSends("retired-PC"); } + }); + + it("a bridge that names no herdr loses its baselines with the connection: a replacement could reuse its pane ids", () => { + const key = waitingKey("unnamed-PC", "reused"); + paneLifetimes.observe([{ id: "unnamed-PC", snapshot: { panes: [] } } as unknown as Machine]); + try { + settleWaitingSends(key, [], 10, true); + clearMachineWaitingSends("unnamed-PC"); + expect(waitingBaseline(key)).toBeNull(); + } finally { forgetMachineWaitingSends("unnamed-PC"); } + }); + + it("does not prune by a roster read from a herdr the page already left", () => { + const key = waitingKey("moved-PC", "new-pane"); + const roster = (lifetime: string, panes: { pane_id: string }[]) => [{ id: "moved-PC", state: "connected", snapshot: { panes, herdr_lifetime: lifetime } } as unknown as Machine]; + try { + paneLifetimes.observe(roster("moved-a", [])); + paneLifetimes.observe(roster("moved-b", [{ pane_id: "new-pane" }])); + pruneWaitingSends(roster("moved-b", [{ pane_id: "new-pane" }])); + rememberWaitingSend(key, "still mine", Date.now()); + // a delayed read of the herdr left behind, without the pane: paneLifetimes skips it, so must the prune + const stale = roster("moved-a", []); + paneLifetimes.observe(stale); + pruneWaitingSends(stale); + expect(currentWaitingSends(key).map((send) => send.text)).toEqual(["still mine"]); + } finally { forgetMachineWaitingSends("moved-PC"); } + }); + + it("keeps a chat's baseline while it remounts empty before its read", () => { + const key = waitingKey("local", "remounted"); + try { + settleWaitingSends(key, [user("earlier", 1_000)], 3_000, true); + const baseline = waitingBaseline(key); + settleWaitingSends(key, [], 9_000, false); + settleWaitingSends(key, [user("earlier", 1_000)], 9_100, true); + expect(waitingBaseline(key)).toBe(baseline); + } finally { forgetWaitingSends(key); } + }); + + it("forgets a replaced transcript's baseline before the next send", () => { + const key = waitingKey("local", "replaced"), sibling = waitingKey("local", "retained-sibling"); + const turn = user("repeat", 10_000); + try { + for (const pane of [key, sibling]) settleWaitingSends(pane, [turn], 3_000, true); + const retained = waitingBaseline(sibling); + forgetWaitingBaseline(key); + expect(waitingBaseline(key)).toBeNull(); + expect(waitingBaseline(sibling)).toBe(retained); + rememberWaitingSend(key, "repeat", 3_100, waitingBaseline(key)); + settleWaitingSends(key, [turn], 3_200, true); + expect(currentWaitingSends(key)).toEqual([]); + } finally { for (const pane of [key, sibling]) forgetWaitingSends(pane); } + }); + it("records no baseline for a chat that has not read its transcript", () => { const key = waitingKey("local", "unread"); try { diff --git a/src/lib/sentWaiting.ts b/src/lib/sentWaiting.ts index 2d3c004e07..36a60ab8a7 100644 --- a/src/lib/sentWaiting.ts +++ b/src/lib/sentWaiting.ts @@ -1,3 +1,4 @@ +import type { Machine } from "../../shared/machines.ts"; import type { ConversationTurn } from "../../shared/protocol.ts"; import { composerMessage } from "./compose.ts"; import { paneLifetimes } from "./paneLifetime.ts"; @@ -91,6 +92,7 @@ interface WaitingPane { const waiting = new Map(); /** the user turns each pane's chat showed last, for the baseline of its next send */ const shown = new Map(); +const listedPanes = new Map>(); const empty: readonly WaitingSend[] = []; const listeners = new Set<() => void>(); const announce = (): void => { for (const listener of listeners) listener(); }; @@ -185,12 +187,46 @@ export function forgetWaitingSends(key: string): void { if (waiting.delete(key)) announce(); } +/** The chat dropped its transcript for another history: the turns it showed are no baseline for its next send. */ +export function forgetWaitingBaseline(key: string): void { + shown.delete(key); +} + +/** + * A connection loss drops provisional bubbles, but does not invalidate a chat's loaded turns. Only a + * PC whose snapshots name their herdr keeps them: an older bridge's replacement would reuse its pane + * ids unnoticed, so its baselines go with the connection. + */ +export function clearMachineWaitingSends(machineId: string): void { + if (typeof paneLifetimes.generation(machineId) !== "string") { forgetMachineWaitingSends(machineId); return; } + let changed = false; + for (const key of waiting.keys()) { + if (key.startsWith(`${machineId}:`)) { waiting.delete(key); changed = true; } + } + if (changed) announce(); +} + // herdr gives pane ids out again once it is replaced (lib/paneLifetime.ts): the earlier herdr's // sends never reach the new panes, so a PC's waiting lines go with its herdr export function forgetMachineWaitingSends(machineId: string): void { + listedPanes.delete(machineId); for (const key of [...waiting.keys(), ...shown.keys()]) if (key.startsWith(`${machineId}:`)) forgetWaitingSends(key); } +export function pruneWaitingSends(machines: readonly Machine[]): void { + for (const machine of machines) { + if (machine.state !== "connected" || machine.snapshot === null) continue; + // a roster read from a herdr this page already left (paneLifetimes skipped it) proves no pane closed + const lifetime = machine.snapshot.herdr_lifetime; + if (lifetime !== undefined && lifetime !== paneLifetimes.generation(machine.id)) continue; + const open = new Set(machine.snapshot.panes.map((pane) => waitingKey(machine.id, pane.pane_id))); + for (const key of listedPanes.get(machine.id) ?? []) { + if (!open.has(key)) forgetWaitingSends(key); + } + listedPanes.set(machine.id, open); + } +} + paneLifetimes.onRetire(forgetMachineWaitingSends); export function subscribeWaitingSends(listener: () => void): () => void { diff --git a/src/lib/useMachineRoster.ts b/src/lib/useMachineRoster.ts index ac71150029..369367ea3e 100644 --- a/src/lib/useMachineRoster.ts +++ b/src/lib/useMachineRoster.ts @@ -17,6 +17,7 @@ import type { NotificationTarget } from "./notificationTarget.ts"; import type { SelectionMark } from "./selectionMark.ts"; import type { SplitState } from "./split.ts"; import { deviceInUse, followsHerdrFocus, movingHerdrFocusOn } from "./followFocus.ts"; +import { pruneWaitingSends } from "./sentWaiting.ts"; import { paneLifetimes } from "./paneLifetime.ts"; import { displayPaneTitle } from "../components/Sidebar.tsx"; import type { usePreferencesSync } from "./preferencesSync.ts"; @@ -105,6 +106,7 @@ export function useRosterLoader({ rosterIn, setMachines, setMachineRosterLoaded, await snapshotRequests.current.read(fetchMachines, (next) => { // a PC whose herdr was replaced leaves its panes' drafts and lenses before they are drawn paneLifetimes.observe(next, displayPaneTitle); + pruneWaitingSends(next); // a pane's conversation answers stand for the session it was on: the chats read this roster's observePaneSessions(next, readAfter); reorderCoordinator.roster(next, { kind: "load", started }); @@ -219,6 +221,7 @@ export function useRosterEvents({ onPaneMoved, machineSessions, locked, receiveP snapshotRequests.current.invalidate(); if (payload.type === "machines") { paneLifetimes.observe(payload.machines, displayPaneTitle); + pruneWaitingSends(payload.machines); observePaneSessions(payload.machines); onRoster(payload.machines); reorderCoordinator.roster(payload.machines, { kind: "event" });