fix(kb-ui): make the object reader readable — reflow, window, jump-to

The reader dumped the whole server slice verbatim: a ~445k-char doc rendered
as a 16,558px wall of one-source-line-per-line ragged text, and the outline
panel showed 8 blank rows (the API's sections carry heading:null — they're
mechanical 60k-char chunks, not headings).

- Reflow: off the citation path, parse the extracted markdown/PDF text into
  real blocks — drop form-feeds, split on blank lines, join soft-wrapped
  lines into paragraphs, lift markdown headings + multi-line bullet lists.
  Cap prose width at 68ch. The citation-highlight path still renders the raw
  slice verbatim so character offsets stay exact.
- Window every load: initial 8k chars, "Read more" +12k, section jump loads a
  bounded window from the section's start (server honours start/end precisely).
  Object height 16,558 → ~5,315px; Read more/citations verified.
- Outline: blank heading:null rows now render as "Part N" jump points and the
  panel is titled "Jump to" (vs "Outline") when there are no real headings;
  aria-current on the active row.

Verified live on dev-knowledge.sky-ai.com.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-11 08:51:50 +10:00
parent ac7c294753
commit efc2c18a91

View File

@@ -28,6 +28,16 @@ import {
isImageMime,
} from "./_internal";
// The reader loads a bounded window and reveals more on demand — a long
// document (this one is ~445k chars) must never render as a single wall.
const INITIAL_CHARS = 8000;
const MORE_CHARS = 12000;
/** A section's display label — real heading, else a mechanical "Part N". */
function sectionLabel(s: OutlineSection, index: number): string {
return s.heading?.trim() || `Part ${index + 1}`;
}
// ---- ObjectList -----------------------------------------------------------
export interface ObjectListProps {
@@ -222,20 +232,21 @@ export const OutlineNav: FC<{
}> = ({ sections, activeId, onSelect, className }) => {
if (sections.length === 0) return null;
return (
<nav className={cn("flex flex-col gap-0.5", className)} aria-label="Document outline">
{sections.map((s) => (
<nav className={cn("flex flex-col gap-0.5", className)} aria-label="Document sections">
{sections.map((s, i) => (
<button
key={s.id}
type="button"
data-action="knowledge-outline-section"
onClick={() => onSelect?.(s)}
aria-current={activeId === s.id ? "true" : undefined}
className={cn(
"truncate rounded-md px-2 py-1 text-left text-sm transition hover:bg-muted",
s.level > 1 && "pl-4 text-xs",
activeId === s.id ? "bg-muted font-medium text-foreground" : "text-muted-foreground",
)}
>
{s.heading}
{sectionLabel(s, i)}
</button>
))}
</nav>
@@ -282,17 +293,21 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
[readText],
);
// Initial + reactive loads: an explicit outline selection wins (so clicking a
// section works even on a citation-opened view), then the highlight span,
// else the head of the document.
// Initial + reactive loads, always bounded to a window so a long document
// never renders as one wall. An explicit section selection wins (jump to its
// start), then a citation highlight span, else the head of the document.
useEffect(() => {
if (activeSection) {
void load({ section: activeSection });
const sec = outline.outline.find((s) => s.id === activeSection);
void load(
sec
? { start: sec.start, end: Math.min(sec.end, sec.start + INITIAL_CHARS) }
: { section: activeSection },
);
} else if (highlight) {
const sec = outline.outline.find((s) => highlight.start >= s.start && highlight.end <= s.end);
void load(sec ? { section: sec.id } : { start: Math.max(0, highlight.start - 200), end: highlight.end + 200 });
void load({ start: Math.max(0, highlight.start - 400), end: highlight.end + 1200 });
} else {
void load();
void load({ start: 0, end: INITIAL_CHARS });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [highlight?.start, highlight?.end, activeSection, outline.object_id]);
@@ -305,7 +320,7 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
if (loadingMore || !slice?.range) return;
setLoadingMore(true);
try {
const next = await readText({ start: slice.range.end });
const next = await readText({ start: slice.range.end, end: slice.range.end + MORE_CHARS });
setSlice((prev) =>
prev && prev.range && next.range
? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } }
@@ -346,8 +361,15 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
return (
<div className={className}>
<article className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">
{renderWithHighlight(slice, highlight, markRef)}
<article className="max-w-[68ch] break-words text-sm leading-relaxed text-foreground">
{highlight ? (
// Citation landing: exact character offsets must be preserved so the
// mark lands on the cited span, so render the raw slice verbatim.
<div className="whitespace-pre-wrap break-words">{renderWithHighlight(slice, highlight, markRef)}</div>
) : (
// Reading: reflow the soft-wrapped source into real paragraphs.
renderReadable(slice.text ?? "")
)}
</article>
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
{canReadMore && (
@@ -366,6 +388,53 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
);
};
// Reflow extracted text (markdown / PDF dumps) into readable blocks. The source
// hard-wraps prose with single newlines and separates paragraphs with blank
// lines; rendered verbatim that's a ragged one-line-per-line wall. Here: drop
// page-break form-feeds, split on blank lines, join soft-wrapped lines within a
// block, and lift markdown headings / bullets. Offset-independent, so it's only
// used off the citation path (which needs verbatim offsets).
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
const BULLET_RE = /^\s*([-*•]|\d+[.)])\s+/;
function renderReadable(raw: string): ReactNode {
const text = raw.replace(/\r\n?/g, "\n").replace(/\f/g, "\n\n");
const blocks = text.split(/\n[ \t]*\n+/);
const out: ReactNode[] = [];
blocks.forEach((block, bi) => {
const lines = block.split("\n").map((l) => l.trimEnd()).filter((l) => l.trim() !== "");
if (lines.length === 0) return;
const h = lines.length === 1 ? HEADING_RE.exec(lines[0]) : null;
if (h) {
const level = h[1].length;
out.push(
<p key={bi} className={cn("mb-1 mt-4 font-semibold text-foreground first:mt-0", level <= 2 ? "text-base" : "text-sm")}>
{h[2]}
</p>,
);
return;
}
// A real list needs 2+ marker lines; a lone "1. Introduction" is a heading
// or TOC line, not a one-item bullet.
if (lines.length >= 2 && lines.every((l) => BULLET_RE.test(l))) {
out.push(
<ul key={bi} className="my-2 list-disc space-y-1 pl-5">
{lines.map((l, li) => (
<li key={li}>{l.replace(BULLET_RE, "")}</li>
))}
</ul>,
);
return;
}
const para = lines.join(" ").replace(/[ \t]+/g, " ").trim();
if (para) out.push(<p key={bi} className="my-2 first:mt-0">{para}</p>);
});
return out.length > 0 ? out : raw;
}
function renderWithHighlight(
slice: TextSlice,
highlight: { start: number; end: number } | undefined,
@@ -527,7 +596,9 @@ export const ObjectViewer: FC<ObjectViewerProps> = ({
<aside className="flex flex-col gap-4">
{outline.outline.length > 0 && (
<div className="rounded-xl border border-border bg-card p-3">
<h3 className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Outline</h3>
<h3 className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{outline.outline.some((s) => s.heading?.trim()) ? "Outline" : "Jump to"}
</h3>
<OutlineNav sections={outline.outline} activeId={activeSection} onSelect={(s) => setActiveSection(s.id)} />
</div>
)}