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
8 changes: 8 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1121,9 +1121,17 @@ select:disabled,
.shell-canvas-pane-actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.25rem;
}

.shell-artifact-presence-status {
padding: 0 0.35rem;
font-size: 0.75rem;
color: var(--muted-foreground);
white-space: nowrap;
}

.shell-profile-pane-hint {
margin: 0;
font-size: 0.75rem;
Expand Down
52 changes: 50 additions & 2 deletions apps/web/src/presence/use-presence-room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
// disconnect lifecycle to reason about per (tenant, surface) pair.
import { useCallback, useEffect, useRef, useState } from "react";
import type * as Y from "yjs";
import { connectPresence, type PresenceHandle } from "@corbits/presence/client";
import { reportError } from "@corbits/error-sink";
import {
connectPresence,
type PresenceError,
type PresenceHandle,
} from "@corbits/presence/client";

export interface PresenceRoomMember {
readonly principalId: string;
Expand All @@ -19,8 +24,12 @@ export interface PresenceRoomMember {
readonly typing?: boolean;
}

/** Healthy until two consecutive transport failures; a single retry stays quiet. */
export type PresenceConnection = "ok" | "degraded";

export interface PresenceRoom {
readonly members: readonly PresenceRoomMember[];
readonly connection: PresenceConnection;
readonly publishCursor: (
x: number,
y: number,
Expand All @@ -40,6 +49,16 @@ export interface UsePresenceRoomOptions {
readonly doc?: Y.Doc;
/** Called for every confirmed server-side snapshot — see `formatSaveStateLine` in `@corbits/artifact-ui`. */
readonly onSaved?: (info: { version: number; savedAt: number }) => void;
/** The signed-in principal, carried on `reportError` so a failed join is scoped. */
readonly principalId?: string;
}

const TRANSIENT_FAILURE_BUDGET = 1;

function presenceErrorMessage(error: PresenceError): string {
return error.status === undefined
? `Presence ${error.operation} failed`
: `Presence ${error.operation} failed (${String(error.status)})`;
}

/**
Expand All @@ -56,18 +75,24 @@ export function usePresenceRoom(
options?: UsePresenceRoomOptions,
): PresenceRoom {
const [members, setMembers] = useState<readonly PresenceRoomMember[]>([]);
const [connection, setConnection] = useState<PresenceConnection>("ok");
const handleRef = useRef<PresenceHandle | null>(null);
const consecutiveFailuresRef = useRef(0);
// `onSaved` is read through a ref, not a `connectPresence` dependency:
// a caller re-rendering with a fresh inline callback must never tear
// down and reconnect the stream (it would re-fetch the whole doc state
// for no reason) — only the `Y.Doc` identity changing means a genuinely
// different room to sync.
const onSavedRef = useRef(options?.onSaved);
onSavedRef.current = options?.onSaved;
const principalIdRef = useRef(options?.principalId);
principalIdRef.current = options?.principalId;
const doc = options?.doc;

useEffect(() => {
setMembers([]);
setConnection("ok");
consecutiveFailuresRef.current = 0;
if (tenantId === null || surface === null) {
handleRef.current = null;
return;
Expand All @@ -88,8 +113,31 @@ export function usePresenceRoom(
const unsubscribe = handle.subscribe((snapshot) =>
setMembers(snapshot as readonly PresenceRoomMember[]),
);
const unsubscribeErrors = handle.onError((error) => {
const extra: Record<string, unknown> = {};
if (principalIdRef.current !== undefined) {
extra.principalId = principalIdRef.current;
}
if (error.status !== undefined) extra.status = error.status;
reportError(new Error(presenceErrorMessage(error)), {
operation: `presence.${error.operation}`,
tenantId,
roomId: surface,
...(Object.keys(extra).length > 0 ? { extra } : {}),
});
consecutiveFailuresRef.current += 1;
if (consecutiveFailuresRef.current > TRANSIENT_FAILURE_BUDGET) {
setConnection("degraded");
}
});
const unsubscribeRecovered = handle.onRecovered(() => {
consecutiveFailuresRef.current = 0;
setConnection("ok");
});
return () => {
unsubscribe();
unsubscribeErrors();
unsubscribeRecovered();
handle.disconnect();
handleRef.current = null;
};
Expand All @@ -108,5 +156,5 @@ export function usePresenceRoom(
handleRef.current?.publishTyping(typing);
}, []);

return { members, publishCursor, publishTyping };
return { members, connection, publishCursor, publishTyping };
}
26 changes: 15 additions & 11 deletions apps/web/src/shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,20 @@ export function AppShell({
tenantId,
canvasArtifact === null ? null : `artifact:${canvasArtifact.id}`,
undefined,
artifactDoc === null
? undefined
: {
doc: artifactDoc,
onSaved: (info) =>
setArtifactSaveState({
kind: "saved",
version: info.version,
savedAt: info.savedAt,
}),
},
{
...(viewerPrincipalId === null ? {} : { principalId: viewerPrincipalId }),
...(artifactDoc === null
? {}
: {
doc: artifactDoc,
onSaved: (info: { version: number; savedAt: number }) =>
setArtifactSaveState({
kind: "saved",
version: info.version,
savedAt: info.savedAt,
}),
}),
},
);
const editingCoworkers = artifactPresence.members
.filter(
Expand Down Expand Up @@ -213,6 +216,7 @@ export function AppShell({
{...(artifactDoc !== null ? { artifactDoc } : {})}
artifactSaveState={artifactSaveStateWithEditors}
onArtifactTyping={artifactPresence.publishTyping}
presenceConnection={artifactPresence.connection}
/>
</Suspense>
) : null}
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/shell/canvas-column.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import type { ReactNode } from "react";
import type * as Y from "yjs";

import { useBench } from "../bench-context";
import type { PresenceConnection } from "../presence/use-presence-room";
import { workbenchPath } from "../workbench-path";
import { ensureProfileDm, loadSharedWorkbenches } from "../profile-relations";
import type {
Expand Down Expand Up @@ -90,6 +91,7 @@ export function CanvasColumn({
artifactDoc,
artifactSaveState,
onArtifactTyping,
presenceConnection = "ok",
}: {
readonly open: boolean;
readonly profile: ProfileSubject | null;
Expand Down Expand Up @@ -117,6 +119,8 @@ export function CanvasColumn({
readonly artifactSaveState?: ArtifactSaveState;
/** Fired on local typing start/stop in the text editor, for the host to publish through presence's `typing` awareness field. */
readonly onArtifactTyping?: (typing: boolean) => void;
/** Quiet reconnecting caption when presence has failed more than once. */
readonly presenceConnection?: PresenceConnection;
}) {
// `inert` rather than `aria-hidden`: a collapsed column has to be out of
// both the accessibility tree and the tab order, and `aria-hidden` alone
Expand Down Expand Up @@ -150,6 +154,7 @@ export function CanvasColumn({
{...(artifactDoc !== undefined ? { artifactDoc } : {})}
{...(artifactSaveState !== undefined ? { artifactSaveState } : {})}
{...(onArtifactTyping !== undefined ? { onArtifactTyping } : {})}
presenceConnection={presenceConnection}
/>
) : routine !== null ? (
<RoutinePanel />
Expand Down Expand Up @@ -511,6 +516,7 @@ function ArtifactCanvasPane({
artifactDoc,
artifactSaveState = { kind: "read-only" },
onArtifactTyping,
presenceConnection = "ok",
}: {
readonly artifact: CanvasArtifactContent;
readonly focus: boolean;
Expand All @@ -521,6 +527,7 @@ function ArtifactCanvasPane({
readonly artifactDoc?: Y.Doc;
readonly artifactSaveState?: ArtifactSaveState;
readonly onArtifactTyping?: (typing: boolean) => void;
readonly presenceConnection?: PresenceConnection;
}) {
const showEditor = showsTextEditor(artifact, artifactDoc);
return (
Expand All @@ -533,6 +540,18 @@ function ArtifactCanvasPane({
{...(artifact.previewSrc !== undefined
? { previewSrc: artifact.previewSrc }
: {})}
{...(presenceConnection === "degraded"
? {
trailing: (
<span
className="shell-artifact-presence-status"
aria-live="polite"
>
Reconnecting…
</span>
),
}
: {})}
/>
<div
className="shell-artifact-pane-body"
Expand Down
32 changes: 32 additions & 0 deletions apps/web/test/canvas-column.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ describe("canvas artifact pane: co-editing (CL-5958 phase 2)", () => {
readonly canEdit?: boolean;
};
readonly artifactDoc?: import("yjs").Doc;
readonly presenceConnection?: "ok" | "degraded";
}): Promise<void> {
await act(async () => {
root.render(
Expand All @@ -247,6 +248,9 @@ describe("canvas artifact pane: co-editing (CL-5958 phase 2)", () => {
{...(props.artifactDoc !== undefined
? { artifactDoc: props.artifactDoc }
: {})}
{...(props.presenceConnection !== undefined
? { presenceConnection: props.presenceConnection }
: {})}
/>
</BenchProvider>
</NavigationProvider>
Expand Down Expand Up @@ -359,4 +363,32 @@ describe("canvas artifact pane: co-editing (CL-5958 phase 2)", () => {
expect(container.querySelector("textarea")).toBeNull();
expect(container.textContent).toContain("fetched content");
});

test("a degraded presence connection shows a quiet reconnecting caption", async () => {
await renderArtifact({
artifact: {
id: "art_6",
title: "Notes",
rendererKind: "doc",
content: "fetched content",
},
presenceConnection: "degraded",
});

expect(container.textContent).toContain("Reconnecting…");
});

test("a healthy presence connection shows no reconnecting caption", async () => {
await renderArtifact({
artifact: {
id: "art_7",
title: "Notes",
rendererKind: "doc",
content: "fetched content",
},
presenceConnection: "ok",
});

expect(container.textContent).not.toContain("Reconnecting…");
});
});
Loading
Loading