diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index fa87c5ce4e8..fa1769061a0 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -379,7 +379,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }).pipe(Effect.provide(makeSessionStoreLayer())), ); - it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () => + it.effect("tracks the connection start and final disconnect time", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; const issued = yield* sessions.issue({ @@ -405,11 +405,20 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); yield* sessions.markDisconnected(issued.sessionId); + const afterFirstDisconnect = yield* sessions.listActive(); + + expect(afterFirstDisconnect[0]?.connected).toBe(true); + expect(afterFirstDisconnect[0]?.lastConnectedAt?.toString()).toBe( + firstConnectedAt?.toString(), + ); + yield* sessions.markDisconnected(issued.sessionId); const afterDisconnect = yield* sessions.listActive(); + const disconnectedAt = afterDisconnect[0]?.lastConnectedAt; expect(afterDisconnect[0]?.connected).toBe(false); - expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); + expect(disconnectedAt).not.toBeNull(); + expect(disconnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); yield* TestClock.adjust(Duration.seconds(1)); yield* sessions.markConnected(issued.sessionId); @@ -417,7 +426,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(afterReconnect[0]?.connected).toBe(true); expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull(); - expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); + expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(disconnectedAt?.toString()); }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); it.effect("records client connection metadata without clearing prior values", () => diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index b315bdf87c7..053e2b295d0 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -530,6 +530,16 @@ export const make = Effect.gen(function* () { ); }); + const setLastConnectedAtNow = (sessionId: AuthSessionId) => + DateTime.now.pipe( + Effect.flatMap((lastConnectedAt) => + authSessions.setLastConnectedAt({ + sessionId, + lastConnectedAt, + }), + ), + ); + const markConnected: SessionStore["Service"]["markConnected"] = (sessionId) => Ref.modify(connectedSessionsRef, (current) => { const next = new Map(current); @@ -538,16 +548,7 @@ export const make = Effect.gen(function* () { return [wasDisconnected, next] as const; }).pipe( Effect.flatMap((wasDisconnected) => - wasDisconnected - ? DateTime.now.pipe( - Effect.flatMap((lastConnectedAt) => - authSessions.setLastConnectedAt({ - sessionId, - lastConnectedAt, - }), - ), - ) - : Effect.void, + wasDisconnected ? setLastConnectedAtNow(sessionId) : Effect.void, ), Effect.flatMap(() => loadActiveSession(sessionId)), Effect.flatMap((session) => @@ -587,7 +588,7 @@ export const make = Effect.gen(function* () { ); const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => - Ref.update(connectedSessionsRef, (current) => { + Ref.modify(connectedSessionsRef, (current) => { const next = new Map(current); const remaining = (next.get(sessionId) ?? 0) - 1; if (remaining > 0) { @@ -595,8 +596,11 @@ export const make = Effect.gen(function* () { } else { next.delete(sessionId); } - return next; + return [remaining === 0, next] as const; }).pipe( + Effect.flatMap((becameDisconnected) => + becameDisconnected ? setLastConnectedAtNow(sessionId) : Effect.void, + ), Effect.flatMap(() => loadActiveSession(sessionId)), Effect.flatMap((session) => Option.isSome(session) ? emitUpsert(session.value) : Effect.void, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 1c4e034cf6a..0886e05ee7d 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -44,7 +44,11 @@ import * as Option from "effect/Option"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; -import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; +import { + formatElapsedDurationLabel, + formatExpiresInLabel, + formatRelativeTimeLabel, +} from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; import { applyWslEnableSelection, @@ -952,6 +956,11 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ : lastConnectedAt ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}` : "Not connected yet."; + const lastSeenLabel = isLive + ? "Last seen now" + : lastConnectedAt + ? `Last seen ${formatRelativeTimeLabel(lastConnectedAt)}` + : "Never seen"; const deviceInfoBits = [ clientSession.client.deviceType !== "unknown" ? clientSession.client.deviceType[0]?.toUpperCase() + clientSession.client.deviceType.slice(1) @@ -991,6 +1000,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ ) : null}

+

{lastSeenLabel}

{!clientSession.current ? (