Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Changelog

## 0.271.0

### New Features ✨
Expand Down
168 changes: 168 additions & 0 deletions packages/junior-dashboard/e2e/conversations-mobile.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Page } from "@playwright/test";

import { expect, test } from "./test";
import { mobile, screenshot } from "./screenshot";

Expand Down Expand Up @@ -728,3 +730,169 @@ test("keeps the message in view while earlier history and late content load", as
});
await expect(reading).toBeInViewport({ ratio: 1 });
});

/** Mock one completed Conversation whose server read position moves forward. */
async function mockReadPositionConversation(
page: Page,
input: { eventCount: number; lastReadSeq: number },
) {
const generatedAt = "2026-08-07T11:00:00.000Z";
const conversationId = "read-position";
const conversation = {
conversationId,
cumulativeDurationMs: 1_000,
displayTitle: "Read position",
isParticipant: true,
lastProgressAt: generatedAt,
lastSeenAt: generatedAt,
startedAt: generatedAt,
status: "completed",
surface: "internal",
};
const state = {
conversationId,
eventCount: input.eventCount,
/** Holds detail responses until it resolves. */
detailGate: Promise.resolve(),
reported: [] as number[],
};
let stored = input.lastReadSeq;

await page.route("**/api/conversations", async (route) => {
if (new URL(route.request().url()).pathname !== "/api/conversations") {
await route.fallback();
return;
}
await route.fulfill({
json: {
conversations: [conversation],
generatedAt,
source: "conversation_index",
},
});
});
await page.route(
`**/api/conversations/${conversationId}/pending-messages`,
async (route) => {
await route.fulfill({
json: { conversationId, generatedAt, messages: [] },
});
},
);
await page.route(
`**/api/conversations/${conversationId}/read-position`,
async (route) => {
const { lastReadSeq } = route.request().postDataJSON() as {
lastReadSeq: number;
};
state.reported.push(lastReadSeq);
stored = Math.max(stored, lastReadSeq);
await route.fulfill({ json: { lastReadSeq: stored } });
},
);
await page.route(`**/api/conversations/${conversationId}`, async (route) => {
await state.detailGate;
await route.fulfill({
json: {
...conversation,
eventHistory: { status: "available" },
events: Array.from({ length: state.eventCount }, (_, seq) => ({
createdAt: new Date(
Date.parse(generatedAt) + seq * 1_000,
).toISOString(),
data: {
type: "message",
messageId: `message-${seq}`,
role: seq % 2 === 0 ? "user" : "assistant",
text: `Transcript message ${seq} with enough content to occupy a visible row.`,
},
seq,
})),
generatedAt,
readPosition: { lastReadSeq: stored },
},
});
});
return state;
}

test("opens where the reader left off and reports what they read", async ({
page,
dashboard,
}) => {
await page.setViewportSize(mobile);
const mock = await mockReadPositionConversation(page, {
eventCount: 60,
lastReadSeq: 20,
});
const { reported } = mock;

await page.goto(`${dashboard.baseURL}/conversations/${mock.conversationId}`);
const transcript = page.getByLabel("Conversation transcript");
const marker = transcript.getByRole("separator", { name: "New messages" });
const firstUnread = transcript.getByText("Transcript message 21 ");
const latest = transcript.getByText("Transcript message 59 ");
await expect(marker).toBeInViewport();
await expect(firstUnread).toBeInViewport();
await expect(latest).not.toBeInViewport();
await screenshot(page, "conversation-unread-marker", { view: "mobile" });

// Reading the first screen moves the shared position past the marker.
await expect.poll(() => reported.at(-1) ?? 0).toBeGreaterThan(20);
await page.getByRole("button", { name: "Jump to latest" }).click();
await expect(latest).toBeInViewport();
await expect.poll(() => reported.at(-1)).toBe(59);
expect(reported).toEqual([...reported].sort((left, right) => left - right));

// Readers who prefer the latest message open there, below the marker.
await page.goto(`${dashboard.baseURL}/settings`);
await page.getByRole("radio", { name: /Latest message/ }).click();
await page.goto(`${dashboard.baseURL}/conversations/${mock.conversationId}`);
await expect(latest).toBeInViewport();
await expect(marker).not.toBeInViewport();
});

test("opens at new messages after a cached reopen and keeps a quick exit", async ({
page,
dashboard,
}) => {
await page.setViewportSize(mobile);
const mock = await mockReadPositionConversation(page, {
eventCount: 40,
lastReadSeq: 39,
});
await page.goto(`${dashboard.baseURL}/conversations/${mock.conversationId}`);
const transcript = page.getByLabel("Conversation transcript");
const marker = transcript.getByRole("separator", { name: "New messages" });
await expect(transcript.getByText("Transcript message 39 ")).toBeInViewport();
await expect(marker).toHaveCount(0);

// New messages arrive while the reader is away. The reopen shows the cached
// copy first, then the fetch brings the new messages and the marker.
await page.getByRole("link", { name: "Back to conversations" }).click();
mock.eventCount = 60;
let releaseDetail = () => {};
mock.detailGate = new Promise((resolve) => {
releaseDetail = resolve;
});
await page
.getByRole("link", { name: /Read position/ })
.first()
.click();
await expect(transcript.getByText("Transcript message 39 ")).toBeVisible();
await expect(marker).toHaveCount(0);
releaseDetail();
await expect(marker).toBeInViewport();
await expect(transcript.getByText("Transcript message 40 ")).toBeInViewport();
await expect(
transcript.getByText("Transcript message 59 "),
).not.toBeInViewport();

// Leaving right after a scroll still saves the place.
const reportCount = mock.reported.length;
await transcript.getByText("Transcript message 52 ").scrollIntoViewIfNeeded();
await page.getByRole("link", { name: "Back to conversations" }).click();
await expect
.poll(() => mock.reported.slice(reportCount).at(-1) ?? 0)
.toBeGreaterThanOrEqual(52);
});
2 changes: 2 additions & 0 deletions packages/junior-dashboard/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from "./auth";
import { isAuthPath, type AuthenticatedRoute } from "./authenticated-routes";
import { resetMockConversationArchiveState } from "./mock-reporting/fixtures";
import { resetMockReadPositions } from "./mock-reporting/read-positions";
import { createMockReportingApi } from "./mock-reporting/routes";
import {
DASHBOARD_AVATAR_HEADER_PATH,
Expand Down Expand Up @@ -71,6 +72,7 @@ const mockDisplayNamesByEmail = new Map<string, string>();
export function resetMockDashboardState(): void {
mockDisplayNamesByEmail.clear();
resetMockConversationArchiveState();
resetMockReadPositions();
}

export interface JuniorDashboardOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ import {
} from "../review-desk/reviewItemFormat";
import { useConfirmUnsentAnswers } from "../review-desk/UnsentAnswers";
import { Transcript } from "./TranscriptView";
import {
readConversationStartPosition,
useMarkConversationRead,
useReadPositionAtOpen,
} from "./transcriptReadPosition";
import { TranscriptLoading } from "./TranscriptLoading";
import type { TranscriptViewMode } from "./transcriptRenderModel";
import {
Expand Down Expand Up @@ -166,6 +171,8 @@ export function ConversationPage(props: {
const conversations = buildConversations(summaries);
const detail = useConversationData(conversationId);
const archive = useArchiveConversation(conversationId);
const markRead = useMarkConversationRead(conversationId);
const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
const feedConversation = conversations.find(
(item) => item.id === conversationId,
);
Expand Down Expand Up @@ -484,9 +491,11 @@ export function ConversationPage(props: {
hasPreviousPage={detail.hasPreviousPage}
historyError={detail.historyError}
historyVersion={detail.historyVersion}
lastReadSeq={readAtOpen.lastReadSeq}
live={live}
loadingPreviousPage={detail.isLoadingPreviousPage}
onLoadPreviousPage={detail.loadPreviousPage}
onRead={markRead}
onSelectedEventSeqChange={setSelectedEventSeq}
pendingMessages={detail.inlineMessages}
pinRequestVersion={pinRequestVersion}
Expand All @@ -497,8 +506,10 @@ export function ConversationPage(props: {
search={search}
onSearchChange={setSearch}
selectedEventSeq={selectedEventSeq}
startAtUnread={readConversationStartPosition() === "unread"}
transcript={transcript}
turnContextPanel={turnContextPanel}
unread={readAtOpen.unread}
view={view}
/>
</>
Expand Down
Loading
Loading