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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/waiting-baseline-disconnect.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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
Expand All @@ -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");
Expand Down
24 changes: 23 additions & 1 deletion src/components/SentWaiting.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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); }
});
});
4 changes: 2 additions & 2 deletions src/components/SentWaiting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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));
}
Expand Down
114 changes: 113 additions & 1 deletion src/lib/sentWaiting.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions src/lib/sentWaiting.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -91,6 +92,7 @@ interface WaitingPane {
const waiting = new Map<string, WaitingPane>();
/** the user turns each pane's chat showed last, for the baseline of its next send */
const shown = new Map<string, WaitingBaseline>();
const listedPanes = new Map<string, ReadonlySet<string>>();
const empty: readonly WaitingSend[] = [];
const listeners = new Set<() => void>();
const announce = (): void => { for (const listener of listeners) listener(); };
Expand Down Expand Up @@ -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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

// 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 {
Expand Down
3 changes: 3 additions & 0 deletions src/lib/useMachineRoster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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" });
Expand Down
Loading