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
89 changes: 89 additions & 0 deletions e2e/citation-dedupe.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { expect, test } from "@playwright/test"

test.setTimeout(60_000)

test("keeps duplicate source labels clickable for separate documents", async ({
context,
page,
}) => {
let firstSourceChunkRequests = 0
let secondSourceChunkRequests = 0

await context.addCookies([
{
name: "better-auth.session_token",
value: "playwright",
url: "http://localhost:3000",
},
])

await page.route("**/api/sources/source_first/chunks**", async (route) => {
firstSourceChunkRequests += 1
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
chunks: [
{
chunkId: "chunk_first",
documentId: "doc_first",
sectionPath: "Root",
type: "text",
content: "First report source content.",
sourceTitle: "report.pdf",
},
],
pagination: {
page: 1,
pageSize: 50,
total: 1,
totalPages: 1,
},
}),
})
})
await page.route("**/api/sources/source_second/chunks**", async (route) => {
secondSourceChunkRequests += 1
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
chunks: [
{
chunkId: "chunk_second",
documentId: "doc_second",
sectionPath: "Root",
type: "text",
content: "Second report source content.",
sourceTitle: "report.pdf",
},
],
pagination: {
page: 1,
pageSize: 50,
total: 1,
totalPages: 1,
},
}),
})
})

await page.goto("/e2e/citation-dedupe")

const chatPanel = page.getByTestId("desktop-chat-panel")
const duplicateSourceLinks = chatPanel.getByRole("button", {
name: "Open source report.pdf",
})
await expect(duplicateSourceLinks).toHaveCount(2)

const secondSourceRequestsBeforeClick = secondSourceChunkRequests
await duplicateSourceLinks.nth(1).click()
await expect(page.getByText("Second report source content.")).toBeVisible()
expect(secondSourceChunkRequests).toBeGreaterThan(
secondSourceRequestsBeforeClick,
)

await duplicateSourceLinks.first().click()
await expect(page.getByText("First report source content.")).toBeVisible()
expect(firstSourceChunkRequests).toBeGreaterThan(0)
})
19 changes: 13 additions & 6 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { defineConfig, devices } from "@playwright/test"

const shouldUseExternalWebServer =
process.env.PLAYWRIGHT_EXTERNAL_WEB_SERVER === "1"

export default defineConfig({
testDir: "./e2e",
testMatch: "**/*.e2e.ts",
Expand All @@ -17,10 +20,14 @@ export default defineConfig({
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
timeout: 60_000,
},
...(shouldUseExternalWebServer
? {}
: {
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
timeout: 60_000,
},
}),
})
83 changes: 83 additions & 0 deletions src/app/e2e/citation-dedupe/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { notFound } from "next/navigation"

import { WorkspaceShell } from "@/components/workspace-shell"
import type { ChatMessageView, ChatThreadView } from "@/domains/chat/types"
import type { SourceView } from "@/domains/sources/types"

const duplicateTitleSources: SourceView[] = [
{
id: "source_first",
title: "report.pdf",
mimeType: "application/pdf",
status: "ready",
documentId: "doc_first",
chunkCount: 1,
},
{
id: "source_second",
title: "report.pdf",
mimeType: "application/pdf",
status: "ready",
documentId: "doc_second",
chunkCount: 1,
},
]

const chatThreads: ChatThreadView[] = [
{
id: "thread_1",
title: "Duplicate source labels",
createdAt: "2026-05-17T00:00:00.000Z",
updatedAt: "2026-05-17T00:00:00.000Z",
},
]

const chatMessages: ChatMessageView[] = [
{
id: "assistant_1",
role: "assistant",
content: "Both reports are relevant to the answer.",
citations: [
{
chunkType: "text",
score: 0.91,
source: {
documentId: "doc_first",
sourceFileName: "report.pdf",
sectionPath: "Root",
},
},
{
chunkType: "text",
score: 0.89,
source: {
documentId: "doc_second",
sourceFileName: "report.pdf",
sectionPath: "Root",
},
},
],
},
]

export default function CitationDedupeTestPage() {
if (process.env.NODE_ENV === "production") notFound()

return (
<WorkspaceShell
user={{
id: "user_playwright",
name: "Playwright",
email: "playwright@example.com",
}}
workspace={{
id: "workspace_playwright",
namespace: "notebook-playwright",
}}
sources={duplicateTitleSources}
chatThreads={chatThreads}
activeChatThreadId="thread_1"
chatMessages={chatMessages}
/>
)
}
17 changes: 8 additions & 9 deletions src/app/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ vi.mock("@/domains/workspace/initial-state", () => ({
}))

import { HomeContent } from "./page"
import { makeWorkspaceInitialStateFailureFixture } from "@/test/workspace-initial-state-failure-fixture"

describe("Home", () => {
afterEach(() => {
Expand All @@ -42,18 +43,16 @@ describe("Home", () => {
})

it("logs a readable page-load failure before rethrowing", async () => {
mocks.loadWorkspaceShellInitialState.mockRejectedValue(
new Error("database connection refused"),
)
const failure = makeWorkspaceInitialStateFailureFixture()

await expect(HomeContent()).rejects.toThrow(
"Workspace initial state failed: database connection refused",
)
mocks.loadWorkspaceShellInitialState.mockRejectedValue(failure.error)

await expect(HomeContent()).rejects.toThrow(failure.boundaryMessage)
expect(mocks.logger.error).toHaveBeenCalledWith(
"workspace: initial state failed",
expect.objectContaining({
error: expect.stringContaining("database connection refused"),
}),
{
error: failure.rootCauseMessage,
},
)
})
})
4 changes: 2 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Suspense } from "react"
import { WorkspaceShell } from "@/components/workspace-shell"
import { loadWorkspaceShellInitialState } from "@/domains/workspace/initial-state"
import { effectOperation } from "@/lib/effect-operation"
import { formatUnknownForLog } from "@/lib/format-log-value"
import { summarizeUnknownError } from "@/lib/format-log-value"
import { logger } from "@/lib/logger"
import { connection } from "next/server"

Expand All @@ -27,7 +27,7 @@ async function loadWorkspaceInitialState(): ReturnType<
return await loadWorkspaceShellInitialState()
} catch (error) {
logger.error("workspace: initial state failed", {
error: formatUnknownForLog(error),
error: summarizeUnknownError(error),
})
throw effectOperation.createBoundaryError(
"Workspace initial state failed",
Expand Down
77 changes: 69 additions & 8 deletions src/components/chat-message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import type {
ChatMessageView,
} from "@/domains/chat/types";

type DisplayCitation = {
readonly citation: ChatCitationView;
readonly citationId: string;
readonly label: string;
};

export type ChatMessageListProps = {
readonly isDisabled?: boolean;
readonly isSending?: boolean;
Expand Down Expand Up @@ -230,30 +236,30 @@ function MessageBubble({
);
}

const displayCitations = getDisplayCitations(
message,
sourceTitlesByDocumentId,
);

return (
<div className="flex min-w-0 flex-col items-start">
<div className="max-w-[92%] overflow-hidden rounded-2xl rounded-tl-sm border border-border/70 bg-card px-3 py-2.5 text-sm leading-relaxed text-foreground shadow-xs sm:max-w-[90%] sm:px-4 sm:py-3">
<p className="whitespace-pre-wrap break-words">{message.content}</p>
{message.citations && message.citations.length > 0 && (
{displayCitations.length > 0 && (
<div className="mt-3 border-t border-border/70 pt-2.5">
<p className="mb-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Sources used
</p>
<div className="flex flex-wrap gap-1.5">
{message.citations.map((cite, i) => {
const citationId = chatPanelModel.getCitationId(message.id, i);
const label = chatPanelModel.getCitationLabel(
cite,
sourceTitlesByDocumentId,
);
{displayCitations.map(({ citation, citationId, label }) => {
const isPending = citationId === pendingCitationId;

return (
<button
key={citationId}
type="button"
disabled={!onCitationClick || isPending}
onClick={() => onCitationClick?.(cite, citationId)}
onClick={() => onCitationClick?.(citation, citationId)}
className="inline-flex max-w-full cursor-pointer items-center gap-1 whitespace-normal rounded-sm px-0.5 py-0 text-left text-[11px] font-semibold text-primary underline decoration-primary/45 underline-offset-4 transition-colors hover:text-primary/80 hover:decoration-primary focus:outline-none focus:ring-4 focus:ring-ring/15 focus:ring-offset-2 focus:ring-offset-background disabled:cursor-wait disabled:opacity-75"
aria-label={`Open source ${label}`}
>
Expand All @@ -269,3 +275,58 @@ function MessageBubble({
</div>
);
}

function getDisplayCitations(
message: ChatMessageView,
sourceTitlesByDocumentId: Readonly<Record<string, string>>,
): readonly DisplayCitation[] {
const seenKeys = new Set<string>();
const displayCitations: DisplayCitation[] = [];

for (const [index, citation] of (message.citations ?? []).entries()) {
const label = chatPanelModel.getCitationLabel(
citation,
sourceTitlesByDocumentId,
);
const key = getCitationDisplayKey(citation, label);
if (seenKeys.has(key)) continue;

seenKeys.add(key);
displayCitations.push({
citation,
citationId: chatPanelModel.getCitationId(message.id, index),
label,
});
}

return displayCitations;
}

function getCitationDisplayKey(
citation: ChatCitationView,
label: string,
): string {
const documentId = getTrimmedCitationField(citation.source.documentId);
if (documentId) {
return joinCitationDisplayKeyParts(["document", documentId, label]);
}

return joinCitationDisplayKeyParts([
"fallback",
getTrimmedCitationField(citation.source.sourceFileName) ?? "",
getTrimmedCitationField(citation.source.sectionPath) ?? "",
getTrimmedCitationField(citation.description) ?? "",
label,
]);
}

function getTrimmedCitationField(value: string | undefined): string | null {
const trimmedValue = value?.trim() ?? "";
return trimmedValue.length > 0 ? trimmedValue : null;
}

function joinCitationDisplayKeyParts(parts: readonly string[]): string {
return parts
.map((part: string): string => `${part.length}:${part}`)
.join("|");
}
Loading
Loading