Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
db667ea
perf(pdf): draw pages on the GPU at pdf.js's own canvas limits
yaacovcorcos Nov 5, 2026
48ce886
perf(pdf): extract a PDF's text only when search opens
yaacovcorcos Nov 5, 2026
12e6faf
perf(pdf): keep a loaded PDF when only its signed URL is renewed
yaacovcorcos Nov 5, 2026
9d3f1fe
fix(pdf): save a copy with what was typed into the PDF's form fields
yaacovcorcos Nov 5, 2026
fa2697a
perf(pdf): draw thumbnails after the pages in view
yaacovcorcos Nov 5, 2026
161d5da
perf(pdf): check only the pages in view while a PDF opens
yaacovcorcos Nov 5, 2026
e9c9a63
fix(web): size the Word preview limits to the engine that lays it out
yaacovcorcos Nov 5, 2026
a7ed9ed
feat(web): read Word and PowerPoint files with the PDF reader's controls
yaacovcorcos Nov 5, 2026
ebaded4
feat(web): ask in chat about text on a Word page or slide
yaacovcorcos Nov 5, 2026
b2b16ee
fix(web): leave out only a long Word header, and count CDATA text
yaacovcorcos Nov 7, 2026
9b803cd
fix(web): keep an open document while only other files change
yaacovcorcos Nov 7, 2026
3d76e60
feat(web): reopen a Word file or slide deck where it was left
yaacovcorcos Nov 7, 2026
682ce00
Merge origin/main into scient/office-viewers-20261105
yaacovcorcos Nov 7, 2026
9936c62
docs(upstream): note the T3 PR for the document reload fix
yaacovcorcos Nov 7, 2026
c681f44
fix(web): reload a document preview when the reader asks
yaacovcorcos Nov 7, 2026
59acba3
fix(web): reach a page among the last mounted once the rest mount
yaacovcorcos Nov 7, 2026
19854af
Merge remote-tracking branch 'origin/main' into scient/office-viewers…
yaacovcorcos Nov 9, 2026
36d2907
test(web): wait for Office pages to take their size before jumping
yaacovcorcos Nov 9, 2026
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
67 changes: 67 additions & 0 deletions UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -2025,3 +2025,70 @@ When aligning, check:
`scient/multiWindow/previewHostWindows.ts`) or all `appWindows`.
- **Shortcuts.** File > New Window uses `CmdOrCtrl+Alt+Shift+N`. If T3 binds it,
decide which command keeps the key and update `docs/user/multiple-windows.md`.

## Office preview limits

T3's `apps/web/src/components/files/officeArchive.ts` bounds a Word file before its engine opens it.
The bounds were sized for a renderer on the page's main thread, so ordinary documents lost parts:
any table over about 2,000 tags or 128 KB cut the document off there, a body stopped at 5,000
paragraphs, and styles past 1,024 definitions or lists past 2,000 tags were dropped, which unstyled
the whole document. Scient replaces the numbers with `WORD_LIMITS`, measured against the
`@betteroffice/docx` engine, which runs in its own worker:

- Paragraph text, 32K characters. Line breaking grows with the square of one paragraph's text
(16K 0.7 s, 32K 1.5 s, 128K 11.6 s on an M-series Mac). This replaces the 128 KB element limit,
which mostly measured picture markup.
- One body element, 100,000 tags: a table is laid out in one go, and a 4,000-row table takes 2.2 s.
- A header or footer, 128 KB: its cost grows with its own length, not the page count. A longer
one alone is left out, as an unused one is, and the preview says it is limited only when the
document uses it. Text in CDATA counts toward a paragraph's limit.
- The body's 4 MB read limit stays (about 700 pages of prose, opened in under 1 s). Styles, lists,
notes and the expansion totals rose to where they no longer bind real documents; the engine opened
4,000 chained styles, 2,000 lists or 5,000 footnotes in under 0.75 s.
- Archive, entry and nesting limits are T3's, unchanged.

`WordDocument.tsx` shows the first 1,000 pages with the "Preview limited" notice instead of
replacing them with an error once layout passes 1,000. Each changed line in these files names a
`WORD_LIMITS` field or sits in a marked block. T3's limit cases in
`AttachmentFilePreview.browser.test.tsx` size their inputs and bounds from `WORD_LIMITS`, so each
still crosses the limit it checks. If T3 resizes its own budgets, compare them with these
measurements before taking either.

The limits (not the 1,000-page change) are offered to T3 as pingdotgg/t3code#32040 (issue #32039).
If it merges, take T3's version and drop the marked lines in `officeArchive.ts` and its test.

## Office reader

Word and PowerPoint files read with the PDF reader's controls: page number, zoom (buttons, keys
and pinch), a sidebar of pages and find. The reader is `apps/web/src/scient/office`; T3's viewer
still draws the pages, and its files keep marked seams:

| T3 file | Seam |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `office/OfficePages.tsx` | `zoom` and `onWidth`; zooming keeps the middle of the view; wide pages scroll sideways; 2^25-pixel canvases; `onPlace` reports the page and point at the top |
| `office/WordDocument.tsx`, `Presentation.tsx` | `ScientOfficeReader` instead of `OfficePages`; thumbnail paints and find; Word says when layout is complete. Presentation drops T3's previous/next slide bar, which the reader's page controls replace |
| `office/document.worker.ts`, `word-protocol.ts` | `find` through the engine's `searchText`, placed on pages by document position; thumbnail paints do not release the pages built around the view |
| `office/presentation.worker.ts`, `slide-protocol.ts` | `text` (a slide's text layer, word by word from its laid-out glyphs) and `find` |

Selected text on a page or slide quotes into chat as an Office quote (`OfficeCitation`, with the
other quote kinds in `packages/contracts/src/fileCitations.ts`). `FilePreviewPanel.tsx` gives the
reader its file and conversation (`OfficeFileContext`), and `AssistantCitationChip.tsx` names,
links and reopens the quote in its existing Scient blocks.

A file opened again returns to its page, point on it and zoom (`officeReaderPlace.ts`, kept for
the window's session), unless a quote opens it at its own page.

T3 remounts a workspace document preview whenever anything in the project may have changed: the
key of `FilePreviewPanel.tsx`'s document branch carried the thread's workspace mutation id, which
moves after every command, edit or tool call. An open Word file loaded again and went back to its
first page while an agent worked. Scient keys that branch by the file alone and wraps it in
`ScientFileRevisionBoundary` (`scient/fileSurfaces`), which checks the file's revision on each
possible change and remounts only for a new one (a reload the reader asks for, from the header
or the Files pane, still always remounts it); `AssetAccess.ts` reports the revision for every
exact workspace file, not only PDFs. The boundary is offered to T3 as pingdotgg/t3code#32670 (issue #32669,
`FileRevisionBoundary`); if it merges, take T3's and drop `ScientFileRevisionBoundary` and the
marked key in `FilePreviewPanel.tsx`.

When aligning, check that T3's `OfficePages` props, the paint keys (`paint:`, `slide:`) and the
worker method maps still meet these seams. A T3 text layer for slides, or find of its own, should
replace Scient's rather than run beside it.
38 changes: 38 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1604,6 +1604,12 @@ describe("AssetAccess", () => {
revision: { size: 9 },
});
expect(yield* resolveAsset(token, "private-notes.pdf")).toBeNull();
const renewed = yield* issueAssetUrl({
resource: { _tag: "workspace-file", threadId: ThreadId.make("thread-1"), path: pdfPath },
workspaceRoot: root,
});
expect(result.fileRevision).toEqual(expect.any(String));
expect(renewed.fileRevision).toBe(result.fileRevision);

yield* fileSystem.writeFile(pdfPath, new TextEncoder().encode("%PDF-1.7\nchanged"));
const staleAsset = yield* resolveAsset(token, "paper.pdf");
Expand All @@ -1625,8 +1631,39 @@ describe("AssetAccess", () => {
refreshedSuffix.slice(refreshedSeparator + 1),
),
).toMatchObject({ revision: { size: 16 } });
expect(refreshed.fileRevision).not.toBe(result.fileRevision);
}).pipe(Effect.provide(layerTest)),
);

// SCIENT-FORK:START — a preview keeps a loaded document while its file's revision holds.
it.effect("reports the revision of a workspace document", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-document-workspace-",
});
const documentPath = path.join(root, "report.docx");
yield* fileSystem.writeFileString(documentPath, "PK first");
const issue = () =>
issueAssetUrl({
resource: {
_tag: "workspace-file",
threadId: ThreadId.make("thread-1"),
path: documentPath,
},
workspaceRoot: root,
});
const first = yield* issue();
const renewed = yield* issue();
expect(first.fileRevision).toEqual(expect.any(String));
expect(renewed.fileRevision).toBe(first.fileRevision);

yield* fileSystem.writeFileString(documentPath, "PK second, longer");
expect((yield* issue()).fileRevision).not.toBe(first.fileRevision);
}).pipe(Effect.provide(layerTest)),
);
// SCIENT-FORK:END

it.effect("issues exact renewable URLs for immutable generated PDF revisions", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -2018,6 +2055,7 @@ describe("AssetAccess", () => {
revision: { size: 4 },
});
expect(yield* resolveAsset(suffix.slice(0, separatorIndex), "sibling.png")).toBeNull();
expect(issued.fileRevision).toEqual(expect.any(String));

const replacementPath = path.join(root, "replacement.png");
yield* fileSystem.writeFile(replacementPath, new Uint8Array([137, 80, 78, 71]));
Expand Down
15 changes: 13 additions & 2 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { ScientAssetClaimsSchemas } from "./ScientAssetClaims.ts";
import {
isScientAssetClaims,
issueScientAssetClaims,
fileInfoRevision,
resolveScientAsset,
} from "./ScientAssetAccess.ts";
// SCIENT-FORK:END
Expand Down Expand Up @@ -551,7 +552,9 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA
)
? yield* readImageDimensionsFromHeader(canonicalFile)
: null;
const pdfInfo = isWorkspacePdfPreviewPath(resolved.relativePath)
// SCIENT-FORK:START — every exact file reports its revision, so a preview keeps a loaded
// document while a check finds the file unchanged.
const fileInfo = !/\.html?$/i.test(resolved.relativePath)
? yield* fileSystem.stat(canonicalFile).pipe(
Effect.mapError(
(cause) =>
Expand All @@ -562,6 +565,8 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA
),
)
: null;
const pdfInfo = isWorkspacePdfPreviewPath(resolved.relativePath) ? fileInfo : null;
// SCIENT-FORK:END
return {
claims: !/\.html?$/i.test(resolved.relativePath)
? {
Expand Down Expand Up @@ -590,7 +595,9 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA
fileName: path.basename(resolved.relativePath),
sourcePath: resolved.relativePath,
imageDimensions,
fileRevision: undefined,
// SCIENT-FORK:START — the revision a reader keeps its document by; see fileInfoRevision.
fileRevision: fileInfo ? fileInfoRevision(fileInfo) : undefined,
// SCIENT-FORK:END
};
},
);
Expand Down Expand Up @@ -688,6 +695,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
fileName = finalized.fileName;
sourcePath = finalized.sourcePath;
imageDimensions = finalized.imageDimensions;
// SCIENT-FORK:START — report the revision, as draft-workspace-file does below.
fileRevision = finalized.fileRevision;
// SCIENT-FORK:END
break;
}
case "draft-workspace-file": {
Expand Down Expand Up @@ -920,6 +930,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
fileName = issued.fileName;
sourcePath = issued.sourcePath;
expiresAt = issued.expiresAt;
fileRevision = issued.fileRevision;
break;
}
// SCIENT-FORK:END
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/assets/ScientAssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ import { decodeRelativePath, type ResolvedAsset } from "./AssetAccess.ts";
import { optionOnNotFound } from "../project/ProjectFaviconResolver.ts";
import type { ScientAssetClaims } from "./ScientAssetClaims.ts";

/**
* Names the file's bytes apart from its signed URL, like statFingerprint does for media: a
* replacement under the same name is a new revision. A reader keeps a loaded PDF while a renewed
* URL reports the same revision.
*/
export const fileInfoRevision = (info: FileSystem.File.Info) =>
[
info.dev,
Option.getOrElse(info.ino, () => ""),
info.size,
Option.match(info.mtime, { onNone: () => "", onSome: (mtime) => mtime.getTime() }),
].join(":");

const ENVIRONMENT_HTML_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
const ENVIRONMENT_HTML_EXTENSIONS = new Set([".html", ".htm", ".xhtml"]);

Expand Down Expand Up @@ -128,6 +141,7 @@ export const issueScientAssetClaims = Effect.fnUntraced(function* (
let claims: ScientAssetClaims;
let fileName: string;
let sourcePath: string | undefined;
let fileRevision: string | undefined;

switch (input.resource._tag) {
case "generated-document": {
Expand Down Expand Up @@ -324,10 +338,11 @@ export const issueScientAssetClaims = Effect.fnUntraced(function* (
}),
};
}
if (claims.kind === "environment-file-exact") fileRevision = fileInfoRevision(info.value);
break;
}
}
return { claims, fileName, sourcePath, expiresAt };
return { claims, fileName, sourcePath, expiresAt, fileRevision };
});

/** Resolves a Scient claim to the file it grants, or null when the request does not match. */
Expand Down
44 changes: 35 additions & 9 deletions apps/web/src/components/chat/AssistantCitationChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AssistantCitation } from "@t3tools/contracts";
import {
isComputeCitation,
isFileCitation,
isOfficeCitation,
isPdfCitation,
type ComposerCitation,
} from "@t3tools/contracts";
Expand All @@ -25,6 +26,12 @@ import { FileCitationHoverCard } from "~/scient/markdownEditor/FileCitationHover
import { PdfCitationHoverCard } from "~/scient/pdf/anchors/PdfCitationHoverCard";
import { pdfCitationHash, pdfCitationNavigation } from "~/scient/pdf/anchors/pdfCitationNavigation";
import { pdfCitationDocumentLabel } from "@t3tools/shared/pdfCitations";
import { officeCitationPlaceLabel } from "@t3tools/shared/officeCitations";
import {
OfficeCitationHoverCard,
officeCitationHash,
officeCitationNavigation,
} from "~/scient/office/officeQuote";
// SCIENT-FORK:END
import { Link, useNavigate } from "@tanstack/react-router";
// SCIENT-FORK:START — a composer chip can be removed.
Expand Down Expand Up @@ -121,7 +128,10 @@ export function AssistantCitationChip({
if (!commentOpen || sourceAnchor === unavailableSourceAnchor) return;
// SCIENT-FORK:START — a document quote has no assistant-message source to find.
const assistantQuote =
isFileCitation(citation) || isPdfCitation(citation) || isComputeCitation(citation)
isFileCitation(citation) ||
isPdfCitation(citation) ||
isComputeCitation(citation) ||
isOfficeCitation(citation)
? undefined
: citation;
const anchor =
Expand Down Expand Up @@ -158,14 +168,19 @@ export function AssistantCitationChip({
// SCIENT-FORK:START — file and PDF quotes label, copy and link to their document.
const label = composerCitationLabel(citation);
const documentQuote =
isFileCitation(citation) || isPdfCitation(citation) || isComputeCitation(citation);
isFileCitation(citation) ||
isPdfCitation(citation) ||
isComputeCitation(citation) ||
isOfficeCitation(citation);
const sourceName = isFileCitation(citation)
? citation.path
: isPdfCitation(citation)
? pdfCitationDocumentLabel(citation)
: isComputeCitation(citation)
? computeCitationPlaceLabel(citation)
: "assistant text";
: isOfficeCitation(citation)
? officeCitationPlaceLabel(citation)
: "assistant text";
// SCIENT-FORK:END
const sourceLinkProps = {
to: "/$environmentId/$threadId" as const,
Expand All @@ -175,9 +190,11 @@ export function AssistantCitationChip({
? pdfCitationHash(citation)
: isComputeCitation(citation)
? computeCitationHash(citation)
: isFileCitation(citation)
? fileCitationHash(citation)
: assistantCitationHash(citation),
: isOfficeCitation(citation)
? officeCitationHash(citation)
: isFileCitation(citation)
? fileCitationHash(citation)
: assistantCitationHash(citation),
"data-markdown-copy": serializeComposerCitation(citation),
// SCIENT-FORK:END
resetScroll: false,
Expand All @@ -192,6 +209,8 @@ export function AssistantCitationChip({
void navigate(pdfCitationNavigation(citation));
} else if (isComputeCitation(citation)) {
void navigate(computeCitationNavigation(citation));
} else if (isOfficeCitation(citation)) {
void navigate(officeCitationNavigation(citation));
} else if (isFileCitation(citation)) {
void navigate(fileCitationNavigation(citation));
} else void navigate(assistantCitationNavigation(citation));
Expand Down Expand Up @@ -273,6 +292,11 @@ export function AssistantCitationChip({
citation={citation}
trigger={composer ? composerSourceLink : chatSourceLink}
/>
) : isOfficeCitation(citation) ? (
<OfficeCitationHoverCard
citation={citation}
trigger={composer ? composerSourceLink : chatSourceLink}
/>
) : (
composerSourceLink
)}
Expand Down Expand Up @@ -381,9 +405,11 @@ export function AssistantCitationChip({
? "Remove PDF citation"
: isComputeCitation(citation)
? "Remove Compute citation"
: isFileCitation(citation)
? "Remove file citation"
: "Remove assistant citation"
: isOfficeCitation(citation)
? "Remove Office citation"
: isFileCitation(citation)
? "Remove file citation"
: "Remove assistant citation"
}
>
<XIcon aria-hidden="true" className="size-[0.85em]" />
Expand Down
Loading
Loading