diff --git a/e2e/citation-dedupe.e2e.ts b/e2e/citation-dedupe.e2e.ts
new file mode 100644
index 0000000..78de15e
--- /dev/null
+++ b/e2e/citation-dedupe.e2e.ts
@@ -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)
+})
diff --git a/e2e/workspace-responsive.e2e.ts b/e2e/workspace-responsive.e2e.ts
new file mode 100644
index 0000000..7abd49b
--- /dev/null
+++ b/e2e/workspace-responsive.e2e.ts
@@ -0,0 +1,66 @@
+import { expect, test } from "@playwright/test"
+
+test("fits desktop notebook panels inside a 13-inch viewport", async ({
+ context,
+ page,
+}) => {
+ await context.addCookies([
+ {
+ name: "better-auth.session_token",
+ value: "playwright",
+ url: "http://localhost:3000",
+ },
+ ])
+ await page.setViewportSize({ width: 1280, height: 832 })
+ await page.goto("/e2e/citation-dedupe")
+
+ const layout = page.getByTestId("desktop-panel-layout")
+ const chatPanel = page.getByTestId("desktop-chat-panel")
+ await expect(layout).toBeVisible()
+
+ await expect
+ .poll(async () => {
+ return layout.evaluate((element) => {
+ return element.scrollWidth <= element.clientWidth
+ })
+ })
+ .toBe(true)
+
+ const measurements = await layout.evaluate((element) => {
+ return {
+ clientWidth: element.clientWidth,
+ scrollWidth: element.scrollWidth,
+ }
+ })
+ const chatBounds = await chatPanel.boundingBox()
+
+ expect(measurements.scrollWidth).toBeLessThanOrEqual(
+ measurements.clientWidth,
+ )
+ expect(chatBounds?.x).toBeGreaterThanOrEqual(0)
+ expect((chatBounds?.x ?? 0) + (chatBounds?.width ?? 0)).toBeLessThanOrEqual(
+ measurements.clientWidth,
+ )
+})
+
+test("uses the tabbed notebook layout below the desktop panel minimum", async ({
+ context,
+ page,
+}) => {
+ await context.addCookies([
+ {
+ name: "better-auth.session_token",
+ value: "playwright",
+ url: "http://localhost:3000",
+ },
+ ])
+ await page.setViewportSize({ width: 1099, height: 832 })
+ await page.goto("/e2e/citation-dedupe")
+
+ await expect(page.getByTestId("desktop-panel-layout")).toBeHidden()
+ await expect(
+ page.getByRole("tab", {
+ name: /Assistant/u,
+ }),
+ ).toBeVisible()
+})
diff --git a/playwright.config.ts b/playwright.config.ts
index e59bfbf..77f738b 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -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",
@@ -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,
+ },
+ }),
})
diff --git a/src/app/e2e/citation-dedupe/page.tsx b/src/app/e2e/citation-dedupe/page.tsx
new file mode 100644
index 0000000..8304a0e
--- /dev/null
+++ b/src/app/e2e/citation-dedupe/page.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/app/page.test.ts b/src/app/page.test.ts
index f3d9612..f7e3525 100644
--- a/src/app/page.test.ts
+++ b/src/app/page.test.ts
@@ -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(() => {
@@ -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,
+ },
)
})
})
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 0838292..2ebc597 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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"
@@ -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",
diff --git a/src/components/chat-message-list.tsx b/src/components/chat-message-list.tsx
index 1e8aa06..33703b5 100644
--- a/src/components/chat-message-list.tsx
+++ b/src/components/chat-message-list.tsx
@@ -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;
@@ -230,22 +236,22 @@ function MessageBubble({
);
}
+ const displayCitations = getDisplayCitations(
+ message,
+ sourceTitlesByDocumentId,
+ );
+
return (
{message.content}
- {message.citations && message.citations.length > 0 && (
+ {displayCitations.length > 0 && (
Sources used
- {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 (
@@ -253,7 +259,7 @@ function MessageBubble({
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}`}
>
@@ -269,3 +275,58 @@ function MessageBubble({
);
}
+
+function getDisplayCitations(
+ message: ChatMessageView,
+ sourceTitlesByDocumentId: Readonly
>,
+): readonly DisplayCitation[] {
+ const seenKeys = new Set();
+ 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("|");
+}
diff --git a/src/components/chat-panel.test.ts b/src/components/chat-panel.test.ts
index d716e1c..bffe21a 100644
--- a/src/components/chat-panel.test.ts
+++ b/src/components/chat-panel.test.ts
@@ -101,6 +101,128 @@ describe("ChatPanel", () => {
);
});
+ it("deduplicates assistant sources by displayed label while keeping the first click target", async () => {
+ const user = userEvent.setup();
+ const onCitationClick = vi.fn();
+ const firstCitation = {
+ chunkType: "text",
+ score: 0.9,
+ description: "document-wDh6N9QBSgbdAjjweXN8xbw0vTTo5J.pdf",
+ source: {
+ documentId: "doc_micron_q1",
+ sourceFileName: "document-wDh6N9QBSgbdAjjweXN8xbw0vTTo5J.pdf",
+ sectionPath: "Root",
+ },
+ } as const;
+
+ render(
+ React.createElement(C, {
+ sourceTitlesByDocumentId: {
+ doc_micron_q1: "Micron Q1-26 Earnings Deck_R.pdf",
+ doc_micron_q2: "Q2 2026 Earnings Deck.pdf",
+ },
+ messages: [
+ {
+ id: "assistant_1",
+ role: "assistant",
+ content: "Diluted EPS is discussed in the earnings decks.",
+ citations: [
+ firstCitation,
+ {
+ chunkType: "text",
+ score: 0.86,
+ source: {
+ documentId: "doc_micron_q1",
+ sourceFileName: "Micron Q1-26 Earnings Deck_R.pdf",
+ sectionPath: "Root",
+ },
+ },
+ {
+ chunkType: "text",
+ score: 0.82,
+ source: {
+ documentId: "doc_micron_q2",
+ sourceFileName: "Q2 2026 Earnings Deck.pdf",
+ sectionPath: "Mark Murphy / Non-GAAP operating results",
+ },
+ },
+ ],
+ },
+ ],
+ onCitationClick,
+ }),
+ );
+
+ const duplicatedSourceLinks = screen.getAllByRole("button", {
+ name: "Open source Micron Q1-26 Earnings Deck_R.pdf",
+ });
+
+ expect(duplicatedSourceLinks).toHaveLength(1);
+ expect(
+ screen.getByRole("button", {
+ name: "Open source Q2 2026 Earnings Deck.pdf ยท Mark Murphy / Non-GAAP operating results",
+ }),
+ ).toBeTruthy();
+
+ await user.click(duplicatedSourceLinks[0]);
+
+ expect(onCitationClick).toHaveBeenCalledWith(firstCitation, "assistant_1:0");
+ });
+
+ it("keeps separate source links when different documents share one displayed label", async () => {
+ const user = userEvent.setup();
+ const onCitationClick = vi.fn();
+ const firstCitation = {
+ chunkType: "text",
+ score: 0.9,
+ source: {
+ documentId: "doc_first",
+ sourceFileName: "report.pdf",
+ sectionPath: "Root",
+ },
+ } as const;
+ const secondCitation = {
+ chunkType: "text",
+ score: 0.88,
+ source: {
+ documentId: "doc_second",
+ sourceFileName: "report.pdf",
+ sectionPath: "Root",
+ },
+ } as const;
+
+ render(
+ React.createElement(C, {
+ sourceTitlesByDocumentId: {
+ doc_first: "report.pdf",
+ doc_second: "report.pdf",
+ },
+ messages: [
+ {
+ id: "assistant_1",
+ role: "assistant",
+ content: "Both reports are relevant.",
+ citations: [firstCitation, secondCitation],
+ },
+ ],
+ onCitationClick,
+ }),
+ );
+
+ const duplicatedLabelLinks = screen.getAllByRole("button", {
+ name: "Open source report.pdf",
+ });
+
+ expect(duplicatedLabelLinks).toHaveLength(2);
+
+ await user.click(duplicatedLabelLinks[1]);
+
+ expect(onCitationClick).toHaveBeenCalledWith(
+ secondCitation,
+ "assistant_1:1",
+ );
+ });
+
it("renders citation links as button-backed links with per-citation loading feedback", async () => {
const user = userEvent.setup();
const onCitationClick = vi.fn();
diff --git a/src/components/chat-panel.tsx b/src/components/chat-panel.tsx
index 51eef5e..47c8c5e 100644
--- a/src/components/chat-panel.tsx
+++ b/src/components/chat-panel.tsx
@@ -94,7 +94,7 @@ export function ChatPanel({
return (
diff --git a/src/components/parsed-chunk-card.test.ts b/src/components/parsed-chunk-card.test.ts
index 350a1fb..b269c08 100644
--- a/src/components/parsed-chunk-card.test.ts
+++ b/src/components/parsed-chunk-card.test.ts
@@ -12,7 +12,7 @@ describe("ParsedChunkCard", () => {
});
it("renders text chunks with source, summary, content, and keywords", () => {
- const { container } = render(
+ render(
React.createElement(ParsedChunkCard, {
chunk: {
chunkId: "text_1",
@@ -108,6 +108,27 @@ describe("ParsedChunkCard", () => {
expect(screen.getByTestId("chunk-card-shell-text_1").getAttribute("role")).toBeNull();
});
+ it("hides the original file button when a chunk has no page numbers", () => {
+ render(
+ React.createElement(ParsedChunkCard, {
+ chunk: {
+ chunkId: "text_1",
+ type: "text",
+ content: "Revenue details do not include page metadata.",
+ sourceTitle: "report.pdf",
+ },
+ isFocused: false,
+ isOriginalPreviewAvailable: true,
+ onChunkClick: vi.fn(),
+ onReferenceClick: vi.fn(),
+ }),
+ );
+
+ expect(
+ screen.queryByRole("button", { name: /original file/i }),
+ ).toBeNull();
+ });
+
it("keeps original file buttons quiet when preview is not supported", async () => {
const user = userEvent.setup();
const chunk = {
diff --git a/src/components/parsed-chunk-card.tsx b/src/components/parsed-chunk-card.tsx
index a75d41b..386d596 100644
--- a/src/components/parsed-chunk-card.tsx
+++ b/src/components/parsed-chunk-card.tsx
@@ -128,6 +128,7 @@ function ChunkSourcePanel({
readonly onChunkClick?: (chunk: ParsedChunkView) => void;
}): ReactNode {
const sourceMetadata = parsedChunkCardModel.getSourceMetadata(chunk);
+ const firstPageNumber = getFirstValidPageNumber(chunk);
return (
- {onChunkClick ? (
+ {onChunkClick && firstPageNumber !== null ? (
@@ -180,12 +182,24 @@ function ChunkSourcePanel({
);
}
+function getFirstValidPageNumber(chunk: ParsedChunkView): number | null {
+ const pageNums = chunk.pageNums ?? [];
+ const validPageNums = pageNums.filter(
+ (pageNum) => Number.isFinite(pageNum) && pageNum > 0,
+ );
+ if (validPageNums.length === 0) return null;
+
+ return Math.min(...validPageNums);
+}
+
function OpenOriginalButton({
chunk,
+ firstPageNumber,
isOriginalPreviewAvailable,
onChunkClick,
}: {
readonly chunk: ParsedChunkView;
+ readonly firstPageNumber: number;
readonly isOriginalPreviewAvailable: boolean;
readonly onChunkClick: (chunk: ParsedChunkView) => void;
}): ReactNode {
@@ -203,24 +217,18 @@ function OpenOriginalButton({
onClick={() => onChunkClick(chunk)}
>
- {getOpenOriginalButtonLabel(chunk, isOriginalPreviewAvailable)}
+ {getOpenOriginalButtonLabel(firstPageNumber, isOriginalPreviewAvailable)}
);
}
function getOpenOriginalButtonLabel(
- chunk: ParsedChunkView,
+ firstPageNumber: number,
isOriginalPreviewAvailable: boolean,
): string {
if (!isOriginalPreviewAvailable) return "Open original file";
- const pageNums = chunk.pageNums ?? [];
- const validPageNums = pageNums.filter(
- (pageNum) => Number.isFinite(pageNum) && pageNum > 0,
- );
- if (validPageNums.length === 0) return "Open original file";
-
- return `Open page ${Math.min(...validPageNums)} in original file`;
+ return `Open page ${firstPageNumber} in original file`;
}
function ChunkSummaryPanel({
diff --git a/src/components/workspace-citation-focus.test.ts b/src/components/workspace-citation-focus.test.ts
index feb1bca..2d2803a 100644
--- a/src/components/workspace-citation-focus.test.ts
+++ b/src/components/workspace-citation-focus.test.ts
@@ -93,6 +93,82 @@ describe("useWorkspaceCitationFocus", () => {
requestId: 1,
});
});
+
+ it("opens the source without fetching chunks when the citation has no exact target hint", async () => {
+ const fetchChunks = vi.fn(async () => [prefetchedChunk]);
+ const selectSource = vi.fn();
+ const sourceOnlyCitation: ChatCitationView = {
+ chunkType: "text",
+ score: 0.5,
+ source: {
+ documentId: "document_1",
+ sourceFileName: "Contract.pdf",
+ sectionPath: "Root",
+ },
+ };
+
+ const { result } = renderHook(() =>
+ useWorkspaceCitationFocus({
+ fetchChunks,
+ initialPrefetchedChunksBySourceId: {
+ source_1: [prefetchedChunk],
+ },
+ onSelectSource: selectSource,
+ selectedSourceId: null,
+ sources: [readySource],
+ }),
+ { wrapper: createSWRWrapper },
+ );
+
+ await act(async () => {
+ await result.current.handleCitationClick(
+ sourceOnlyCitation,
+ "message_1:0",
+ );
+ });
+
+ expect(fetchChunks).not.toHaveBeenCalled();
+ expect(selectSource).toHaveBeenLastCalledWith("source_1");
+ expect(result.current.prefetchedChunksBySourceId).toEqual({});
+ expect(result.current.focusedChunk.chunkId).toBeNull();
+ expect(result.current.pendingCitationId).toBeNull();
+ });
+
+ it("reuses cached chunks for a different source without refetching", async () => {
+ const fetchChunks = vi.fn(async () => [prefetchedChunk]);
+ const selectSource = vi.fn();
+ const otherSource: SourceView = {
+ id: "source_2",
+ title: "Other.pdf",
+ mimeType: "application/pdf",
+ status: "ready",
+ documentId: "document_2",
+ };
+
+ const { result } = renderHook(() =>
+ useWorkspaceCitationFocus({
+ fetchChunks,
+ initialPrefetchedChunksBySourceId: {
+ source_1: [prefetchedChunk],
+ },
+ onSelectSource: selectSource,
+ selectedSourceId: "source_2",
+ sources: [readySource, otherSource],
+ }),
+ { wrapper: createSWRWrapper },
+ );
+
+ await act(async () => {
+ await result.current.handleCitationClick(citation, "message_1:0");
+ });
+
+ expect(fetchChunks).not.toHaveBeenCalled();
+ expect(selectSource).toHaveBeenLastCalledWith("source_1");
+ expect(result.current.focusedChunk.chunkId).toBe("chunk_1");
+ expect(Object.keys(result.current.prefetchedChunksBySourceId)).toContain(
+ "source_1",
+ );
+ });
});
function createSWRWrapper({
diff --git a/src/components/workspace-citation-focus.ts b/src/components/workspace-citation-focus.ts
index d428bf0..711e912 100644
--- a/src/components/workspace-citation-focus.ts
+++ b/src/components/workspace-citation-focus.ts
@@ -85,7 +85,7 @@ export function useWorkspaceCitationFocus({
onSelectSource(sourceId)
if (sourceId) {
setPrefetchedChunksBySourceId((current) =>
- removeRecordKey(current, sourceId),
+ workspaceCitationState.removePrefetchedChunks(current, sourceId),
)
}
requestChunkFocus(null)
@@ -119,6 +119,37 @@ export function useWorkspaceCitationFocus({
return
}
+ if (!workspaceCitationState.hasExactCitationTargetHint(citation)) {
+ setPrefetchedChunksBySourceId((current) =>
+ workspaceCitationState.removePrefetchedChunks(current, source.id),
+ )
+ if (selectedSourceId !== source.id) onSelectSource(source.id)
+ requestChunkFocus(null)
+ return
+ }
+
+ const cachedChunks = prefetchedChunksBySourceId[source.id]
+ if (cachedChunks) {
+ const cachedFocusId =
+ workspaceCitationState.getLoadedCitationChunkId({
+ citation,
+ selectedSourceId: source.id,
+ sourceId: source.id,
+ selectedChunks: cachedChunks,
+ hasMoreSelectedChunks: false,
+ })
+ setPrefetchedChunksBySourceId((current) =>
+ workspaceCitationState.upsertPrefetchedChunks(
+ current,
+ source.id,
+ cachedChunks,
+ ),
+ )
+ if (selectedSourceId !== source.id) onSelectSource(source.id)
+ requestChunkFocus(cachedFocusId)
+ return
+ }
+
requestChunkFocus(null)
const chunks = await fetchChunks(source.id)
setPrefetchedChunksBySourceId((current) =>
@@ -148,6 +179,7 @@ export function useWorkspaceCitationFocus({
fetchChunks,
hasMoreSelectedChunks,
onSelectSource,
+ prefetchedChunksBySourceId,
requestChunkFocus,
selectedChunks,
selectedSourceId,
@@ -170,12 +202,3 @@ export function useWorkspaceCitationFocus({
selectedSource,
}
}
-
-function removeRecordKey
(
- record: Readonly>,
- key: string,
-): Record {
- const nextRecord = { ...record }
- delete nextRecord[key]
- return nextRecord
-}
diff --git a/src/components/workspace-citation-state.test.ts b/src/components/workspace-citation-state.test.ts
index 2ffc308..ce5851e 100644
--- a/src/components/workspace-citation-state.test.ts
+++ b/src/components/workspace-citation-state.test.ts
@@ -1,10 +1,23 @@
import { describe, expect, it } from "vitest"
-import { workspaceCitationState } from "./workspace-citation-state"
+import {
+ maxPrefetchedChunkSources,
+ workspaceCitationState,
+} from "./workspace-citation-state"
import type { ChatCitationView } from "@/domains/chat/types"
import type { ParsedChunkView } from "@/domains/chunks/types"
import type { SourceView } from "@/domains/sources/types"
+function makeChunk(chunkId: string, documentId: string): ParsedChunkView {
+ return {
+ chunkId,
+ documentId,
+ type: "text",
+ content: `${chunkId} content`,
+ sourceTitle: `${documentId}.pdf`,
+ }
+}
+
describe("workspaceCitationState", () => {
it("finds the Source and loaded Parsed Chunk for a Citation", () => {
const source: SourceView = {
@@ -77,4 +90,154 @@ describe("workspaceCitationState", () => {
}),
).toBeNull()
})
+
+ describe("hasExactCitationTargetHint", () => {
+ it("returns true when the citation has content text", () => {
+ const citation: ChatCitationView = {
+ chunkType: "text",
+ score: 0.5,
+ content: "Revenue grew this quarter.",
+ source: { documentId: "document_1", sourceFileName: "Contract.pdf" },
+ }
+
+ expect(
+ workspaceCitationState.hasExactCitationTargetHint(citation),
+ ).toBe(true)
+ })
+
+ it("returns true when the citation has a meaningful section path", () => {
+ const citation: ChatCitationView = {
+ chunkType: "text",
+ score: 0.5,
+ source: {
+ documentId: "document_1",
+ sectionPath: "Revenue",
+ },
+ }
+
+ expect(
+ workspaceCitationState.hasExactCitationTargetHint(citation),
+ ).toBe(true)
+ })
+
+ it("returns false for source-only citations with no useful target hint", () => {
+ const citation: ChatCitationView = {
+ chunkType: "text",
+ score: 0.5,
+ source: {
+ documentId: "document_1",
+ sourceFileName: "Contract.pdf",
+ sectionPath: "Root",
+ },
+ }
+
+ expect(
+ workspaceCitationState.hasExactCitationTargetHint(citation),
+ ).toBe(false)
+ })
+
+ it("returns false for empty content and missing section path", () => {
+ const citation: ChatCitationView = {
+ chunkType: "text",
+ score: 0.5,
+ content: " ",
+ source: { documentId: "document_1" },
+ }
+
+ expect(
+ workspaceCitationState.hasExactCitationTargetHint(citation),
+ ).toBe(false)
+ })
+ })
+
+ describe("upsertPrefetchedChunks bounded LRU", () => {
+ it("keeps insertion order so the newest source appears last", () => {
+ const next = workspaceCitationState.upsertPrefetchedChunks(
+ workspaceCitationState.upsertPrefetchedChunks({}, "source_a", [
+ makeChunk("a", "doc_a"),
+ ]),
+ "source_b",
+ [makeChunk("b", "doc_b")],
+ )
+
+ expect(Object.keys(next)).toEqual(["source_a", "source_b"])
+ })
+
+ it("refreshes recency for an existing source by moving it to the end", () => {
+ const seed = workspaceCitationState.upsertPrefetchedChunks(
+ workspaceCitationState.upsertPrefetchedChunks({}, "source_a", [
+ makeChunk("a", "doc_a"),
+ ]),
+ "source_b",
+ [makeChunk("b", "doc_b")],
+ )
+
+ const refreshed = workspaceCitationState.upsertPrefetchedChunks(
+ seed,
+ "source_a",
+ [makeChunk("a", "doc_a"), makeChunk("a2", "doc_a")],
+ )
+
+ expect(Object.keys(refreshed)).toEqual(["source_b", "source_a"])
+ expect(refreshed["source_a"]?.length).toBe(2)
+ })
+
+ it(`evicts the oldest entries when more than ${maxPrefetchedChunkSources} sources are stored`, () => {
+ const seeded = Array.from({ length: maxPrefetchedChunkSources }).reduce<
+ Readonly>
+ >(
+ (acc, _value, index) =>
+ workspaceCitationState.upsertPrefetchedChunks(
+ acc,
+ `source_${index}`,
+ [makeChunk(`chunk_${index}`, `doc_${index}`)],
+ ),
+ {},
+ )
+
+ const next = workspaceCitationState.upsertPrefetchedChunks(
+ seeded,
+ "source_overflow",
+ [makeChunk("chunk_overflow", "doc_overflow")],
+ )
+
+ const keys = Object.keys(next)
+ expect(keys.length).toBe(maxPrefetchedChunkSources)
+ expect(keys).not.toContain("source_0")
+ expect(keys).toContain("source_overflow")
+ expect(keys[keys.length - 1]).toBe("source_overflow")
+ })
+ })
+
+ describe("removePrefetchedChunks", () => {
+ it("returns the same record when the source is not cached", () => {
+ const seed = workspaceCitationState.upsertPrefetchedChunks({}, "source_a", [
+ makeChunk("a", "doc_a"),
+ ])
+
+ const next = workspaceCitationState.removePrefetchedChunks(
+ seed,
+ "missing",
+ )
+
+ expect(next).toBe(seed)
+ })
+
+ it("removes only the requested source", () => {
+ const seed = workspaceCitationState.upsertPrefetchedChunks(
+ workspaceCitationState.upsertPrefetchedChunks({}, "source_a", [
+ makeChunk("a", "doc_a"),
+ ]),
+ "source_b",
+ [makeChunk("b", "doc_b")],
+ )
+
+ const next = workspaceCitationState.removePrefetchedChunks(
+ seed,
+ "source_a",
+ )
+
+ expect(Object.keys(next)).toEqual(["source_b"])
+ })
+ })
})
diff --git a/src/components/workspace-citation-state.ts b/src/components/workspace-citation-state.ts
index 224fa5e..d8331af 100644
--- a/src/components/workspace-citation-state.ts
+++ b/src/components/workspace-citation-state.ts
@@ -24,13 +24,22 @@ type WorkspaceCitationStateModule = {
readonly getLoadedCitationChunkId: (
input: LoadedCitationChunkInput,
) => string | null
+ readonly hasExactCitationTargetHint: (
+ citation: ChatCitationView,
+ ) => boolean
readonly upsertPrefetchedChunks: (
current: PrefetchedChunksBySourceId,
sourceId: string,
chunks: readonly ParsedChunkView[],
) => Record
+ readonly removePrefetchedChunks: (
+ current: PrefetchedChunksBySourceId,
+ sourceId: string,
+ ) => PrefetchedChunksBySourceId
}
+export const maxPrefetchedChunkSources = 5
+
function findCitationSource(
sources: readonly SourceView[],
citation: ChatCitationView,
@@ -55,19 +64,63 @@ function getLoadedCitationChunkId(
return focusedChunk?.chunkId ?? null
}
+function hasExactCitationTargetHint(citation: ChatCitationView): boolean {
+ if (typeof citation.content === "string" && citation.content.trim().length > 0) {
+ return true
+ }
+
+ const sectionPath = citation.source.sectionPath
+ if (typeof sectionPath !== "string") return false
+
+ const trimmed = sectionPath.trim()
+ if (trimmed.length === 0) return false
+ if (trimmed === "Root") return false
+
+ return true
+}
+
function upsertPrefetchedChunks(
current: PrefetchedChunksBySourceId,
sourceId: string,
chunks: readonly ParsedChunkView[],
): Record {
- return {
- ...current,
- [sourceId]: [...chunks],
+ const next: Record = {}
+
+ for (const [existingSourceId, existingChunks] of Object.entries(current)) {
+ if (existingSourceId === sourceId) continue
+ next[existingSourceId] = existingChunks
+ }
+ next[sourceId] = [...chunks]
+
+ const orderedKeys = Object.keys(next)
+ if (orderedKeys.length <= maxPrefetchedChunkSources) return next
+
+ const evictionCount = orderedKeys.length - maxPrefetchedChunkSources
+ for (let index = 0; index < evictionCount; index += 1) {
+ delete next[orderedKeys[index]!]
+ }
+
+ return next
+}
+
+function removePrefetchedChunks(
+ current: PrefetchedChunksBySourceId,
+ sourceId: string,
+): PrefetchedChunksBySourceId {
+ if (!Object.prototype.hasOwnProperty.call(current, sourceId)) return current
+
+ const next: Record = {}
+ for (const [existingSourceId, existingChunks] of Object.entries(current)) {
+ if (existingSourceId === sourceId) continue
+ next[existingSourceId] = existingChunks
}
+ return next
}
export const workspaceCitationState: WorkspaceCitationStateModule = {
findCitationSource,
getLoadedCitationChunkId,
+ hasExactCitationTargetHint,
upsertPrefetchedChunks,
+ removePrefetchedChunks,
}
diff --git a/src/components/workspace-desktop-panels.test.ts b/src/components/workspace-desktop-panels.test.ts
index 2026442..80e58fb 100644
--- a/src/components/workspace-desktop-panels.test.ts
+++ b/src/components/workspace-desktop-panels.test.ts
@@ -5,6 +5,22 @@ import { describe, expect, it } from "vitest";
import { useWorkspaceDesktopPanels } from "./workspace-desktop-panels";
describe("useWorkspaceDesktopPanels", () => {
+ it("fits default desktop panel widths to the rendered layout width", () => {
+ const { result } = renderHook(() => useWorkspaceDesktopPanels());
+
+ act(() => {
+ result.current.handleDesktopLayoutElementChange(createPanelElement(1280));
+ });
+
+ const totalWidth =
+ result.current.desktopPanelWidths.sources +
+ result.current.desktopPanelWidths.chunks +
+ result.current.desktopPanelWidths.chat;
+
+ expect(totalWidth).toBe(1264);
+ expect(result.current.desktopPanelWidths.chat).toBeGreaterThanOrEqual(360);
+ });
+
it("resizes desktop panels from their rendered widths during a drag", () => {
const { result } = renderHook(() => useWorkspaceDesktopPanels());
diff --git a/src/components/workspace-desktop-panels.ts b/src/components/workspace-desktop-panels.ts
index b44c945..ccc6251 100644
--- a/src/components/workspace-desktop-panels.ts
+++ b/src/components/workspace-desktop-panels.ts
@@ -1,6 +1,6 @@
"use client";
-import { useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { workspaceShellState } from "@/components/workspace-shell-state";
@@ -17,6 +17,9 @@ type DesktopPanelResizeDrag = {
type WorkspaceDesktopPanels = {
readonly desktopPanelWidths: DesktopPanelWidths;
readonly minimumDesktopPanelWidth: number;
+ readonly handleDesktopLayoutElementChange: (
+ element: HTMLDivElement | null,
+ ) => void;
readonly handleDesktopPanelElementChange: (
panel: DesktopPanelKey,
element: HTMLDivElement | null,
@@ -37,7 +40,8 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels {
const [desktopPanelWidths, setDesktopPanelWidths] =
useState({
...workspaceShellState.defaultDesktopPanelWidths,
- });
+ });
+ const desktopLayoutResizeObserver = useRef(null);
const desktopPanelElements = useRef<
Record
>({
@@ -47,6 +51,42 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels {
});
const desktopPanelResizeDrag = useRef(null);
+ const fitDesktopPanelWidthsToElement = useCallback(
+ (element: HTMLDivElement): void => {
+ const renderedWidth = element.getBoundingClientRect().width;
+ setDesktopPanelWidths(
+ workspaceShellState.fitDesktopPanelWidthsToContainer(renderedWidth),
+ );
+ },
+ [],
+ );
+
+ useEffect(() => {
+ return () => {
+ desktopLayoutResizeObserver.current?.disconnect();
+ };
+ }, []);
+
+ const handleDesktopLayoutElementChange = useCallback(
+ (element: HTMLDivElement | null): void => {
+ desktopLayoutResizeObserver.current?.disconnect();
+ desktopLayoutResizeObserver.current = null;
+
+ if (!element) return;
+
+ fitDesktopPanelWidthsToElement(element);
+
+ if (typeof ResizeObserver === "undefined") return;
+
+ const resizeObserver = new ResizeObserver(() => {
+ fitDesktopPanelWidthsToElement(element);
+ });
+ resizeObserver.observe(element);
+ desktopLayoutResizeObserver.current = resizeObserver;
+ },
+ [fitDesktopPanelWidthsToElement],
+ );
+
function getRenderedDesktopPanelWidth(
panel: DesktopPanelKey,
fallbackWidth: number,
@@ -117,6 +157,7 @@ export function useWorkspaceDesktopPanels(): WorkspaceDesktopPanels {
return {
desktopPanelWidths,
minimumDesktopPanelWidth: workspaceShellState.getMinimumDesktopPanelWidth(),
+ handleDesktopLayoutElementChange,
handleDesktopPanelElementChange,
handleDesktopPanelResize,
handleDesktopPanelResizeEnd,
diff --git a/src/components/workspace-shell-layout.test.ts b/src/components/workspace-shell-layout.test.ts
index 14cd389..76999ed 100644
--- a/src/components/workspace-shell-layout.test.ts
+++ b/src/components/workspace-shell-layout.test.ts
@@ -48,6 +48,7 @@ describe("WorkspaceShellLayout", () => {
onChatSend: vi.fn(),
onCitationClick: vi.fn(),
onCreateChatThread: vi.fn(),
+ onDesktopLayoutElementChange: vi.fn(),
onDesktopPanelElementChange: vi.fn(),
onDesktopPanelResize: vi.fn(),
onDesktopPanelResizeEnd: vi.fn(),
diff --git a/src/components/workspace-shell-layout.tsx b/src/components/workspace-shell-layout.tsx
index f3d3dc4..74a5ec3 100644
--- a/src/components/workspace-shell-layout.tsx
+++ b/src/components/workspace-shell-layout.tsx
@@ -1,4 +1,4 @@
-import type { ReactElement } from "react"
+import { useCallback, type ReactElement } from "react"
import { ChatPanel } from "@/components/chat-panel"
import { ChunksPanel } from "@/components/chunks-panel"
@@ -77,6 +77,7 @@ export type WorkspaceShellLayoutProps = {
citationId: string,
) => void | Promise
readonly onCreateChatThread: () => void | Promise
+ readonly onDesktopLayoutElementChange: (element: HTMLDivElement | null) => void
readonly onDesktopPanelElementChange: (
panel: DesktopPanelKey,
element: HTMLDivElement | null,
@@ -103,6 +104,14 @@ export type WorkspaceShellLayoutProps = {
export function WorkspaceShellLayout(
props: WorkspaceShellLayoutProps,
): ReactElement {
+ const { onDesktopLayoutElementChange } = props
+ const handleDesktopLayoutRef = useCallback(
+ (element: HTMLDivElement | null): void => {
+ onDesktopLayoutElementChange(element)
+ },
+ [onDesktopLayoutElementChange],
+ )
+
return (
@@ -264,7 +274,7 @@ export function WorkspaceShellLayout(
id="panel-content"
role="tabpanel"
aria-labelledby="tab-content"
- className={`lg:hidden flex-1 overflow-hidden pb-14 ${
+ className={`min-[1116px]:hidden flex-1 overflow-hidden pb-14 ${
props.mobilePanel === "content" ? "flex flex-col" : "hidden"
}`}
>
@@ -286,7 +296,7 @@ export function WorkspaceShellLayout(
id="panel-chat"
role="tabpanel"
aria-labelledby="tab-chat"
- className={`lg:hidden flex-1 overflow-hidden pb-14 ${
+ className={`min-[1116px]:hidden flex-1 overflow-hidden pb-14 ${
props.mobilePanel === "chat" ? "flex flex-col" : "hidden"
}`}
>
@@ -325,7 +335,7 @@ export function WorkspaceShellLayout(
/>
{props.chat.error && (
-
+
{props.chat.error}
)}
diff --git a/src/components/workspace-shell-state.test.ts b/src/components/workspace-shell-state.test.ts
index 72db37c..cb5c0a4 100644
--- a/src/components/workspace-shell-state.test.ts
+++ b/src/components/workspace-shell-state.test.ts
@@ -3,6 +3,26 @@ import { describe, expect, it } from "vitest";
import { workspaceShellState } from "./workspace-shell-state";
describe("workspaceShellState", () => {
+ it("fits default desktop panel widths inside a 13-inch viewport", () => {
+ const widths = workspaceShellState.fitDesktopPanelWidthsToContainer(1280);
+ const totalWidth =
+ widths.sources +
+ widths.chunks +
+ widths.chat +
+ workspaceShellState.desktopPanelGutterWidth * 2;
+
+ expect(totalWidth).toBeLessThanOrEqual(1280);
+ expect(widths.sources).toBeGreaterThanOrEqual(
+ workspaceShellState.minimumDesktopPanelWidths.sources,
+ );
+ expect(widths.chunks).toBeGreaterThanOrEqual(
+ workspaceShellState.minimumDesktopPanelWidths.chunks,
+ );
+ expect(widths.chat).toBeGreaterThanOrEqual(
+ workspaceShellState.minimumDesktopPanelWidths.chat,
+ );
+ });
+
it("resizes neighboring desktop panels while preserving their combined width", () => {
const resized = workspaceShellState.resizeDesktopPanelWidths(
{
diff --git a/src/components/workspace-shell-state.ts b/src/components/workspace-shell-state.ts
index 90fb3a8..ed6bf93 100644
--- a/src/components/workspace-shell-state.ts
+++ b/src/components/workspace-shell-state.ts
@@ -16,6 +16,8 @@ type DesktopPanelKey = keyof typeof minimumDesktopPanelWidths
type DesktopPanelWidths = Record
+const desktopPanelKeys = ["sources", "chunks", "chat"] as const
+
type DesktopPanelResizeInput = {
readonly leftPanel: DesktopPanelKey
readonly rightPanel: DesktopPanelKey
@@ -29,6 +31,9 @@ type WorkspaceShellStateModule = {
readonly minimumDesktopPanelWidths: typeof minimumDesktopPanelWidths
readonly defaultDesktopPanelWidths: typeof defaultDesktopPanelWidths
readonly getMinimumDesktopPanelWidth: () => number
+ readonly fitDesktopPanelWidthsToContainer: (
+ containerWidth: number,
+ ) => DesktopPanelWidths
readonly resizeDesktopPanelWidths: (
currentWidths: Readonly,
resize: DesktopPanelResizeInput,
@@ -44,6 +49,65 @@ function getMinimumDesktopPanelWidth(): number {
)
}
+function getDefaultDesktopPanelContentWidth(): number {
+ return (
+ defaultDesktopPanelWidths.sources +
+ defaultDesktopPanelWidths.chunks +
+ defaultDesktopPanelWidths.chat
+ )
+}
+
+function getMinimumDesktopPanelContentWidth(): number {
+ return (
+ minimumDesktopPanelWidths.sources +
+ minimumDesktopPanelWidths.chunks +
+ minimumDesktopPanelWidths.chat
+ )
+}
+
+function fitDesktopPanelWidthsToContainer(
+ containerWidth: number,
+): DesktopPanelWidths {
+ if (!Number.isFinite(containerWidth) || containerWidth <= 0) {
+ return { ...defaultDesktopPanelWidths }
+ }
+
+ const availableContentWidth = containerWidth - desktopPanelGutterWidth * 2
+ const defaultContentWidth = getDefaultDesktopPanelContentWidth()
+ if (availableContentWidth >= defaultContentWidth) {
+ return { ...defaultDesktopPanelWidths }
+ }
+
+ const minimumContentWidth = getMinimumDesktopPanelContentWidth()
+ if (availableContentWidth <= minimumContentWidth) {
+ return { ...minimumDesktopPanelWidths }
+ }
+
+ const defaultExtraWidth = defaultContentWidth - minimumContentWidth
+ const availableExtraWidth = availableContentWidth - minimumContentWidth
+ const fittedWidths = {} as DesktopPanelWidths
+ let assignedWidth = 0
+
+ for (const [index, panel] of desktopPanelKeys.entries()) {
+ const isLastPanel = index === desktopPanelKeys.length - 1
+ if (isLastPanel) {
+ fittedWidths[panel] = availableContentWidth - assignedWidth
+ break
+ }
+
+ const panelExtraWidth =
+ defaultDesktopPanelWidths[panel] - minimumDesktopPanelWidths[panel]
+ const fittedWidth = Math.round(
+ minimumDesktopPanelWidths[panel] +
+ (panelExtraWidth / defaultExtraWidth) * availableExtraWidth,
+ )
+ fittedWidths[panel] = fittedWidth
+ assignedWidth += fittedWidth
+ }
+
+ return fittedWidths
+}
+
function resizeDesktopPanelWidths(
currentWidths: Readonly,
resize: DesktopPanelResizeInput,
@@ -73,5 +137,6 @@ export const workspaceShellState: WorkspaceShellStateModule = {
minimumDesktopPanelWidths,
defaultDesktopPanelWidths,
getMinimumDesktopPanelWidth,
+ fitDesktopPanelWidthsToContainer,
resizeDesktopPanelWidths,
}
diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx
index 4e2af57..e931cf6 100644
--- a/src/components/workspace-shell.tsx
+++ b/src/components/workspace-shell.tsx
@@ -100,6 +100,7 @@ function WorkspaceShellContent({
const {
desktopPanelWidths,
minimumDesktopPanelWidth,
+ handleDesktopLayoutElementChange,
handleDesktopPanelElementChange,
handleDesktopPanelResize,
handleDesktopPanelResizeEnd,
@@ -150,6 +151,7 @@ function WorkspaceShellContent({
onChatSend={chatWorkflow.handleChatSend}
onCitationClick={citationFocus.handleCitationClick}
onCreateChatThread={chatWorkflow.handleCreateChatThread}
+ onDesktopLayoutElementChange={handleDesktopLayoutElementChange}
onDesktopPanelElementChange={handleDesktopPanelElementChange}
onDesktopPanelResize={handleDesktopPanelResize}
onDesktopPanelResizeEnd={handleDesktopPanelResizeEnd}
diff --git a/src/lib/effect-operation.test.ts b/src/lib/effect-operation.test.ts
index 04c3b97..93204c0 100644
--- a/src/lib/effect-operation.test.ts
+++ b/src/lib/effect-operation.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"
import { effectOperation } from "./effect-operation"
import { formatUnknownForLog } from "./format-log-value"
+import { makeWorkspaceInitialStateFailureFixture } from "@/test/workspace-initial-state-failure-fixture"
describe("effectOperation", () => {
it("adds operation context to Promise failures", async () => {
@@ -53,9 +54,48 @@ describe("effectOperation", () => {
)
expect(boundaryError.message).toBe(
- "Workspace initial state failed: Effect operation workspace.load failed",
+ "Workspace initial state failed: database connection refused",
)
expect(boundaryError.cause).toBe(error)
}
})
+
+ it("surfaces driver causes through database query wrappers", async () => {
+ const databaseConnectionError = new Error(
+ "connect ECONNREFUSED 127.0.0.1:5434",
+ )
+ const queryError = new Error(
+ 'Failed query: select "id" from "workspaces" where "user_id" = $1',
+ { cause: databaseConnectionError },
+ )
+
+ try {
+ await Effect.runPromise(
+ effectOperation.tryPromise("workspace.load", () =>
+ Promise.reject(queryError),
+ ),
+ )
+ throw new Error("Expected operation to fail.")
+ } catch (error) {
+ const boundaryError = effectOperation.createBoundaryError(
+ "Workspace initial state failed",
+ error,
+ )
+
+ expect(boundaryError.message).toBe(
+ "Workspace initial state failed: connect ECONNREFUSED 127.0.0.1:5434",
+ )
+ }
+ })
+
+ it("surfaces deeply nested driver defects through Effect FiberFailure cause objects", () => {
+ const failure = makeWorkspaceInitialStateFailureFixture()
+
+ const boundaryError = effectOperation.createBoundaryError(
+ "Workspace initial state failed",
+ failure.error,
+ )
+
+ expect(boundaryError.message).toBe(failure.boundaryMessage)
+ })
})
diff --git a/src/lib/format-log-value.ts b/src/lib/format-log-value.ts
index f241a32..002c497 100644
--- a/src/lib/format-log-value.ts
+++ b/src/lib/format-log-value.ts
@@ -7,6 +7,7 @@ type LogValue =
| { readonly [key: string]: LogValue }
const maxDepth = 8
+const maxSummaryDepth = 16
const maxStackLines = 12
export function formatUnknownForLog(value: unknown): string {
@@ -117,21 +118,23 @@ function findErrorMessage(
depth: number,
seenObjects: WeakSet