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
1 change: 1 addition & 0 deletions .github/workflows/scient-upstream-provenance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Fetch official T3 main
run: git fetch --no-tags https://github.com/pingdotgg/t3code.git main:refs/remotes/upstream-verification/main
Expand Down
10 changes: 10 additions & 0 deletions UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,16 @@ behavior, and Markdown transport must remain outside inherited T3 components;
`index.css` inherited rule bodies stay byte-identical. See
[Scient rich Markdown editor](docs/internals/scient-rich-markdown-editor.md).

Markdown file quotes deliberately extend the inherited assistant Cite flow.
Preserve the concrete `FileCitation` variant and `composerCitations` helpers,
the shared composer node/comment and selection toolbar, and the small source
capture/reveal mounts in `ChatView`, `FilePreviewPanel`, and the right-panel
store. Source mapping and editor interaction remain in `scient/markdownEditor`;
assistant v1 links and timeline navigation keep their original semantics.
Provider expansion, prompt previews, and mobile fallback must handle both quote
types. Do not reinstate an assistant-only parser in these shared entry points
or turn file citations into file-read operations. The rich editor remains lazy.

No upstream update authorizes public release, live cloud, mobile publication,
production credentials, or user-data conversion. Those remain separate Scient
gates even when inherited T3 code contains the capability.
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
ThreadId,
TurnId,
} from "@t3tools/contracts";
import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
import { renderComposerCitationsAsText } from "@t3tools/shared/composerCitations";
import {
codexArtifactTemplatePresentationLabel,
type CodexArtifactTemplate,
Expand Down Expand Up @@ -1453,7 +1453,7 @@ function renderFeedEntry(
if (entry.type === "message") {
const { message } = entry;
const isUser = message.role === "user";
const renderedText = renderAssistantCitationsAsText(message.text);
const renderedText = renderComposerCitationsAsText(message.text);
const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
const attachments = message.attachments ?? [];
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/lib/projectThreadStartTurn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import {
type ProviderInteractionMode,
type RuntimeMode,
} from "@t3tools/contracts";
import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
import { composerCitationsToPlainText } from "@t3tools/shared/composerCitations";

import type { UploadedMobileAttachment } from "./attachmentUpload";

export function deriveThreadTitleFromPrompt(value: string): string {
const trimmed = assistantCitationsToPlainText(value).trim();
const trimmed = composerCitationsToPlainText(value).trim();
if (trimmed.length === 0) {
return "New thread";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type RuntimeMode,
type TurnId,
} from "@t3tools/contracts";
import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
import { composerCitationsToPlainText } from "@t3tools/shared/composerCitations";
import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git";
import * as Cache from "effect/Cache";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -125,7 +125,7 @@ function formatThreadTitleSection(message: ThreadTitleMessage): string | undefin
if (message.role === "system") {
return undefined;
}
const text = assistantCitationsToPlainText(message.text).trim();
const text = composerCitationsToPlainText(message.text).trim();
const attachmentSummary = (message.attachments ?? [])
.map((attachment) => attachment.name)
.join(", ");
Expand Down Expand Up @@ -1312,7 +1312,7 @@ const make = Effect.gen(function* () {
projects: project ? [project] : [],
}) ?? process.cwd();
const generationInput = {
messageText: assistantCitationsToPlainText(message.text),
messageText: composerCitationsToPlainText(message.text),
...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}),
};
Expand Down
51 changes: 51 additions & 0 deletions apps/server/src/provider/Layers/ProviderService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as NodePath from "node:path";
import * as FileSystem from "effect/FileSystem";

import type {
FileCitation,
ProviderApprovalDecision,
ProviderRuntimeEvent,
ProviderSendTurnInput,
Expand Down Expand Up @@ -33,6 +34,10 @@ import {
expandAssistantCitationsForProvider,
serializeAssistantCitation,
} from "@t3tools/shared/assistantCitations";
import {
serializeComposerCitation,
expandComposerCitationsForProvider,
} from "@t3tools/shared/composerCitations";
import { createModelSelection } from "@t3tools/shared/model";
import { it, assert, describe, vi } from "@effect/vitest";
import { afterAll } from "vite-plus/test";
Expand Down Expand Up @@ -3442,6 +3447,52 @@ citations.layer("ProviderServiceLive assistant citations", (it) => {
[CLAUDE_AGENT_DRIVER, citations.claude],
[CURSOR_DRIVER, citations.cursor],
] as const) {
it.effect(
`expands a file quote at the shared ${driver} boundary without losing its source`,
() =>
Effect.gen(function* () {
const provider = yield* ProviderService.ProviderService;
const threadId = asThreadId(`thread-file-citation-${driver}`);
yield* provider.startSession(threadId, {
provider: driver,
providerInstanceId: ProviderInstanceId.make(driver),
threadId,
runtimeMode: "full-access",
});
const quote: FileCitation = {
kind: "file",
version: 1,
environmentId: EnvironmentId.make("remote-source"),
threadId: asThreadId("original-thread"),
cwd: "/original/worktree",
path: "notes.md",
revision: `sha256:${"a".repeat(64)}`,
origin: "draft",
sourceStart: 0,
sourceEnd: 50,
startLine: 1,
endLine: 4,
from: 1,
to: 12,
text: "Exact quote\n with indentation",
prefix: "",
suffix: "",
comment: "Explain this.",
};
const prompt = `Explain ${serializeComposerCitation(quote)}`;
const request = Object.freeze({ threadId, input: prompt });
adapter.sendTurn.mockClear();
yield* provider.sendTurn(request);
const sent = adapter.sendTurn.mock.calls[0]?.[0].input ?? "";
assert.equal(sent, expandComposerCitationsForProvider(prompt));
assert.include(sent, '"cwd": "/original/worktree"');
assert.include(sent, '"origin": "draft"');
assert.include(sent, '"text": "Exact quote\\n with indentation"');
assert.notInclude(sent, "scient-file-citation:");
assert.equal(request.input, prompt);
yield* provider.stopSession({ threadId });
}),
);
it.effect(`expands quotes and bound comments as JSON data for ${driver}`, () =>
Effect.gen(function* () {
const provider = yield* ProviderService.ProviderService;
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/provider/Layers/ProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
type ProviderRuntimeEvent,
type ProviderSession,
} from "@t3tools/contracts";
import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
import { expandComposerCitationsForProvider } from "@t3tools/shared/composerCitations";
import { causeErrorTag } from "@t3tools/shared/observability";
import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings";
Expand Down Expand Up @@ -1574,7 +1574,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
}

const inputTextWithCitations =
parsed.input === undefined ? undefined : expandAssistantCitationsForProvider(parsed.input);
parsed.input === undefined ? undefined : expandComposerCitationsForProvider(parsed.input);
if (inputTextWithCitations !== parsed.input) {
yield* decodeInputOrValidationError({
operation: "ProviderService.sendTurn",
Expand Down
54 changes: 53 additions & 1 deletion apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EnvironmentId } from "@t3tools/contracts";
import { EnvironmentId, ThreadId, type FileCitation } from "@t3tools/contracts";
import { serializeComposerCitation } from "@t3tools/shared/composerCitations";
import { act, type ComponentProps, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { create, type ReactTestRenderer } from "react-test-renderer";
Expand All @@ -10,6 +11,15 @@ import { Button } from "./ui/button";
import { setMarkdownTaskChecked } from "./files/filePreviewMode";

vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
vi.mock("@tanstack/react-router", async (original) => ({
...(await original<typeof import("@tanstack/react-router")>()),
useNavigate: () => vi.fn(),
Link: ({ children, className, "aria-label": label }: ComponentProps<"a">) => (
<a href="#source" className={className} aria-label={label}>
{children}
</a>
),
}));
vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
vi.mock("../hooks/useSettings", async (importOriginal) => {
const actual = await importOriginal<typeof import("../hooks/useSettings")>();
Expand Down Expand Up @@ -72,6 +82,48 @@ function codeButton(renderer: ReactTestRenderer, label: string) {
return button.props as ComponentProps<typeof Button>;
}

describe("Markdown file quote in sent messages", () => {
it("renders validated file links as the shared citation chip, but not inline code examples", () => {
const citation: FileCitation = {
kind: "file",
version: 1,
environmentId: EnvironmentId.make("local"),
threadId: ThreadId.make("source"),
cwd: "/workspace",
path: "notes.md",
revision: `sha256:${"a".repeat(64)}`,
origin: "draft",
from: 1,
to: 6,
sourceStart: 0,
sourceEnd: 10,
startLine: 1,
endLine: 1,
text: "Hello",
prefix: "",
suffix: "",
};
const token = serializeComposerCitation(citation);
const html = renderToStaticMarkup(<ChatMarkdown cwd="/workspace" text={token} />);
expect(html).toContain('data-file-citation-chip="true"');
expect(html).toContain("notes.md");
expect(html).toContain("Hello");
expect(html).not.toContain('data-assistant-citation-chip="true"');
const code = renderToStaticMarkup(<ChatMarkdown cwd="/workspace" text={`\`${token}\``} />);
expect(code).not.toContain('data-file-citation-chip="true"');
expect(code).toContain("scient-file-citation");
const invalid = renderToStaticMarkup(
<ChatMarkdown
cwd="/workspace"
text="[File quote](scient-file-citation://v1/?data=bad) [unsafe](javascript:alert)"
/>,
);
expect(invalid).not.toContain("data-file-citation-chip");
expect(invalid).not.toContain('href="scient-file-citation');
expect(invalid).not.toContain('href="javascript:');
});
});

describe("ChatMarkdown favicon privacy", () => {
it("suppresses private link images while preserving public links across updates", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ import { defaultUrlTransform } from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkBreaks from "remark-breaks";
import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";
import { AssistantCitationChip } from "./chat/AssistantCitationChip";
import { parseComposerCitationHref } from "@t3tools/shared/composerCitations";
import { CitationChip } from "./chat/AssistantCitationChip";
import remarkGfm from "remark-gfm";
import { remarkGithubAlerts } from "../markdown-github-alerts";
import {
Expand Down Expand Up @@ -503,7 +503,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
},
protocols: {
...defaultSchema.protocols,
href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation"],
href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation", "scient-file-citation"],
src: [...(defaultSchema.protocols?.src ?? []), "file"],
},
} satisfies Parameters<typeof rehypeSanitize>[0];
Expand Down Expand Up @@ -2179,7 +2179,7 @@ function useChatMarkdownState({
return buildFileLinkParentSuffixByPath(filePaths);
}, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]);
const markdownUrlTransform = useCallback((href: string) => {
if (parseAssistantCitationHref(href)) return href;
if (parseComposerCitationHref(href)) return href;
if (isWindowsDrivePathHref(href)) return href;
return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
}, []);
Expand Down Expand Up @@ -2763,8 +2763,8 @@ const CHAT_MARKDOWN_COMPONENTS = {
updateThreadPullRequestLink,
} = use(ChatMarkdownRendererContext);

const citation = href ? parseAssistantCitationHref(href) : null;
if (citation) return <AssistantCitationChip citation={citation} />;
const citation = href ? parseComposerCitationHref(href) : null;
if (citation) return <CitationChip citation={citation} />;
const normalizedHref = href ? normalizeMarkdownLinkHref(href) : "";
const fileLinkMeta = normalizedHref
? (markdownFileLinkMetaByHref.get(markdownLinkLookupKey(normalizedHref)) ??
Expand Down
24 changes: 21 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ import {
} from "react";
import { flushSync } from "react-dom";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
import { useFileCitationTarget } from "~/scient/markdownEditor/fileCitationNavigation";
import { composerCitationsToPlainText } from "@t3tools/shared/composerCitations";
import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation";
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
import { useShallow } from "zustand/react/shallow";
Expand Down Expand Up @@ -1619,7 +1620,10 @@ function ChatViewContent(props: ChatViewProps) {
const citationLocation = useLocation({
select: (location) => ({
href: location.href,
key: location.state.assistantCitationActivation ?? location.state.__TSR_key,
key:
location.state.fileCitationActivation ??
location.state.assistantCitationActivation ??
location.state.__TSR_key,
}),
});
const citationRequest = useMemo<AssistantCitationRequest | null>(() => {
Expand Down Expand Up @@ -3693,6 +3697,12 @@ function ChatViewContent(props: ChatViewProps) {
worktreePath: activeThreadWorktreePath,
projectCwd: activeProjectCwd,
});
useFileCitationTarget(
activeThreadRef,
citationLocation,
activeWorkspaceRoot,
runAfterPendingFileSave,
);
useEffect(() => {
if (!activeThreadRef) return;
restoreForkPdfContinuity({
Expand Down Expand Up @@ -7600,7 +7610,7 @@ function ChatViewContent(props: ChatViewProps) {
firstComposerImageName = firstComposerImage.name;
}
}
let titleSeed = assistantCitationsToPlainText(trimmed);
let titleSeed = composerCitationsToPlainText(trimmed);
if (!titleSeed) {
if (firstComposerImageName) {
titleSeed = `Image: ${firstComposerImageName}`;
Expand Down Expand Up @@ -8954,6 +8964,14 @@ function ChatViewContent(props: ChatViewProps) {
projectName={activeProject?.title ?? "Project"}
threadRef={activeThreadRef}
composerDraftTarget={composerDraftTarget}
onCiteFile={(citation, anchor) =>
composerRef.current?.citeText(citation, anchor) ?? false
}
fileCitation={
renderedRightPanelSurface.kind === "file"
? renderedRightPanelSurface.fileCitation
: undefined
}
keybindings={keybindings}
availableEditors={availableEditors}
relativePath={
Expand Down
Loading
Loading