Skip to content

Commit ed73942

Browse files
Say lost edits in the status area, offer Reconnect in phone Chat, and restart backoff on request
The notice for edits a rebuild dropped now sits with the document status in the header, where it covers no decision card. Chat offers Reconnect when the header is out of view. Asking to reconnect starts the backoff over, so a few failed asks never leave the next automatic attempt waiting out the cap. The no-Planner mention test finds the sent message by its raw text, since the transcript drops the leading mention. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a4e9e01 commit ed73942

10 files changed

Lines changed: 135 additions & 54 deletions

File tree

‎apps/web/src/chat/chat.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -990,6 +990,16 @@ export function Chat(
990990
</span>
991991
)}
992992
</span>
993+
{/* Where the document header is out of view; see composer.css. */}
994+
{connectionNotice === "offline" && wire && (
995+
<button
996+
className="composer-reconnect btn btn-sm btn-ghost"
997+
onClick={() => wire.reconnect()}
998+
type="button"
999+
>
1000+
Reconnect
1001+
</button>
1002+
)}
9931003
<span className="composer-run-control">
9941004
{agent && (busy || counts.active > 0) && (
9951005
<button

‎apps/web/src/chat/composer.css‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,10 @@
278278
animation: composer-connection-enter var(--duration-fast) var(--ease-out);
279279
}
280280
}
281+
/*
282+
* Side by side with the document, its header already offers Reconnect, and
283+
* one state gets one action. Without it in view, as on a phone, Chat does.
284+
*/
285+
[data-workspace-mode="split"] .chat-composer .composer-reconnect {
286+
display: none;
287+
}

‎apps/web/src/room-workspace.tsx‎

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,9 @@ export function Header(
248248
);
249249
}
250250

251+
const LOST_EDITS =
252+
"Your last edits couldn't be saved because the document changed while you were offline.";
253+
251254
/** Reconnect attempts a person can make in one outage before Reload is offered. */
252255
const RECONNECTS_BEFORE_RELOAD = 3;
253256

@@ -310,6 +313,8 @@ export function RoomWorkspace(
310313
// Reconnecting in place keeps unsent work, so it is offered first. A reload
311314
// is the fallback once it has failed this often in one outage.
312315
let [reconnects, setReconnects] = useState(0);
316+
// The last loss of unsent edits this person has dismissed.
317+
let [lostSeen, setLostSeen] = useState(0);
313318
if (status === "connected" && reconnects) setReconnects(0);
314319
let [members, setMembers] = useState<Session.Member[]>([]);
315320
let [effectiveCanEdit, setEffectiveCanEdit] = useState(canEdit && !archivedAt);
@@ -917,17 +922,43 @@ export function RoomWorkspace(
917922
/>
918923
}
919924
status={
920-
<PlanStatus
921-
connection={status === "deleted" ? "closed" : treatAsConnected ? undefined : status}
922-
failed={planState.failed}
923-
onReconnect={wire && reconnects < RECONNECTS_BEFORE_RELOAD
924-
? () => {
925-
setReconnects(count => count + 1);
926-
wire.reconnect();
927-
}
928-
: undefined}
929-
synced={planState.synced}
930-
/>
925+
<>
926+
{!!planState.lost && planState.lost !== lostSeen && (
927+
<div className="plan-status" data-level="alert" role="alert">
928+
<span className="plan-status-text">
929+
<span
930+
aria-hidden="true"
931+
className="plan-status-label"
932+
data-tooltip={LOST_EDITS}
933+
data-tooltip-verbatim=""
934+
>
935+
Edits not saved
936+
</span>
937+
<span aria-hidden="true" className="plan-status-detail">{LOST_EDITS}</span>
938+
<span className="sr-only">{LOST_EDITS}</span>
939+
</span>
940+
<button
941+
className="btn btn-sm btn-ghost"
942+
onClick={() =>
943+
setLostSeen(planState.lost ?? 0)}
944+
type="button"
945+
>
946+
Dismiss
947+
</button>
948+
</div>
949+
)}
950+
<PlanStatus
951+
connection={status === "deleted" ? "closed" : treatAsConnected ? undefined : status}
952+
failed={planState.failed}
953+
onReconnect={wire && reconnects < RECONNECTS_BEFORE_RELOAD
954+
? () => {
955+
setReconnects(count => count + 1);
956+
wire.reconnect();
957+
}
958+
: undefined}
959+
synced={planState.synced}
960+
/>
961+
</>
931962
}
932963
ids={workspaceIds}
933964
identity={room}

‎apps/web/src/wire.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,36 @@ describe("status", () => {
317317
}
318318
}, 10_000);
319319

320+
it("starts the backoff over when asked to reconnect", async () => {
321+
let attempts = 0;
322+
let server = Bun.serve({
323+
port: 0,
324+
hostname: "127.0.0.1",
325+
fetch(request) {
326+
if (request.headers.get("x-chopin-socket-probe") !== "1") attempts++;
327+
return new Response("try again", { status: 503 });
328+
},
329+
});
330+
servers.push(server);
331+
let random = Math.random;
332+
Math.random = () => 0.999;
333+
try {
334+
let wire = connect(server.port!, []);
335+
await until(() => wire.status === "reconnecting", "backoff");
336+
// Each failed ask would otherwise double the wait after it.
337+
for (let i = 0; i < 5; i++) {
338+
let before = attempts;
339+
wire.reconnect();
340+
await until(() => attempts > before, "asked attempt");
341+
await Bun.sleep(50);
342+
}
343+
let before = attempts;
344+
await until(() => attempts > before, "automatic attempt soon after");
345+
} finally {
346+
Math.random = random;
347+
}
348+
});
349+
320350
it("ignores a superseded refusal probe after manual reconnection", async () => {
321351
let probe = Promise.withResolvers<void>();
322352
let release = Promise.withResolvers<void>();

‎apps/web/src/wire.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,18 @@ export class Wire {
142142
return this.#socket?.readyState === WebSocket.OPEN;
143143
}
144144

145+
/**
146+
* Try again now, at a person's request.
147+
*
148+
* The backoff starts over: someone who asks to reconnect is saying the
149+
* network may be back, and after a few failed asks the next automatic
150+
* attempt must not still be waiting out the cap.
151+
*/
145152
reconnect(): void {
146153
if (this.#disposed || this.#terminal) return;
147154
clearTimeout(this.#timer);
148155
this.#timer = undefined;
156+
this.#attempts = 0;
149157
let previous = this.#socket;
150158
this.#socket = undefined;
151159
this.#abandon("connection restarted");

‎e2e/chat-mentions.e2e.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,10 @@ test("without a Planner a manually addressed message gets a local notice", async
217217
await expect(chat.getByRole("listbox", { name: "Mentions" })).toHaveCount(0);
218218
await input.fill("@chopin are you there?");
219219
await chat.getByRole("button", { name: "Send message" }).click();
220-
await expect(chat.getByText("@chopin are you there?", { exact: true })).toBeVisible();
220+
// The transcript drops the leading mention from what it shows, so find the
221+
// sent message by what was sent rather than by the draft still on screen.
222+
await expectChatValue(input, "");
223+
await expect(chat.locator('[data-chat-raw="@chopin are you there?"]')).toBeVisible();
221224
let notice = chat.getByText("Chopin is off on this server. Your message went to the room only.", {
222225
exact: true,
223226
});

‎e2e/connection.e2e.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,23 @@ test("a reload keeps an unsent Chat message", async ({ join, page }) => {
214214
await ready(page);
215215
await expectChatValue(chatInput(chatPane(page)), "Not sent before the reload");
216216
});
217+
218+
test("on a phone, Chat offers Reconnect when offline", async ({ join, page }) => {
219+
await page.setViewportSize({ width: 390, height: 844 });
220+
let wire = route(page);
221+
await wire.ready;
222+
await join("ana");
223+
await page.getByRole("navigation", { name: "Workspace view" })
224+
.getByRole("button", { name: /^Chat/ }).click();
225+
let chat = chatPane(page);
226+
227+
wire.state.offline = true;
228+
await wire.sockets.at(-1)!.close();
229+
await expect(chat.locator(".composer-connection")).toHaveText("Offline", { timeout: 10_000 });
230+
let reconnect = chat.getByRole("button", { name: "Reconnect", exact: true });
231+
await expect(reconnect).toBeVisible();
232+
233+
wire.state.offline = false;
234+
await reconnect.click();
235+
await expect(chat.locator(".composer-connection")).toBeEmpty();
236+
});

‎e2e/editing.e2e.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@ test("a lost connection is said in the document header and the composer", async
263263
await expect(status.getByRole("button", { name: "Reload" })).toBeVisible();
264264
await expect(status.getByRole("button", { name: "Reconnect", exact: true })).toHaveCount(0);
265265

266+
// No online event here: the wire's own retry has to bring it back, and
267+
// asking to reconnect restarted the backoff rather than adding to it.
266268
offline = false;
267269
await ready(page);
268270
await expect(status).toHaveAttribute("data-level", "hidden");

‎packages/editor/src/plan-editor.tsx‎

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ export type PlanState = {
105105
synced: boolean;
106106
/** Why the document was last replaced, if it was. */
107107
reset?: Plan.Reset["reason"];
108+
/**
109+
* Counts replacements that dropped edits the server never acknowledged,
110+
* so the host can say so once for each, until it is dismissed.
111+
*/
112+
lost?: number;
108113
/** Why it could not be opened at all, if it could not. */
109114
failed?: string;
110115
};
@@ -136,8 +141,6 @@ export function PlanEditor(
136141
let ref = useRef<MDXEditorMethods>(null);
137142
let scroller = useRef<HTMLDivElement>(null);
138143
let [state, setState] = useState<PlanState>({ synced: false });
139-
// Unsent edits a rebuilt document could not keep, said until dismissed.
140-
let [lost, setLost] = useState(false);
141144
let [generation, setGeneration] = useState(0);
142145
let provider = useRef<PlanProvider>(undefined);
143146
// Presence renders, so it needs the provider as state; edits need it
@@ -155,10 +158,15 @@ export function PlanEditor(
155158
// rebuilt rather than reconciled — that is what "reset" means. The marks
156159
// describe a history that no longer exists, so they go with it.
157160
let onReset = useCallback((reason: Plan.Reset["reason"], lost: boolean) => {
158-
if (lost) setLost(true);
159161
changes.clear();
160162
questions?.resetDocument();
161-
setState(prev => ({ ...prev, synced: false, reset: reason, failed: undefined }));
163+
setState(prev => ({
164+
...prev,
165+
synced: false,
166+
reset: reason,
167+
failed: undefined,
168+
lost: lost ? (prev.lost ?? 0) + 1 : prev.lost,
169+
}));
162170
setGeneration(value => value + 1);
163171
}, [changes, questions]);
164172

@@ -419,17 +427,6 @@ export function PlanEditor(
419427
</div>
420428
{/* In the document column, so they track the prose, not the pane. */}
421429
<PlanChanges motionImmediately={motionImmediately} store={changes} />
422-
{lost && (
423-
<div className="plan-lost" role="alert">
424-
<span>
425-
Your last edits couldn't be saved because the document changed while you were
426-
offline.
427-
</span>
428-
<button className="btn btn-sm btn-ghost" onClick={() => setLost(false)} type="button">
429-
Dismiss
430-
</button>
431-
</div>
432-
)}
433430
</div>
434431
</div>
435432
</div>

‎packages/editor/src/styles.css‎

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2304,33 +2304,6 @@
23042304
background: var(--color-brand-wash);
23052305
}
23062306

2307-
/*
2308-
* Over the prose, like the change chips, so saying it moves nothing. It stays
2309-
* until dismissed: the edits are gone, and a note that fades on its own could
2310-
* go unread.
2311-
*/
2312-
.plan-lost {
2313-
position: absolute;
2314-
z-index: 4;
2315-
inset-block-end: 0.75rem;
2316-
inset-inline-start: 50%;
2317-
translate: -50% 0;
2318-
display: flex;
2319-
align-items: center;
2320-
gap: 0.5rem;
2321-
width: max-content;
2322-
max-width: min(28rem, calc(100% - 1.5rem));
2323-
padding: 0.375rem 0.375rem 0.375rem 0.75rem;
2324-
border-radius: var(--radius-lg);
2325-
background: var(--color-page);
2326-
outline: var(--edge-width) solid var(--color-edge);
2327-
outline-offset: calc(-1 * var(--edge-width));
2328-
box-shadow: var(--shadow-resting);
2329-
color: var(--color-destructive-ink);
2330-
font-size: var(--text-sm);
2331-
line-height: var(--text-sm--line-height);
2332-
}
2333-
23342307
/* Reaching the ones off screen ------------------------------------------- */
23352308

23362309
/*

0 commit comments

Comments
 (0)