diff --git a/apps/mobile/src/components/AssistantCitationSheet.tsx b/apps/mobile/src/components/AssistantCitationSheet.tsx
new file mode 100644
index 0000000000000..ed67deadf377f
--- /dev/null
+++ b/apps/mobile/src/components/AssistantCitationSheet.tsx
@@ -0,0 +1,126 @@
+import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH, type AssistantCitation } from "@t3tools/contracts";
+import { scopedThreadKey } from "@t3tools/client-runtime/environment";
+import { useState } from "react";
+import { Platform, ScrollView, TextInput, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread";
+import { requestThreadMessageJump } from "../features/threads/threadMessageJump";
+import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
+import { AppText as Text } from "./ui/AppText";
+import { ListActionRow } from "./ui/ListActionRow";
+import { ListSection } from "./ui/ListSection";
+import { Sheet } from "./ui/Sheet";
+
+const CONTEXT_CHARS = 80;
+
+function contextBefore(prefix: string) {
+ const flat = prefix.replace(/\s+/g, " ");
+ if (flat.length <= CONTEXT_CHARS) return flat.trimStart();
+ const cut = flat.slice(-CONTEXT_CHARS);
+ return `…${cut.slice(cut.indexOf(" ") + 1)}`;
+}
+
+function contextAfter(suffix: string) {
+ const flat = suffix.replace(/\s+/g, " ");
+ if (flat.length <= CONTEXT_CHARS) return flat.trimEnd();
+ const cut = flat.slice(0, CONTEXT_CHARS);
+ return `${cut.slice(0, cut.lastIndexOf(" "))}…`;
+}
+
+export function AssistantCitationSheet(props: {
+ readonly citation: AssistantCitation;
+ readonly onClose: () => void;
+ readonly onRemove?: () => void;
+ readonly onCommentChange?: (comment: string) => void;
+ readonly canChangeComment?: (comment: string) => boolean;
+}) {
+ const openLinkedThread = useOpenLinkedThread();
+ const insets = useSafeAreaInsets();
+ const citation = props.citation;
+ const [comment, setComment] = useState(citation.comment ?? "");
+ const commentChanged = comment.trim() !== (citation.comment ?? "");
+ const commentTooLong = commentChanged && props.canChangeComment?.(comment) === false;
+ const close = () => {
+ if (commentTooLong) return;
+ if (props.onCommentChange && commentChanged) {
+ props.onCommentChange(comment);
+ }
+ props.onClose();
+ };
+ return (
+
+
+
+
+
+ {contextBefore(citation.prefix)}
+ {citation.text}
+ {contextAfter(citation.suffix)}
+
+
+ {props.onCommentChange ? (
+
+
+ {commentTooLong ? (
+
+ This comment makes the message too long to send. Shorten it to save and close.
+
+ ) : null}
+
+ ) : citation.comment ? (
+
+
+ {citation.comment}
+
+
+ ) : null}
+
+ {
+ if (commentTooLong) return;
+ close();
+ requestThreadMessageJump(scopedThreadKey(citation), citation.messageId, {
+ text: citation.text,
+ occurrence: citation.occurrence,
+ });
+ openLinkedThread(citation);
+ }}
+ />
+ copyTextWithHaptic(citation.text)}
+ />
+ {props.onRemove ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/ComposerEditor.ios.test.tsx b/apps/mobile/src/components/ComposerEditor.ios.test.tsx
index f7ce1f9918d85..60f37716b84bb 100644
--- a/apps/mobile/src/components/ComposerEditor.ios.test.tsx
+++ b/apps/mobile/src/components/ComposerEditor.ios.test.tsx
@@ -116,6 +116,7 @@ vi.mock("expo-file-system", () => ({
vi.mock("./ui/Dialog", () => ({ alert: vi.fn() }));
vi.mock("./ui/AppText", () => ({ AppText: ({ children }: { children?: ReactNode }) => children }));
vi.mock("./ComposerContextSheet", () => ({ ComposerContextSheet: () => null }));
+vi.mock("./AssistantCitationSheet", () => ({ AssistantCitationSheet: () => null }));
vi.mock("../features/threads/useOpenLinkedThread", () => ({ useOpenLinkedThread: () => vi.fn() }));
vi.mock("../state/peerLinks", () => ({ useLinkedEnvironments: () => [] }));
vi.mock("../state/model-mentions", () => ({ useModelMentions: () => [] }));
diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx
index f7118b95b425f..ea527efdb8e94 100644
--- a/apps/mobile/src/components/ComposerEditor.tsx
+++ b/apps/mobile/src/components/ComposerEditor.tsx
@@ -7,6 +7,12 @@ import { useEffect, useState } from "react";
import type { EnvironmentId } from "@t3tools/contracts";
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
+import {
+ collectAssistantCitations,
+ serializeAssistantCitation,
+ withAssistantCitationComment,
+} from "@t3tools/shared/assistantCitations";
+import { AssistantCitationSheet } from "./AssistantCitationSheet";
import { ComposerEditor as NativeComposerEditor } from "../native/T3ComposerEditor";
import type { ComposerEditorProps as NativeComposerEditorProps } from "../native/T3ComposerEditor";
import {
@@ -34,6 +40,7 @@ import { useLinkedEnvironments } from "../state/peerLinks";
import { ComposerContextSheet } from "./ComposerContextSheet";
import { AppText as Text } from "./ui/AppText";
import {
+ assistantCitationDraftWithinLimit,
composerDocumentAttachment,
composerMentionPath,
type ComposerDocumentAttachment,
@@ -169,6 +176,9 @@ export function ComposerEditor({
const selectedReference = selected
? collectComposerContextReferences(selected.source)[0]
: undefined;
+ const selectedCitation = selected
+ ? collectAssistantCitations(selected.source)[0]?.citation
+ : undefined;
const selectedSkillName = selected?.source.match(/^\p{Sc}(.+)$/u)?.[1];
const selectedSkill = selectedSkillName
? props.skills?.find((skill) => skill.name === selectedSkillName)
@@ -176,6 +186,39 @@ export function ComposerEditor({
const record = draft.context?.records.find(
(entry) => entry.contextId === selectedReference?.contextId,
);
+ const removeSelected =
+ props.readOnly || props.editable === false
+ ? undefined
+ : () => {
+ if (!selected) return;
+ if (props.value.slice(selected.start, selected.end) === selected.source) {
+ changeText(props.value.slice(0, selected.start) + props.value.slice(selected.end));
+ props.onSelectionChange?.({ start: selected.start, end: selected.start });
+ }
+ setSelected(null);
+ };
+ const commentSelectedCitation =
+ props.readOnly || props.editable === false || !selected || !selectedCitation
+ ? undefined
+ : (comment: string) => {
+ if (props.value.slice(selected.start, selected.end) !== selected.source) return;
+ const next = serializeAssistantCitation(
+ withAssistantCitationComment(selectedCitation, comment),
+ );
+ const text =
+ props.value.slice(0, selected.start) + next + props.value.slice(selected.end);
+ if (!assistantCitationDraftWithinLimit(text)) {
+ Dialog.alert(
+ "Could not update quote",
+ "This comment would make the message too long to send. Shorten the comment or draft.",
+ );
+ return;
+ }
+ const cursor = selected.start + next.length;
+ if (draftKey)
+ rememberComposerDraftSelection(draftKey, text, { start: cursor, end: cursor });
+ changeText(text);
+ };
return (
<>
{/* Reset revision history with the recreated iOS view without aborting context imports. */}
@@ -226,6 +269,26 @@ export function ComposerEditor({
{importing ? (
Copying context…
) : null}
+ {selectedCitation ? (
+ setSelected(null)}
+ onRemove={removeSelected}
+ onCommentChange={commentSelectedCitation}
+ canChangeComment={
+ selected && commentSelectedCitation
+ ? (comment) =>
+ assistantCitationDraftWithinLimit(
+ props.value.slice(0, selected.start) +
+ serializeAssistantCitation(
+ withAssistantCitationComment(selectedCitation, comment),
+ ) +
+ props.value.slice(selected.end),
+ )
+ : undefined
+ }
+ />
+ ) : null}
{selected && (selectedReference || selectedSkill) ? (
setSelected(null)}
- onRemove={
- props.readOnly || props.editable === false
- ? undefined
- : () => {
- if (props.value.slice(selected.start, selected.end) === selected.source) {
- changeText(
- props.value.slice(0, selected.start) + props.value.slice(selected.end),
- );
- props.onSelectionChange?.({ start: selected.start, end: selected.start });
- }
- setSelected(null);
- }
- }
+ onRemove={removeSelected}
/>
) : null}
>
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index 3e7eec1449e26..c6c987f8838da 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -130,7 +130,15 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
import type { ComposerEditorHandle } from "../../components/ComposerEditor";
import type { StatusTone } from "../../components/StatusPill";
-import { getComposerDraftSnapshot, setFreshSessionPending } from "../../state/use-composer-drafts";
+import {
+ captureComposerDraftInsertion,
+ getComposerDraftSnapshot,
+ setFreshSessionPending,
+ insertComposerDraftText,
+} from "../../state/use-composer-drafts";
+import { assistantCitationDraftWithinLimit } from "../../lib/composerContext";
+import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
+import type { AssistantCitation } from "@t3tools/contracts";
import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
import {
ANDROID_READING_MAX_WIDTH,
@@ -211,6 +219,7 @@ import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-scroll-position
import { useOpenLinkedThread } from "./useOpenLinkedThread";
import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider";
import type { OrchestrationV2ProviderSession } from "@t3tools/contracts";
+import { voiceInputBlocksSubmission } from "@t3tools/client-runtime/voice-input";
export interface ThreadDetailScreenProps {
readonly header?: React.ReactNode;
@@ -1381,6 +1390,39 @@ function ThreadDetailBody(
composerEditorRef.current?.blur();
};
+ const canCiteAssistantText =
+ props.composerDraftKey !== null &&
+ !isProviderSubagent &&
+ !composerSlotHidden &&
+ !props.isSavingQueuedEdit &&
+ !props.draftMode?.interactionLocked &&
+ !(
+ voiceInputSession.ownerKey === props.composerDraftKey &&
+ voiceInputBlocksSubmission(voiceInputSession.controller.currentState)
+ );
+ const handleCiteAssistantText = (citation: AssistantCitation) => {
+ if (
+ !canCiteAssistantText ||
+ props.composerDraftKey === null ||
+ (voiceInputSession.ownerKey === props.composerDraftKey &&
+ voiceInputBlocksSubmission(voiceInputSession.controller.currentState))
+ )
+ return;
+ const target = captureComposerDraftInsertion(props.composerDraftKey);
+ const prefix = target.start > 0 && !/\s/.test(target.text[target.start - 1]!) ? " " : "";
+ const value = `${prefix}${serializeAssistantCitation(citation)} `;
+ const text = target.text.slice(0, target.start) + value + target.text.slice(target.end);
+ if (!assistantCitationDraftWithinLimit(text)) {
+ Dialog.alert(
+ "Could not add quote",
+ "This quote would make the message too long to send. Shorten the draft or select less text.",
+ );
+ return;
+ }
+ insertComposerDraftText(props.composerDraftKey, value, target);
+ composerEditorRef.current?.focus();
+ };
+
const handleUseArtifactTemplate = (template: CodexArtifactTemplate) => {
const draftMessage =
props.composerDraftKey === null ? "" : getComposerDraftSnapshot(props.composerDraftKey).text;
@@ -1518,6 +1560,7 @@ function ThreadDetailBody(
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
onIsAtEndChange={setIsAtEnd}
skills={selectedProviderSkills}
+ onCiteAssistantText={canCiteAssistantText ? handleCiteAssistantText : undefined}
onUseArtifactTemplate={handleUseArtifactTemplate}
/>
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index dbbcaa1fd9cff..3686be8c9be30 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -1,5 +1,13 @@
+import { Sheet } from "../../components/ui/Sheet";
+import { ListActionRow } from "../../components/ui/ListActionRow";
+import { ListSection } from "../../components/ui/ListSection";
+import {
+ collectMobileAssistantTextSelectors,
+ locateMobileAssistantCitation,
+} from "../../lib/assistantCitationSelection";
import { Button } from "../../components/ui/Button";
import * as Dialog from "../../components/ui/Dialog";
+import { ASSISTANT_CITATION_MAX_TEXT_LENGTH, type AssistantCitation } from "@t3tools/contracts";
import { useOpenPullRequest } from "../../native/useOpenPullRequest";
import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width";
import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo";
@@ -60,7 +68,6 @@ import {
type ScopedThreadRef,
} from "@t3tools/contracts";
import type { ToolCallVisibility } from "@t3tools/contracts/settings";
-import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
import { hostedThreadLink, parseThreadLinkHref } from "@t3tools/shared/threadLinks";
import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks";
@@ -117,6 +124,7 @@ import {
} from "react";
import {
Image,
+ ScrollView,
Platform,
type LayoutChangeEvent,
type NativeScrollEvent,
@@ -160,6 +168,7 @@ import {
type NativeMarkdownTextStyle,
type SelectableMarkdownSkill,
} from "../../native/SelectableMarkdownText";
+import { pendingThreadMessageJumpQuote, type ThreadMessageJumpQuote } from "./threadMessageJump";
import { AppText as Text } from "../../components/ui/AppText";
import { VideoPreviewModal, type VideoPreviewSource } from "../../components/ui/VideoPreviewModal";
@@ -347,6 +356,7 @@ export interface ThreadFeedProps {
readonly onIsAtEndChange?: (isAtEnd: boolean) => void;
readonly skills?: ReadonlyArray;
readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
+ readonly onCiteAssistantText?: (citation: AssistantCitation) => void;
}
const USER_MESSAGE_COPY_MENU_ACTIONS = [
@@ -1138,15 +1148,36 @@ function ArtifactTemplateCard(props: {
const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
readonly markdown: string;
readonly environmentId: EnvironmentId;
+ readonly citationSource?: Pick;
+ readonly onCiteAssistantText?: (citation: AssistantCitation) => void;
+ readonly highlight?: ThreadMessageJumpQuote | undefined;
readonly markdownStyles: MarkdownStyleSet;
readonly linkHandlers: MarkdownLinkHandlers;
readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
readonly renderImage: MarkdownImageRenderer;
readonly skills?: ReadonlyArray | undefined;
}) {
+ const [quoteChoices, setQuoteChoices] = useState<
+ ReturnType
+ >([]);
+ const cite = (selector: (typeof quoteChoices)[number]) => {
+ if (!props.citationSource || !props.onCiteAssistantText) return;
+ setQuoteChoices([]);
+ props.onCiteAssistantText({
+ version: 1,
+ environmentId: props.environmentId,
+ ...props.citationSource,
+ ...selector,
+ });
+ };
const models = useModelMentions(props.environmentId, props.markdown);
const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
const threadIcons = useThreadChipIcons(props.environmentId, liveMarkdown);
+ const located = props.highlight
+ ? locateMobileAssistantCitation(liveMarkdown, props.highlight.text, props.highlight.occurrence)
+ : null;
+ const highlight =
+ located && props.highlight ? { ...located, text: props.highlight.text } : undefined;
const linkHandlers: MarkdownLinkHandlers = {
...props.linkHandlers,
linkCustomization: (href) =>
@@ -1154,21 +1185,70 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
};
return (
-
+ <>
+ {
+ if (!text.trim()) return;
+ if (text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH) {
+ Dialog.alert(
+ "Quote is too long",
+ `Select up to ${ASSISTANT_CITATION_MAX_TEXT_LENGTH.toLocaleString()} characters to cite.`,
+ );
+ return;
+ }
+ const selectors = collectMobileAssistantTextSelectors(liveMarkdown, path, text);
+ if (selectors.length === 0) {
+ Dialog.alert(
+ "Unable to locate quote",
+ "Select more surrounding text to cite its source accurately.",
+ );
+ return;
+ }
+ if (selectors.length === 1) cite(selectors[0]!);
+ else setQuoteChoices(selectors);
+ }
+ : undefined
+ }
+ markdownStyles={props.markdownStyles}
+ linkHandlers={linkHandlers}
+ onUseArtifactTemplate={props.onUseArtifactTemplate}
+ renderImage={props.renderImage}
+ skills={props.skills}
+ models={models}
+ />
+ {quoteChoices.length > 0 && props.onCiteAssistantText ? (
+ setQuoteChoices([])}>
+
+
+ {quoteChoices.map((selector) => (
+ cite(selector)}
+ />
+ ))}
+
+
+
+ ) : null}
+ >
);
});
// Folds split first, so an artifact card inside a fold stays inside it.
function AssistantMarkdownBlocks(props: {
readonly markdown: string;
+ readonly citationPath?: string;
+ readonly highlight?: { readonly path: string; readonly index: number; readonly text: string };
+ readonly onCiteText?: (text: string, path: string) => void;
readonly markdownStyles: MarkdownStyleSet;
readonly linkHandlers: MarkdownLinkHandlers;
readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
@@ -1185,7 +1265,7 @@ function AssistantMarkdownBlocks(props: {
),
[props.markdown],
);
- return blocks.map((block) => {
+ return blocks.map((block, blockIndex) => {
if (block.kind === "details") {
return (
-
+
);
}
- return block.segments.map((segment) => {
+ return block.segments.map((segment, segmentIndex) => {
const offset = block.sourceOffset + segment.sourceOffset;
if (segment.kind === "artifact-template") {
return (
@@ -1209,6 +1293,7 @@ function AssistantMarkdownBlocks(props: {
);
}
if (segment.markdown.trim().length === 0) return null;
+ const citationPath = `${props.citationPath ?? ""}${blockIndex}:${segmentIndex}`;
return (
props.onCiteText?.(text, citationPath) : undefined
+ }
+ highlightText={props.highlight?.path === citationPath ? props.highlight.text : undefined}
+ highlightOccurrence={props.highlight?.index}
{...props.linkHandlers}
renderImage={props.renderImage}
/>
@@ -1485,7 +1575,7 @@ function ThreadAutomationMessageRow(props: {
& {
readonly copiedRowId: string | null;
+ readonly citationHighlight: CitationHighlight | null;
readonly expandedWorkRows: Record;
readonly workRowSizing: ReturnType;
readonly workGroupScrollPositions: Map;
@@ -1855,7 +1955,7 @@ function renderFeedEntry(
const { message } = entry;
const isUser = message.role === "user";
const presentation = resolveUserMessagePresentation(message);
- const renderedText = renderAssistantCitationsAsText(presentation.text);
+ const renderedText = presentation.text;
const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
const timestampLabel = formatMessageTimestamp(isUser ? message.createdAt : message.updatedAt);
const attachments = message.attachments ?? [];
@@ -2254,6 +2354,13 @@ function renderFeedEntry(
| null>(null);
+ const citationHighlightTimeoutRef = useRef | null>(null);
const headerMaterialVisibleRef = useRef(false);
const { width: windowWidth, fontScale } = useWindowDimensions();
const { appearance } = useAppearancePreferences();
@@ -2732,15 +2840,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
readonly expandedWorkRows: Record;
readonly expandedTurnIds: ReadonlySet;
readonly revealedMessageId: MessageId | null;
+ readonly citationHighlight: CitationHighlight | null;
}>({
copiedRowId: null,
expandedWorkGroups: {},
expandedWorkRows: {},
expandedTurnIds: new Set(),
revealedMessageId: null,
+ citationHighlight: null,
});
- const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds, revealedMessageId } =
- interactionState;
+ const {
+ copiedRowId,
+ expandedWorkGroups,
+ expandedWorkRows,
+ expandedTurnIds,
+ revealedMessageId,
+ citationHighlight,
+ } = interactionState;
const markdownLinkRequest = useRef(null);
const onInlinePlaybackStart = () => {
markdownLinkRequest.current?.abort();
@@ -3255,11 +3371,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const listAppearanceData = {
disclosureLayout,
reduceMotion,
+ onCiteAssistantText: props.onCiteAssistantText,
worktreeSetup: props.worktreeSetup,
setupWorkingStartedAt: props.setupWorkingStartedAt,
dispatchingMessageId: props.dispatchingMessageId,
unsettledTurnId,
copiedRowId,
+ citationHighlight,
expandedWorkGroups,
expandedWorkRows,
workRowSizing,
@@ -3352,6 +3470,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const jumpIndex = jumpReady
? presentedFeed.findIndex((entry) => entry.type === "message" && entry.id === jumpToMessageId)
: null;
+ const highlightJumpQuote = () => {
+ const quote = pendingThreadMessageJumpQuote(feedThreadKey);
+ if (!quote || !jumpToMessageId) return;
+ const highlight = { messageId: jumpToMessageId, quote };
+ setInteractionState((current) => ({ ...current, citationHighlight: highlight }));
+ if (citationHighlightTimeoutRef.current) clearTimeout(citationHighlightTimeoutRef.current);
+ citationHighlightTimeoutRef.current = setTimeout(() => {
+ setInteractionState((current) =>
+ current.citationHighlight === highlight ? { ...current, citationHighlight: null } : current,
+ );
+ citationHighlightTimeoutRef.current = null;
+ }, CITATION_HIGHLIGHT_MS);
+ };
const landJump = useEffectEvent((index: number) => {
const list = listRef.current;
if (!list) return;
@@ -3359,7 +3490,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const positioning =
index === -1 ? undefined : list.scrollToIndex({ index, viewPosition: 0.2, animated: false });
void Promise.resolve(positioning).then(() => {
- if (!cancelled && listRef.current === list) props.onJumpedToMessage?.();
+ if (cancelled || listRef.current !== list) return;
+ if (index !== -1) highlightJumpQuote();
+ props.onJumpedToMessage?.();
});
return () => {
cancelled = true;
@@ -3423,6 +3556,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
useEffect(
() => () => {
if (copyFeedbackTimeoutRef.current) clearTimeout(copyFeedbackTimeoutRef.current);
+ if (citationHighlightTimeoutRef.current) clearTimeout(citationHighlightTimeoutRef.current);
if (disclosureMotionTimer.current !== null) clearTimeout(disclosureMotionTimer.current);
if (disclosureSettleFrameRef.current !== null) {
cancelAnimationFrame(disclosureSettleFrameRef.current);
@@ -3669,8 +3803,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
dispatchingMessageId: props.dispatchingMessageId,
onEditPendingMessage: props.onEditPendingMessage,
onUseArtifactTemplate: props.onUseArtifactTemplate,
+ onCiteAssistantText: props.onCiteAssistantText,
threadId: props.threadId,
copiedRowId,
+ citationHighlight,
expandedWorkRows,
workRowSizing,
workGroupScrollPositions,
diff --git a/apps/mobile/src/features/threads/threadMessageJump.ts b/apps/mobile/src/features/threads/threadMessageJump.ts
index 405084016138a..ff1bbc810ae37 100644
--- a/apps/mobile/src/features/threads/threadMessageJump.ts
+++ b/apps/mobile/src/features/threads/threadMessageJump.ts
@@ -1,4 +1,6 @@
-import type { MessageId } from "@t3tools/contracts";
+import type { AssistantCitation, MessageId } from "@t3tools/contracts";
+
+export type ThreadMessageJumpQuote = Pick;
// A palette message result opens its thread at that message. The thread
// screen reads the request while it shows that thread, and finishes it once
@@ -8,6 +10,7 @@ const PENDING_JUMP_MS = 30_000;
let pending: {
readonly threadKey: string;
readonly messageId: MessageId;
+ readonly quote: ThreadMessageJumpQuote | null;
readonly at: number;
} | null = null;
const listeners = new Set<() => void>();
@@ -16,8 +19,12 @@ function notify() {
for (const listener of listeners) listener();
}
-export function requestThreadMessageJump(threadKey: string, messageId: MessageId): void {
- pending = { threadKey, messageId, at: Date.now() };
+export function requestThreadMessageJump(
+ threadKey: string,
+ messageId: MessageId,
+ quote: ThreadMessageJumpQuote | null = null,
+): void {
+ pending = { threadKey, messageId, quote, at: Date.now() };
notify();
}
@@ -28,6 +35,11 @@ export function pendingThreadMessageJump(threadKey: string): MessageId | null {
: null;
}
+/** The quoted text to highlight once the pending jump lands, if a citation asked for one. */
+export function pendingThreadMessageJumpQuote(threadKey: string): ThreadMessageJumpQuote | null {
+ return pendingThreadMessageJump(threadKey) === null ? null : (pending?.quote ?? null);
+}
+
export function finishThreadMessageJump(threadKey: string): void {
if (pending?.threadKey !== threadKey) return;
pending = null;
diff --git a/apps/mobile/src/lib/assistantCitationSelection.test.ts b/apps/mobile/src/lib/assistantCitationSelection.test.ts
new file mode 100644
index 0000000000000..c5d5f1e48474c
--- /dev/null
+++ b/apps/mobile/src/lib/assistantCitationSelection.test.ts
@@ -0,0 +1,86 @@
+import { expect, it } from "vite-plus/test";
+import {
+ collectMobileAssistantTextSelectors,
+ locateMobileAssistantCitation,
+} from "./assistantCitationSelection";
+import {
+ findAssistantCitationText,
+ serializeAssistantCitation,
+ expandAssistantCitationsForProvider,
+} from "@t3tools/shared/assistantCitations";
+import { EnvironmentId, ThreadId, MessageId } from "@t3tools/contracts";
+
+it("offers distinct source choices within a block and maps recursive folds without merging occurrences", () => {
+ const paragraph = `${"x".repeat(40)}**quote**${"x".repeat(40)}`;
+ const markdown = `${paragraph}\n\nOuter
\n\nInner
\n\n${paragraph}\n\n \n\n `;
+ const first = collectMobileAssistantTextSelectors(markdown, "0:0", "quote")[0];
+ const second = collectMobileAssistantTextSelectors(markdown, "1/1/0:0", "quote")[0];
+ expect(first?.occurrence).toEqual({ index: 0, count: 2 });
+ expect(second?.occurrence).toEqual({ index: 1, count: 2 });
+ expect(locateMobileAssistantCitation(markdown, "quote", first!.occurrence)).toEqual({
+ path: "0:0",
+ index: 0,
+ });
+ expect(locateMobileAssistantCitation(markdown, "quote", second!.occurrence)).toEqual({
+ path: "1/1/0:0",
+ index: 0,
+ });
+ expect(locateMobileAssistantCitation("go now, then go now", "go now", { index: 1 })).toEqual({
+ path: "0:0",
+ index: 1,
+ });
+ expect(locateMobileAssistantCitation(markdown, "quote", { index: 2 })).toBeNull();
+ const rendered = `${"x".repeat(40)}quote${"x".repeat(40)} ${"x".repeat(40)}quote${"x".repeat(40)}`;
+ expect(findAssistantCitationText(rendered, second!)).toEqual({
+ start: rendered.lastIndexOf("quote"),
+ end: rendered.lastIndexOf("quote") + 5,
+ });
+ const markers = [first!, second!].map((selector) =>
+ serializeAssistantCitation({
+ version: 1,
+ environmentId: EnvironmentId.make("env"),
+ threadId: ThreadId.make("thread"),
+ messageId: MessageId.make("assistant"),
+ ...selector,
+ }),
+ );
+ expect(markers[0]).not.toBe(markers[1]);
+ expect(expandAssistantCitationsForProvider(markers.join(" "))).toContain(
+ '"id": "assistant-quote-2"',
+ );
+ const sameBlock = collectMobileAssistantTextSelectors(
+ "first **quote** then **quote** last",
+ "0:0",
+ "quote",
+ );
+ expect(sameBlock[1]?.start).toBe(17);
+ expect(sameBlock[1]?.prefix).toBe("first quote then ");
+});
+
+it("rejects unmappable or blank selections instead of inventing a source", () => {
+ expect(collectMobileAssistantTextSelectors("quote quote", "0:0", " ")).toEqual([]);
+ expect(collectMobileAssistantTextSelectors("quote ".repeat(21), "0:0", "quote")).toEqual([]);
+ expect(collectMobileAssistantTextSelectors("quote quote", "0:0", "other")).toEqual([]);
+ expect(collectMobileAssistantTextSelectors("quote quote", "missing", "quote")).toEqual([]);
+});
+
+it("uses the web math representation around a selected passage", () => {
+ for (const formula of ["$x$", "\\(x\\)"]) {
+ const [selector] = collectMobileAssistantTextSelectors(
+ `Before ${formula} after`,
+ "0:0",
+ "after",
+ );
+ expect(selector?.prefix).toBe("Before \\(x\\) ");
+ expect(findAssistantCitationText("Before \\(x\\) after", selector!)).toEqual({
+ start: 13,
+ end: 18,
+ });
+ }
+ const [display] = collectMobileAssistantTextSelectors(
+ "Before\n\n$$\nx\n$$\n\nafter",
+ "0:0",
+ "after",
+ );
+ expect(display?.prefix).toBe("Before \\[ x \\] ");
+});
diff --git a/apps/mobile/src/lib/assistantCitationSelection.ts b/apps/mobile/src/lib/assistantCitationSelection.ts
new file mode 100644
index 0000000000000..e878580daf373
--- /dev/null
+++ b/apps/mobile/src/lib/assistantCitationSelection.ts
@@ -0,0 +1,129 @@
+import { markdownMathRanges } from "@t3tools/shared/markdownMath";
+import { createAssistantTextSelector } from "@t3tools/shared/assistantCitations";
+import {
+ splitCodexArtifactTemplateMarkdown,
+ renderCodexInlineDirectivesAsMarkdown,
+} from "@t3tools/shared/codexMarkdownDirectives";
+import { splitMarkdownDetails } from "./markdownDetails";
+import { parseMarkdownDocument, type MarkdownNode } from "./markdownDocument";
+
+function renderedText(node: MarkdownNode, restoreMath: (value: string) => string): string {
+ if (
+ node.type === "definition" ||
+ node.type === "image" ||
+ node.type === "imageReference" ||
+ node.type === "html"
+ )
+ return "";
+ if ("value" in node) return restoreMath(node.value);
+ if ("children" in node) {
+ const inline =
+ node.type === "paragraph" ||
+ node.type === "heading" ||
+ node.type === "link" ||
+ node.type === "linkReference" ||
+ node.type === "emphasis" ||
+ node.type === "strong" ||
+ node.type === "delete" ||
+ node.type === "tableCell";
+ return node.children.map((child) => renderedText(child, restoreMath)).join(inline ? "" : "\n");
+ }
+ return node.type === "break" ? "\n" : "";
+}
+
+function citationText(markdown: string): string {
+ const formulas = markdownMathRanges(markdown);
+ let marker = "\ue000t3math";
+ while (markdown.includes(marker)) marker += "_";
+ const tokens = formulas.map((_, index) => `${marker}${index}\ue001`);
+ let cursor = 0;
+ let prose = "";
+ for (const [index, formula] of formulas.entries()) {
+ prose += markdown.slice(cursor, formula.start) + tokens[index];
+ cursor = formula.end;
+ }
+ prose += markdown.slice(cursor);
+ return renderedText(parseMarkdownDocument(prose), (value) =>
+ value.replace(new RegExp(`${marker}(\\d+)\ue001`, "g"), (_, index: string) => {
+ const formula = formulas[Number(index)]!;
+ const tex = formula.tex.trim();
+ return formula.display ? `\\[\n${tex}\n\\]` : `\\(${tex}\\)`;
+ }),
+ );
+}
+
+function citationSegments(markdown: string, path = ""): Array<{ path: string; text: string }> {
+ return splitMarkdownDetails(markdown).flatMap((block, index) => {
+ if (block.kind === "details") return citationSegments(block.body, `${path}${index}/`);
+ return splitCodexArtifactTemplateMarkdown(block.markdown).flatMap((segment, segmentIndex) =>
+ segment.kind === "markdown"
+ ? [
+ {
+ path: `${path}${index}:${segmentIndex}`,
+ text: citationText(renderCodexInlineDirectivesAsMarkdown(segment.markdown)),
+ },
+ ]
+ : [],
+ );
+ });
+}
+
+function occurrences(text: string, quote: string): number[] {
+ const result: number[] = [];
+ for (let index = text.indexOf(quote); index !== -1; index = text.indexOf(quote, index + 1))
+ result.push(index);
+ return result;
+}
+
+export function collectMobileAssistantTextSelectors(markdown: string, path: string, text: string) {
+ if (!text.trim()) return [];
+ const normalize = (value: string) => value.replace(/\s+/g, " ");
+ const quote = normalize(text);
+ const segments = citationSegments(markdown);
+ const segmentIndex = segments.findIndex((segment) => segment.path === path);
+ const segment = segments[segmentIndex];
+ if (!segment) return [];
+ const leading = segments
+ .slice(0, segmentIndex)
+ .map((entry) => entry.text)
+ .join("\n");
+ const fullText = normalize(segments.map((entry) => entry.text).join("\n"));
+ const offset = normalize(leading + (segmentIndex > 0 ? "\n" : "")).length;
+ const matches = occurrences(fullText, quote);
+ const localMatches = occurrences(normalize(segment.text), quote);
+ if (localMatches.length > 20) return [];
+ return localMatches.flatMap((localStart) => {
+ const start = offset + localStart;
+ const index = matches.indexOf(start);
+ if (index === -1) return [];
+ const selector = createAssistantTextSelector(fullText, start, start + quote.length);
+ return selector ? [{ ...selector, text, occurrence: { index, count: matches.length } }] : [];
+ });
+}
+
+/** The Markdown segment holding a citation's quote, and which of its matches the citation means. */
+export function locateMobileAssistantCitation(
+ markdown: string,
+ text: string,
+ occurrence?: { readonly index: number },
+): { path: string; index: number } | null {
+ const normalize = (value: string) => value.replace(/\s+/g, " ");
+ if (!text.trim()) return null;
+ const quote = normalize(text);
+ const segments = citationSegments(markdown);
+ const target = occurrences(normalize(segments.map((entry) => entry.text).join("\n")), quote)[
+ occurrence?.index ?? 0
+ ];
+ if (target === undefined) return null;
+ // Offsets as `collectMobileAssistantTextSelectors` computes them, so whitespace collapses the same.
+ for (const [segmentIndex, segment] of segments.entries()) {
+ const leading = segments
+ .slice(0, segmentIndex)
+ .map((entry) => entry.text)
+ .join("\n");
+ const offset = normalize(leading + (segmentIndex > 0 ? "\n" : "")).length;
+ const index = occurrences(normalize(segment.text), quote).indexOf(target - offset);
+ if (index !== -1) return { path: segment.path, index };
+ }
+ return null;
+}
diff --git a/apps/mobile/src/lib/citationHighlight.test.ts b/apps/mobile/src/lib/citationHighlight.test.ts
new file mode 100644
index 0000000000000..9f18c45f48bc8
--- /dev/null
+++ b/apps/mobile/src/lib/citationHighlight.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vite-plus/test";
+import { highlightQuoteInMarkdown } from "./citationHighlight";
+
+describe("highlightQuoteInMarkdown", () => {
+ it.each([
+ [
+ "plain text",
+ "Glad the second install worked.",
+ "Glad the second",
+ "==Glad the second== install worked.",
+ ],
+ ["first of repeated text", "go now, then go now", "go now", "==go now==, then go now"],
+ ["text wrapped onto the next line", "one two\nthree", "two three", "one ==two\nthree=="],
+ ["regex characters", "Run (a+b)*c today", "(a+b)*c", "Run ==(a+b)*c== today"],
+ [
+ "text inside bold",
+ "create **spring tides** now",
+ "spring",
+ "create **==spring== tides** now",
+ ],
+ [
+ "text running into bold",
+ "These changes create **spring tides and neap tides**.",
+ "create spring tides",
+ "These changes ==create== **==spring tides== and neap tides**.",
+ ],
+ ["text across bold", "the **bold** word", "the bold word", "==the== **==bold==** ==word=="],
+ ["text across italics", "an _odd_ one", "an odd one", "==an== _==odd==_ ==one=="],
+ ["snake_case as text", "set my_var now", "my_var now", "set ==my_var now=="],
+ ])("highlights %s", (_name, markdown, quote, expected) => {
+ expect(highlightQuoteInMarkdown(markdown, quote)).toBe(expected);
+ });
+
+ it.each([
+ ["the second of repeated text", "go now, then go now", "go now", 1, "go now, then ==go now=="],
+ [
+ "prose after the same text in code",
+ "`go now` then go now",
+ "go now",
+ 1,
+ "`go now` then ==go now==",
+ ],
+ ["a match in code", "`go now` then go now", "go now", 0, null],
+ ["a match past the last", "go now", "go now", 1, null],
+ ])("picks %s", (_name, markdown, quote, occurrence, expected) => {
+ expect(highlightQuoteInMarkdown(markdown, quote, occurrence)).toBe(expected);
+ });
+
+ it.each([
+ ["an empty quote", "text", " "],
+ ["text across a paragraph break", "one two\n\nthree", "two three"],
+ ["text only inside inline code", "run `vp test` now", "vp test"],
+ ["text only inside a code fence", "```sh\nvp test\n```", "vp test"],
+ ["missing text", "nothing here", "absent"],
+ ])("leaves %s unhighlighted", (_name, markdown, quote) => {
+ expect(highlightQuoteInMarkdown(markdown, quote)).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/lib/citationHighlight.ts b/apps/mobile/src/lib/citationHighlight.ts
new file mode 100644
index 0000000000000..0dbda7ec60c91
--- /dev/null
+++ b/apps/mobile/src/lib/citationHighlight.ts
@@ -0,0 +1,75 @@
+const CODE_RANGES = /^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[^\n]*$|`[^`\n]+`/gm;
+const WORD_CHAR = /[\p{L}\p{N}]/u;
+
+function escapeRegExp(text: string) {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+// `*` and `~` always mark emphasis; `_` only at a word edge, so snake_case stays text.
+function isEmphasisMarker(markdown: string, index: number) {
+ const char = markdown[index];
+ if (char === "*" || char === "~") return true;
+ if (char !== "_") return false;
+ const before = markdown[index - 1] ?? "";
+ const after = markdown[index + 1] ?? "";
+ return !(WORD_CHAR.test(before) && WORD_CHAR.test(after));
+}
+
+/**
+ * Wraps match `occurrence` of `quote` in `==…==` for the native renderer. Matching skips emphasis
+ * markers and wraps each plain run on its own to keep the Markdown valid. Matches in code still
+ * count toward `occurrence`, like rendered text, but are not highlighted.
+ */
+export function highlightQuoteInMarkdown(
+ markdown: string,
+ quote: string,
+ occurrence = 0,
+): string | null {
+ const words = quote.trim().split(/\s+/).filter(Boolean);
+ if (words.length === 0) return null;
+ const code = [...markdown.matchAll(CODE_RANGES)].map((match) => ({
+ start: match.index,
+ end: match.index + match[0].length,
+ }));
+ // Words may wrap onto the next line, but never across a blank line.
+ const pattern = new RegExp(words.map(escapeRegExp).join("(?:[ \\t]+|[ \\t]*\\n[ \\t]*)"), "g");
+ const outsideCode = (start: number, end: number) =>
+ !code.some((range) => start < range.end && end > range.start);
+ const sourceIndex: number[] = [];
+ let plain = "";
+ for (let index = 0; index < markdown.length; index += 1) {
+ if (isEmphasisMarker(markdown, index)) continue;
+ sourceIndex.push(index);
+ plain += markdown[index];
+ }
+ const match = [...plain.matchAll(pattern)][occurrence];
+ if (!match) {
+ // A quote with a literal `*` or `_` only matches the raw source.
+ const raw = occurrence === 0 ? [...markdown.matchAll(pattern)][0] : undefined;
+ if (!raw) return null;
+ const end = raw.index + raw[0].length;
+ if (!outsideCode(raw.index, end)) return null;
+ return `${markdown.slice(0, raw.index)}==${raw[0]}==${markdown.slice(end)}`;
+ }
+ const start = sourceIndex[match.index]!;
+ const end = sourceIndex[match.index + match[0].length - 1]! + 1;
+ if (!outsideCode(start, end)) return null;
+ let output = markdown.slice(0, start);
+ let run = "";
+ const flush = () => {
+ const text = run.trim();
+ if (text) {
+ const leading = run.slice(0, run.indexOf(text));
+ output += `${leading}==${text}==${run.slice(leading.length + text.length)}`;
+ } else output += run;
+ run = "";
+ };
+ for (let index = start; index < end; index += 1) {
+ if (isEmphasisMarker(markdown, index)) {
+ flush();
+ output += markdown[index];
+ } else run += markdown[index];
+ }
+ flush();
+ return output + markdown.slice(end);
+}
diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts
index f61aa5c787071..6f2113dc564c5 100644
--- a/apps/mobile/src/lib/composerChipPresentation.ts
+++ b/apps/mobile/src/lib/composerChipPresentation.ts
@@ -34,6 +34,7 @@ const CONTEXT_CHIP_PRESENTATIONS = {
icon: "mouse-pointer-click",
},
"review-comment": { accent: "#8a70dd", icon: "message-circle" },
+ citation: { accent: "#8a70dd", icon: "message-circle" },
"pull-request": {
accent: "#7079e4",
icon: "git-pull-request-arrow",
diff --git a/apps/mobile/src/lib/composerContext.test.ts b/apps/mobile/src/lib/composerContext.test.ts
index 2e1ee64831d08..657970c28a999 100644
--- a/apps/mobile/src/lib/composerContext.test.ts
+++ b/apps/mobile/src/lib/composerContext.test.ts
@@ -1,9 +1,14 @@
import {
+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
ComposerContextId,
+ EnvironmentId,
+ MessageId,
+ ThreadId,
type OrchestrationMessageContext,
type ServerProviderSkill,
} from "@t3tools/contracts";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
+import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
import {
ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
collectComposerContextReferences,
@@ -12,6 +17,7 @@ import {
} from "@t3tools/shared/composerContextReferences";
import { describe, expect, it } from "vite-plus/test";
import {
+ assistantCitationDraftWithinLimit,
collectComposerModelTokens,
composerContextEditorTokens,
composerSkillLabels,
@@ -201,9 +207,45 @@ describe("mobile composer context", () => {
expect(collectComposerModelTokens("model:codex/gpt-6.1", known, kept)).toEqual([]);
});
+ it("rejects citation drafts exceeding either encoded or expanded input limits", () => {
+ const quote = (text: string) =>
+ serializeAssistantCitation({
+ version: 1,
+ environmentId: EnvironmentId.make("remote"),
+ threadId: ThreadId.make("thread"),
+ messageId: MessageId.make("message"),
+ text,
+ start: 0,
+ end: text.length,
+ prefix: "",
+ suffix: "",
+ });
+ expect(assistantCitationDraftWithinLimit(`Explain ${quote("short quote")}`)).toBe(true);
+ expect(
+ assistantCitationDraftWithinLimit(
+ `${quote("日".repeat(8_000))} ${quote("語".repeat(8_000))}`,
+ ),
+ ).toBe(false);
+ const short = quote("x");
+ const expandedOverflow = "a".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS - short.length) + short;
+ expect(expandedOverflow.length).toBe(PROVIDER_SEND_TURN_MAX_INPUT_CHARS);
+ expect(assistantCitationDraftWithinLimit(expandedOverflow)).toBe(false);
+ });
+
it("keeps exact source positions and repeated references alongside existing native tokens", () => {
const reference = formatComposerContextReference(terminal);
- const text = `Use $babysit with $disabled $agentOnly $HOME $PATH $anything $notaskill $20 $20k and [app.ts](src/app.ts) with ${reference} then ${reference}`;
+ const citation = serializeAssistantCitation({
+ version: 1,
+ environmentId: EnvironmentId.make("remote"),
+ threadId: ThreadId.make("thread"),
+ messageId: MessageId.make("message"),
+ text: "日本語 🚀 with @file.ts and $HOME",
+ start: 0,
+ end: 33,
+ prefix: "",
+ suffix: "",
+ });
+ const text = `Use $babysit with $disabled $agentOnly $HOME $PATH $anything $notaskill $20 $20k and [app.ts](src/app.ts) with ${reference} then ${reference} (${citation}),${citation}!`;
const babysit: ServerProviderSkill = {
name: "babysit",
path: "/skills/babysit",
@@ -222,6 +264,8 @@ describe("mobile composer context", () => {
"[app.ts](src/app.ts)",
reference,
reference,
+ citation,
+ citation,
]);
for (const token of tokens) expect(text.slice(token.start, token.end)).toBe(token.source);
expect(
@@ -230,7 +274,7 @@ describe("mobile composer context", () => {
collectComposerInlineTokens(text),
composerSkillLabels([]),
).map((token) => token.type),
- ).toEqual(["mention", "context", "context"]);
+ ).toEqual(["mention", "context", "context", "citation", "citation"]);
expect(
composerContextEditorTokens(
text,
diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts
index 53494209362af..403e01da6efd2 100644
--- a/apps/mobile/src/lib/composerContext.ts
+++ b/apps/mobile/src/lib/composerContext.ts
@@ -7,6 +7,12 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli
import { filePreviewKind } from "@t3tools/shared/filePreview";
import { videoMimeType } from "@t3tools/shared/video";
import {
+ assistantCitationLabel,
+ collectAssistantCitations,
+ expandAssistantCitationsForProvider,
+} from "@t3tools/shared/assistantCitations";
+import {
+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
COMPOSER_CONTEXT_MAX_RECORDS,
COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS,
ComposerContextId,
@@ -36,6 +42,13 @@ import {
const isMessageContext = Schema.is(OrchestrationMessageContext);
const decodeMessageContext = Schema.decodeUnknownOption(OrchestrationMessageContext);
+export function assistantCitationDraftWithinLimit(text: string): boolean {
+ return (
+ text.length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS &&
+ expandAssistantCitationsForProvider(text).length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS
+ );
+}
+
export function composerContextSendBlockReason(
context?: OrchestrationMessageContext,
): string | null {
@@ -194,13 +207,15 @@ export function composerContextEditorTokens(
modelTokens: ReadonlyArray = [],
) {
const references = collectComposerContextReferences(text);
+ const citations = collectAssistantCitations(text);
const inlineTokens = tokens.filter(
(token) =>
// `$HOME` and other `$name` text that is not one of the provider's skills stays prose.
(token.type !== "skill" || knownSkills.has(token.value)) &&
- !references.some((ref) => token.start < ref.end && token.end > ref.start),
+ !references.some((ref) => token.start < ref.end && token.end > ref.start) &&
+ !citations.some((ref) => token.start < ref.end && token.end > ref.start),
);
- const taken = [...inlineTokens, ...references, ...composerMathRanges(text)];
+ const taken = [...inlineTokens, ...references, ...citations, ...composerMathRanges(text)];
const models = modelTokens.filter(
(mention) => !taken.some((range) => mention.start < range.end && mention.end > range.start),
);
@@ -216,6 +231,11 @@ export function composerContextEditorTokens(
value: mention.model,
...mention,
})),
+ ...citations.map((ref) => ({
+ type: "citation" as const,
+ value: assistantCitationLabel(ref.citation),
+ ...ref,
+ })),
].sort((a, b) => a.start - b.start);
}
diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts
index 47f00f0e2f1fb..d25b8ffe7d0fb 100644
--- a/apps/mobile/src/lib/filePreview.test.ts
+++ b/apps/mobile/src/lib/filePreview.test.ts
@@ -232,6 +232,8 @@ vi.mock("expo-haptics", () => ({ selectionAsync: async () => {} }));
vi.mock("expo-image", () => ({}));
vi.mock("uniwind", () => ({}));
vi.mock("../components/ui/Dialog", () => ({}));
+vi.mock("../components/ui/Sheet", () => ({ Sheet: () => null }));
+vi.mock("../components/ui/ListActionRow", () => ({ ListActionRow: () => null }));
vi.mock("../components/ui/NativePresentation", () => ({
PresentationSource: ({ children }: { children?: ReactNode }) => children,
}));
diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx
index 86bf8021bdd0f..0e5ae584f5ad4 100644
--- a/apps/mobile/src/native/EnrichedMarkdownText.tsx
+++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx
@@ -32,6 +32,7 @@ import { useRetryingImageSource } from "./useRetryingImageSource";
import * as Dialog from "../components/ui/Dialog";
import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
import type { HighlightScheme } from "../lib/highlights";
+import { highlightQuoteInMarkdown } from "../lib/citationHighlight";
import {
ENRICHED_INLINE_FILE_LINK_REGEX,
enrichedTextLinkRegex,
@@ -353,9 +354,25 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
}, [props.textStyle, props.textAlign, themeAppearance, fontScale]);
// Link variants change as a message streams. The code renderer reads only the base style, so
// it stays the same function and the library keeps its rendered blocks.
+ const highlightedMarkdown = props.highlightText
+ ? highlightQuoteInMarkdown(markdown, props.highlightText, props.highlightOccurrence)
+ : null;
const markdownStyle = useMemo(
- () => ({ ...baseMarkdownStyle, linkVariants }),
- [baseMarkdownStyle, linkVariants],
+ () => ({
+ ...baseMarkdownStyle,
+ linkVariants,
+ highlight: {
+ color: props.textStyle.color,
+ backgroundColor: props.textStyle.highlightColor ?? props.textStyle.selectionColor,
+ },
+ }),
+ [
+ baseMarkdownStyle,
+ linkVariants,
+ props.textStyle.color,
+ props.textStyle.highlightColor,
+ props.textStyle.selectionColor,
+ ],
);
const renderers = {
image: renderImage
@@ -405,7 +422,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
}}
>
props.onCiteText?.(text),
+ },
+ ]
+ : undefined
+ }
selectionClipboard={selectionClipboard}
selectionColor={props.textStyle.selectionColor}
selectionHandleColor={props.textStyle.selectionHandleColor}
@@ -428,6 +456,8 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
hardSoftBreaks: props.preserveSoftBreaks ?? false,
admonitions: true,
preserveWindowsPaths: true,
+ // Only while highlighting, so a literal `==` in a message stays text.
+ highlight: highlightedMarkdown !== null,
}}
enableTaskListItemToggle={props.onTaskListItemPress !== undefined}
onTaskListItemPress={props.onTaskListItemPress}
diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx
index e49405af4dd4f..98bcfee9a23bc 100644
--- a/apps/mobile/src/native/SelectableMarkdownText.tsx
+++ b/apps/mobile/src/native/SelectableMarkdownText.tsx
@@ -1,8 +1,13 @@
import { Platform } from "react-native";
+import { useState } from "react";
+import type { AssistantCitation } from "@t3tools/contracts";
+import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";
+import { AssistantCitationSheet } from "../components/AssistantCitationSheet";
+import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types";
import { MobileEnrichedMarkdownText } from "./EnrichedMarkdownText";
-import { themeColorWithAlpha } from "../lib/mobileTheme";
+import { flattenThemeColor, themeColorWithAlpha } from "../lib/mobileTheme";
import { useUniwindTheme } from "../lib/useUniwindTheme";
export type {
@@ -17,12 +22,63 @@ export type {
} from "./SelectableMarkdownText.types";
export function SelectableMarkdownText(props: SelectableMarkdownTextProps) {
+ const [citation, setCitation] = useState(null);
+ const hasCitations = props.markdown.includes("t3-citation:");
const theme = useUniwindTheme();
const selectionColor = themeColorWithAlpha(theme["--color-focus"], 0.32);
const selectionHandleColor = theme["--color-focus"];
+ // Matches web's citation highlight; the native renderer drops translucent colors.
+ const highlightColor = flattenThemeColor(
+ themeColorWithAlpha(theme["--color-focus"], 0.45),
+ theme["--color-screen"],
+ );
const textStyle =
Platform.OS === "android"
- ? { selectionColor, selectionHandleColor, ...props.textStyle }
- : props.textStyle;
- return ;
+ ? { selectionColor, selectionHandleColor, highlightColor, ...props.textStyle }
+ : { highlightColor, ...props.textStyle };
+ return (
+ <>
+ {
+ const quote = parseAssistantCitationHref(href);
+ if (quote) setCitation(quote);
+ else props.onLinkPress?.(href);
+ }
+ : props.onLinkPress
+ }
+ fileContextMenu={
+ hasCitations
+ ? (href) =>
+ parseAssistantCitationHref(href)
+ ? {
+ title: "Assistant quote",
+ actions: [
+ { id: "open-quote", title: "Open quote" },
+ { id: "copy-quote", title: "Copy quote" },
+ ],
+ }
+ : props.fileContextMenu?.(href)
+ : props.fileContextMenu
+ }
+ onFileContextMenuAction={
+ hasCitations
+ ? (href, actionId) => {
+ const quote = parseAssistantCitationHref(href);
+ if (quote) {
+ if (actionId === "open-quote") setCitation(quote);
+ else if (actionId === "copy-quote") copyTextWithHaptic(quote.text);
+ } else props.onFileContextMenuAction?.(href, actionId);
+ }
+ : props.onFileContextMenuAction
+ }
+ />
+ {citation ? (
+ setCitation(null)} />
+ ) : null}
+ >
+ );
}
diff --git a/apps/mobile/src/native/SelectableMarkdownText.types.ts b/apps/mobile/src/native/SelectableMarkdownText.types.ts
index 63f8429c386fd..69a1a4a3a678e 100644
--- a/apps/mobile/src/native/SelectableMarkdownText.types.ts
+++ b/apps/mobile/src/native/SelectableMarkdownText.types.ts
@@ -1,6 +1,7 @@
export interface NativeMarkdownTextStyle {
readonly selectionColor?: string;
readonly selectionHandleColor?: string;
+ readonly highlightColor?: string;
readonly color: string;
readonly strongColor: string;
readonly mutedColor: string;
@@ -77,6 +78,11 @@ export interface SelectableMarkdownTextProps {
readonly models?: ReadonlyArray;
readonly preserveSoftBreaks?: boolean;
readonly onLinkPress?: (href: string) => void;
+ readonly onCiteText?: (text: string) => void;
+ /** Quoted text to highlight, such as a citation's source after "Go to message". */
+ readonly highlightText?: string | undefined;
+ /** Which match of `highlightText` to highlight, counting from 0. */
+ readonly highlightOccurrence?: number | undefined;
readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined;
readonly onFileContextMenuAction?: (href: string, actionId: string) => void;
readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined;
diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx
index 437bcda4d012d..efd7ba0e9a94f 100644
--- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx
+++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx
@@ -200,7 +200,7 @@ export function ComposerEditor({
token.type === "mention" ? token.value : contextRecordFileName(record),
);
return {
- type: token.type,
+ type: token.type === "citation" ? "context" : token.type,
source: token.source,
start: token.start,
end: token.end,
@@ -216,7 +216,9 @@ export function ComposerEditor({
? model.label
: token.type === "context"
? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " · unavailable"}`
- : basename(token.value),
+ : token.type === "citation"
+ ? token.value
+ : basename(token.value),
detail: token.type === "context" ? composerChipSizeSuffix(record) : "",
iconUri: model ? modelChipIconUri(model) : contextChipIconUri(icon),
tintsIcon: model ? modelChipIcon(model).tintsIcon : icon.kind === "chip",
diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx
index 5aabd8e0d6071..e369e5257d8fc 100644
--- a/apps/mobile/src/native/T3ComposerEditor.native.tsx
+++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx
@@ -200,8 +200,8 @@ export function ComposerEditor({
token.type === "mention" ? token.value : contextRecordFileName(record),
);
return {
- // Android taps only known token types; a model chip opens like a skill chip.
- type: token.type === "model" ? "skill" : token.type,
+ type:
+ token.type === "model" ? "skill" : token.type === "citation" ? "context" : token.type,
source: token.source,
start: token.start,
end: token.end,
@@ -217,7 +217,9 @@ export function ComposerEditor({
? model.label
: token.type === "context"
? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " · unavailable"}`
- : basename(token.value),
+ : token.type === "citation"
+ ? token.value
+ : basename(token.value),
detail: token.type === "context" ? composerChipSizeSuffix(record) : "",
iconUri: model ? modelChipIconUri(model) : contextChipIconUri(icon),
tintsIcon: model ? modelChipIcon(model).tintsIcon : icon.kind === "chip",
diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts
index 2d4cf85cb7db1..f05f66aec6f28 100644
--- a/apps/mobile/src/native/useEnrichedLinkVariants.ts
+++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts
@@ -1,5 +1,9 @@
import { modelChipIcon } from "../lib/contextChipIconSource";
import { useMemo } from "react";
+import {
+ assistantCitationLabel,
+ parseAssistantCitationHref,
+} from "@t3tools/shared/assistantCitations";
import { Image } from "react-native";
import type { LinkPillContent, MarkdownStyle } from "react-native-enriched-markdown";
import type { ComposerContextRecord } from "@t3tools/contracts";
@@ -65,6 +69,13 @@ function presentation(
): MarkdownLinkCustomization | undefined {
const custom = props.linkCustomization?.(url);
if (custom) return custom;
+ const citation = parseAssistantCitationHref(url);
+ if (citation)
+ return {
+ label: assistantCitationLabel(citation),
+ icon: CONTEXT_CHIP_ICON_SOURCES["message-circle"],
+ chip: { accent: contextChipPresentation("citation").accent, tintsIcon: true },
+ };
const context = enrichedContextLinkCustomization(url);
if (context) return context;
const skill = enrichedSkillReference(url, props.skills);
diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts
index 0973be037e39a..7b1c34405e063 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts
@@ -1,6 +1,7 @@
import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences";
import {
MessageId,
+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
type ModelSelection,
type NodeId,
ProviderSessionId,
@@ -401,6 +402,18 @@ export const layer: Layer.Layer<
cause: "The persisted steering message or target run is missing.",
});
}
+ const text = projectComposerContextForProvider({
+ text: message.text,
+ records: message.context?.records ?? [],
+ });
+ if (text.length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) {
+ return yield* new ProviderTurnControlError({
+ threadId: input.threadId,
+ operation: "steer",
+ providerTurnId: input.providerTurnId,
+ cause: `Message exceeds ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS.toLocaleString("en-US")} characters after expanding context and quotes. Shorten it and try again.`,
+ });
+ }
yield* loaded.session.value
.steerTurn({
threadId: input.threadId,
@@ -412,10 +425,7 @@ export const layer: Layer.Layer<
: { modelSelection: input.modelSelection }),
message: {
messageId: message.id,
- text: projectComposerContextForProvider({
- text: message.text,
- records: message.context?.records ?? [],
- }),
+ text,
attachments: message.attachments,
createdBy: message.createdBy,
creationSource: message.creationSource,
diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
index ac2692907cbbc..9960d9ad45084 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
@@ -1,3 +1,7 @@
+import {
+ serializeAssistantCitation,
+ expandAssistantCitationsForProvider,
+} from "@t3tools/shared/assistantCitations";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it, vi } from "vite-plus/test";
@@ -19,6 +23,8 @@ import {
RunId,
ThreadId,
ProjectId,
+ EnvironmentId,
+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
type OrchestrationV2ThreadProjection,
OrchestrationV2DomainEvent,
} from "@t3tools/contracts";
@@ -1936,3 +1942,36 @@ for (const previousMessages of [[], ["/compact", " /COMPACT "]]) {
}),
);
}
+
+effectIt.effect("rejects citation expansion before opening a provider session", () =>
+ Effect.gen(function* () {
+ const marker = serializeAssistantCitation({
+ version: 1,
+ environmentId: EnvironmentId.make("env"),
+ threadId: ThreadId.make("thread"),
+ messageId: MessageId.make("assistant"),
+ text: "\u0001".repeat(8_000),
+ start: 0,
+ end: 8_000,
+ prefix: "",
+ suffix: "",
+ });
+ const text = "x".repeat(80_000) + marker;
+ expect(text.length).toBeLessThan(PROVIDER_SEND_TURN_MAX_INPUT_CHARS);
+ expect(expandAssistantCitationsForProvider(text).length).toBeGreaterThan(
+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
+ );
+ const harness = makeLocalCommandHarness({ text });
+ yield* harness.start;
+ expect(harness.open).not.toHaveBeenCalled();
+ expect(harness.startTurn).not.toHaveBeenCalled();
+ expect(harness.projection().runs.at(-1)?.status).toBe("failed");
+ expect(harness.projection().turnItems).toMatchObject([
+ {
+ type: "error",
+ failure: { class: "validation_error", message: expect.stringContaining("120,000") },
+ },
+ ]);
+ expect(harness.projection().messages[0]?.text).toBe(text);
+ }),
+);
diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
index 2de9fcbc70d9d..ed452aee07f30 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
@@ -2,6 +2,7 @@ import { modelSelectionsEqual } from "@t3tools/shared/model";
import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences";
import {
CommandId,
+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
latestProviderTurnForAttempt,
type OrchestrationV2DomainEvent,
type OrchestrationV2ContextHandoff,
@@ -565,6 +566,28 @@ export const layer: Layer.Layer<
});
},
);
+ const userText = projectComposerContextForProvider({
+ text: message.text,
+ records: message.context?.records ?? [],
+ });
+ if (userText.length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) {
+ yield* settleRunBeforeStart({
+ signal: "expanded-input-too-long",
+ status: "failed",
+ now: yield* DateTime.now,
+ providerInstanceId: run.providerInstanceId,
+ itemProviderThreadId: providerThread.id,
+ item: {
+ type: "error",
+ title: "Message is too long",
+ failure: makeProviderFailure({
+ class: "validation_error",
+ message: `Message exceeds ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS.toLocaleString("en-US")} characters after expanding context and quotes. Shorten it and try again.`,
+ }),
+ },
+ });
+ return;
+ }
if (message.attachments.length === 0 && message.text.trimStart().startsWith("/")) {
const isEmptyCompaction =
message.text.trim().toLowerCase() === "/compact" && !projection.hasConversation;
@@ -597,10 +620,7 @@ export const layer: Layer.Layer<
: yield* Effect.result(
providerAuth.tryHandlePromptCommand({
instanceId: authInstanceId,
- text: projectComposerContextForProvider({
- text: message.text,
- records: message.context?.records ?? [],
- }),
+ text: userText,
hasAttachments: false,
}),
);
@@ -865,10 +885,6 @@ export const layer: Layer.Layer<
});
let effectiveHandoffs = handoffs;
let resumeFellBack = false;
- const userText = projectComposerContextForProvider({
- text: message.text,
- records: message.context?.records ?? [],
- });
// Native goal commands go verbatim; handoff history waits a turn.
const goalDriver =
session.driver === "codex" ||
diff --git a/apps/web/src/lib/assistantTextSelection.test.ts b/apps/web/src/lib/assistantTextSelection.test.ts
index d06261cb856aa..a25972be7700a 100644
--- a/apps/web/src/lib/assistantTextSelection.test.ts
+++ b/apps/web/src/lib/assistantTextSelection.test.ts
@@ -507,3 +507,20 @@ describe("findAssistantCitationText", () => {
});
});
});
+
+it("resolves a repeated native quote only while its occurrence count is unchanged", () => {
+ const repeated = "x".repeat(40) + "quote" + "x".repeat(40);
+ const text = `${repeated} / ${repeated}`;
+ const second = text.lastIndexOf("quote");
+ const citation = {
+ ...createAssistantTextSelector(text, second, second + 5)!,
+ occurrence: { index: 1, count: 2 },
+ };
+ expect(findAssistantCitationText(text, citation)).toEqual({ start: second, end: second + 5 });
+ expect(findAssistantCitationText(`${text} / ${repeated}`, citation)).toBeNull();
+ const unique = {
+ ...createAssistantTextSelector("old quote", 4, 9)!,
+ occurrence: { index: 0, count: 1 },
+ };
+ expect(findAssistantCitationText("new quote", unique)).toEqual({ start: 4, end: 9 });
+});
diff --git a/apps/web/src/lib/assistantTextSelection.ts b/apps/web/src/lib/assistantTextSelection.ts
index 9f83cd829eed0..b81d3de6135b5 100644
--- a/apps/web/src/lib/assistantTextSelection.ts
+++ b/apps/web/src/lib/assistantTextSelection.ts
@@ -1,14 +1,18 @@
-import { ASSISTANT_CITATION_CONTEXT_LENGTH, type AssistantCitation } from "@t3tools/contracts";
+import { type AssistantCitation } from "@t3tools/contracts";
import { mathWrapperOf } from "../markdown-clipboard";
-export type AssistantTextSelector = {
- readonly text: string;
- readonly start: number;
- readonly end: number;
- readonly prefix: string;
- readonly suffix: string;
-};
+export {
+ createAssistantTextSelector,
+ findAssistantCitationText,
+} from "@t3tools/shared/assistantCitations";
+import {
+ createAssistantTextSelector,
+ findAssistantCitationText,
+ splitsSurrogatePair,
+ type AssistantTextSelector,
+} from "@t3tools/shared/assistantCitations";
+export type { AssistantTextSelector } from "@t3tools/shared/assistantCitations";
export type AssistantCitationSourceAnchor = {
source: HTMLElement;
@@ -39,87 +43,6 @@ const EXCLUDED_SELECTOR = `${CONTROL_SELECTOR}, [hidden], [aria-hidden=true], sc
const BLOCK_SELECTOR =
"address, article, aside, blockquote, dd, div, dl, dt, figcaption, figure, footer, h1, h2, h3, h4, h5, h6, header, hr, li, main, nav, ol, p, pre, section, table, td, th, tr, ul";
-function normalizeWhitespace(text: string): string {
- return text.replace(/\s+/g, " ");
-}
-
-function splitsSurrogatePair(text: string, offset: number): boolean {
- const before = text.charCodeAt(offset - 1);
- const after = text.charCodeAt(offset);
- return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
-}
-
-export function createAssistantTextSelector(
- text: string,
- rawStart: number,
- rawEnd: number,
-): AssistantTextSelector | null {
- const quote = text.slice(rawStart, rawEnd);
- if (quote.trim().length === 0) return null;
-
- const normalized = normalizeWhitespace(text);
- let start = normalizeWhitespace(text.slice(0, rawStart)).length;
- if (rawStart > 0 && /\s/.test(text[rawStart - 1]!) && /\s/.test(text[rawStart]!)) {
- start -= 1;
- }
- const end = normalizeWhitespace(text.slice(0, rawEnd)).length;
- let prefixStart = Math.max(0, start - ASSISTANT_CITATION_CONTEXT_LENGTH);
- let suffixEnd = Math.min(normalized.length, end + ASSISTANT_CITATION_CONTEXT_LENGTH);
- // A split pair becomes a replacement character when the context enters a URL.
- if (splitsSurrogatePair(normalized, prefixStart)) prefixStart += 1;
- if (splitsSurrogatePair(normalized, suffixEnd)) suffixEnd -= 1;
- return {
- text: quote,
- start,
- end,
- prefix: normalized.slice(prefixStart, start),
- suffix: normalized.slice(end, suffixEnd),
- };
-}
-
-/** Offsets use normalized UTF-16 units. Saved offsets may drift, so they cannot break context ties. */
-export function findAssistantCitationText(
- text: string,
- selector: AssistantTextSelector,
-): { start: number; end: number } | null {
- const normalized = normalizeWhitespace(text);
- const quote = normalizeWhitespace(selector.text);
- if (quote.trim().length === 0) return null;
-
- const prefix = normalizeWhitespace(selector.prefix);
- const suffix = normalizeWhitespace(selector.suffix);
- const matchesContext = (start: number, end: number) =>
- normalized.slice(Math.max(0, start - prefix.length), start) === prefix &&
- normalized.slice(end, end + suffix.length) === suffix;
-
- let match =
- Number.isSafeInteger(selector.start) &&
- Number.isSafeInteger(selector.end) &&
- selector.start >= 0 &&
- selector.end - selector.start === quote.length &&
- normalized.slice(selector.start, selector.end) === quote &&
- matchesContext(selector.start, selector.end)
- ? { start: selector.start, end: selector.end }
- : null;
- let onlyQuote: { start: number; end: number } | null = null;
- let quoteCount = 0;
-
- for (
- let start = normalized.indexOf(quote);
- start !== -1;
- start = normalized.indexOf(quote, start + 1)
- ) {
- const end = start + quote.length;
- quoteCount += 1;
- onlyQuote = { start, end };
- if (!matchesContext(start, end)) continue;
- if (match !== null && match.start !== start) return null;
- match = { start, end };
- }
-
- return match ?? (quoteCount === 1 ? onlyQuote : null);
-}
-
/** A text node, or a whole rendered formula standing for its TeX. */
type TextChunk = { node: Text | Element; start: number; end: number };
diff --git a/docs/internals/assistant-citations.md b/docs/internals/assistant-citations.md
index ec830f75bc031..7b440875a50c2 100644
--- a/docs/internals/assistant-citations.md
+++ b/docs/internals/assistant-citations.md
@@ -11,8 +11,10 @@ Source navigation is best effort. [Text selectors](../../apps/web/src/lib/assist
refer to rendered text after whitespace normalization, measured in UTF-16 units.
They cannot be applied to raw Markdown. The original excerpt keeps its whitespace;
normalization is only for locating it. Repeated text needs an unambiguous context
-match, even if one occurrence still sits at the saved offsets. Guessing could
-highlight the wrong claim.
+match, even if one occurrence still sits at the saved offsets. Mobile records an
+explicit occurrence choice when the native renderer cannot identify its block;
+that choice resolves only while the rendered occurrence count and context still
+match. Guessing could highlight the wrong claim.
Navigation owns the highlight's lifetime across virtual-row remounts. The source
row must be fetched, unfolded, mounted, and measured before quote positioning
diff --git a/docs/user/composer.md b/docs/user/composer.md
index df880e49c6ca5..db1975450bdc1 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -142,8 +142,12 @@ delete it like other inline context. Copying, reloading, and restoring a
with its quote, and sending tells the agent which words were quoted and which comment you wrote.
The quoted text and comment count toward the message limit.
-Mobile displays saved quotes and comments, but does not create citations or
-navigate to their sources.
+On mobile, long-press an assistant response, adjust the text selection, and choose
+**Cite** to insert a quote at the composer cursor. If the passage appears more than
+once in that section, choose its source occurrence. Select more surrounding text
+if a short passage appears too often. Tap a quote in a draft to add
+or edit its comment, or to remove it. To open the source message, tap a quote in
+a draft or sent message, then choose **Go to message**.
## Recall a sent prompt
diff --git a/packages/contracts/src/assistantCitations.ts b/packages/contracts/src/assistantCitations.ts
index 96572a4bfa656..ee16537fc5b71 100644
--- a/packages/contracts/src/assistantCitations.ts
+++ b/packages/contracts/src/assistantCitations.ts
@@ -22,6 +22,15 @@ export const AssistantCitation = Schema.Struct({
),
start: NonNegativeInt.check(Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER)),
end: NonNegativeInt.check(Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER)),
+ occurrence: Schema.optional(
+ Schema.Struct({
+ index: NonNegativeInt.check(Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER)),
+ count: NonNegativeInt.check(
+ Schema.isGreaterThan(0),
+ Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER),
+ ),
+ }).check(Schema.makeFilter((value) => value.index < value.count)),
+ ),
prefix: Schema.String.check(Schema.isMaxLength(ASSISTANT_CITATION_CONTEXT_LENGTH)),
suffix: Schema.String.check(Schema.isMaxLength(ASSISTANT_CITATION_CONTEXT_LENGTH)),
}).check(
diff --git a/packages/shared/src/assistantCitations.test.ts b/packages/shared/src/assistantCitations.test.ts
index 7dbaaf9d8bdeb..a698e482ee4f1 100644
--- a/packages/shared/src/assistantCitations.test.ts
+++ b/packages/shared/src/assistantCitations.test.ts
@@ -95,6 +95,10 @@ describe("assistant citation references", () => {
"t3-citation://v1/a/b/c?text=quote&start=5&end=0&prefix=&suffix=",
"t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&text=other",
"t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&unknown=value",
+ "t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&occurrence=0",
+ "t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&occurrence=1&occurrences=1",
+ "t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&occurrence=0&occurrences=0",
+ "t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&occurrence=0&occurrences=9007199254740992",
"t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&comment=one&comment=two",
])("leaves invalid or unsupported references unchanged: %s", (href) => {
expect(parseAssistantCitationHref(href)).toBeNull();
@@ -282,3 +286,15 @@ describe("assistant citation references", () => {
);
});
});
+
+it("retains distinct repeated occurrences through serialization and provider expansion", () => {
+ const first = { ...citation, occurrence: { index: 0, count: 2 } };
+ const second = { ...citation, occurrence: { index: 1, count: 2 } };
+ expect(parseAssistantCitationHref(formatAssistantCitationHref(second))).toEqual(second);
+ const expanded = readProviderContext(
+ expandAssistantCitationsForProvider(
+ `${serializeAssistantCitation(first)} ${serializeAssistantCitation(second)}`,
+ ),
+ );
+ expect(expanded).toHaveLength(2);
+});
diff --git a/packages/shared/src/assistantCitations.ts b/packages/shared/src/assistantCitations.ts
index bb072e462a45e..d776af320a059 100644
--- a/packages/shared/src/assistantCitations.ts
+++ b/packages/shared/src/assistantCitations.ts
@@ -51,6 +51,10 @@ export function formatAssistantCitationHref(citation: AssistantCitation): string
prefix: citation.prefix,
suffix: citation.suffix,
});
+ if (citation.occurrence !== undefined) {
+ query.set("occurrence", String(citation.occurrence.index));
+ query.set("occurrences", String(citation.occurrence.count));
+ }
if (citation.comment !== undefined) query.set("comment", citation.comment);
return `${CITATION_HREF_PREFIX}${path}?${query}`;
}
@@ -75,8 +79,14 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu
}
const requiredKeys = ["text", "start", "end", "prefix", "suffix"];
const comment = url.searchParams.get("comment");
+ const occurrence = url.searchParams.get("occurrence");
+ const occurrences = url.searchParams.get("occurrences");
+ if ((occurrence === null) !== (occurrences === null)) return null;
+ if (occurrence !== null && (!/^\d{1,16}$/.test(occurrence) || !/^\d{1,16}$/.test(occurrences!)))
+ return null;
if (
- url.searchParams.size !== requiredKeys.length + (comment === null ? 0 : 1) ||
+ url.searchParams.size !==
+ requiredKeys.length + (comment === null ? 0 : 1) + (occurrence === null ? 0 : 2) ||
requiredKeys.some((key) => url.searchParams.getAll(key).length !== 1)
) {
return null;
@@ -96,6 +106,9 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu
prefix: url.searchParams.get("prefix"),
suffix: url.searchParams.get("suffix"),
...(comment === null ? {} : { comment }),
+ ...(occurrence === null
+ ? {}
+ : { occurrence: { index: Number(occurrence), count: Number(occurrences) } }),
}),
);
} catch {
@@ -198,3 +211,100 @@ export function renderAssistantCitationsAsText(prompt: string): string {
}
return text + prompt.slice(cursor);
}
+
+export type AssistantTextSelector = {
+ readonly text: string;
+ readonly start: number;
+ readonly end: number;
+ readonly prefix: string;
+ readonly suffix: string;
+ readonly occurrence?: AssistantCitation["occurrence"];
+};
+
+function normalizeWhitespace(text: string): string {
+ return text.replace(/\s+/g, " ");
+}
+
+export function splitsSurrogatePair(text: string, offset: number): boolean {
+ const before = text.charCodeAt(offset - 1);
+ const after = text.charCodeAt(offset);
+ return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
+}
+
+export function createAssistantTextSelector(
+ text: string,
+ rawStart: number,
+ rawEnd: number,
+): AssistantTextSelector | null {
+ const quote = text.slice(rawStart, rawEnd);
+ if (quote.trim().length === 0) return null;
+
+ const normalized = normalizeWhitespace(text);
+ let start = normalizeWhitespace(text.slice(0, rawStart)).length;
+ if (rawStart > 0 && /\s/.test(text[rawStart - 1]!) && /\s/.test(text[rawStart]!)) {
+ start -= 1;
+ }
+ const end = normalizeWhitespace(text.slice(0, rawEnd)).length;
+ let prefixStart = Math.max(0, start - ASSISTANT_CITATION_CONTEXT_LENGTH);
+ let suffixEnd = Math.min(normalized.length, end + ASSISTANT_CITATION_CONTEXT_LENGTH);
+ // A split pair becomes a replacement character when the context enters a URL.
+ if (splitsSurrogatePair(normalized, prefixStart)) prefixStart += 1;
+ if (splitsSurrogatePair(normalized, suffixEnd)) suffixEnd -= 1;
+ return {
+ text: quote,
+ start,
+ end,
+ prefix: normalized.slice(prefixStart, start),
+ suffix: normalized.slice(end, suffixEnd),
+ };
+}
+
+/** Offsets use normalized UTF-16 units. Saved offsets may drift, so they cannot break context ties. */
+export function findAssistantCitationText(
+ text: string,
+ selector: AssistantTextSelector,
+): { start: number; end: number } | null {
+ const normalized = normalizeWhitespace(text);
+ const quote = normalizeWhitespace(selector.text);
+ if (quote.trim().length === 0) return null;
+
+ const prefix = normalizeWhitespace(selector.prefix);
+ const suffix = normalizeWhitespace(selector.suffix);
+ const matchesContext = (start: number, end: number) =>
+ normalized.slice(Math.max(0, start - prefix.length), start) === prefix &&
+ normalized.slice(end, end + suffix.length) === suffix;
+
+ let match =
+ Number.isSafeInteger(selector.start) &&
+ Number.isSafeInteger(selector.end) &&
+ selector.start >= 0 &&
+ selector.end - selector.start === quote.length &&
+ normalized.slice(selector.start, selector.end) === quote &&
+ matchesContext(selector.start, selector.end)
+ ? { start: selector.start, end: selector.end }
+ : null;
+ let onlyQuote: { start: number; end: number } | null = null;
+ let quoteCount = 0;
+ let occurrenceMatch: { start: number; end: number } | null = null;
+
+ for (
+ let start = normalized.indexOf(quote);
+ start !== -1;
+ start = normalized.indexOf(quote, start + 1)
+ ) {
+ const end = start + quote.length;
+ if (selector.occurrence?.index === quoteCount) occurrenceMatch = { start, end };
+ quoteCount += 1;
+ onlyQuote = { start, end };
+ if (!matchesContext(start, end)) continue;
+ if (match !== null && match.start !== start && selector.occurrence === undefined) return null;
+ match = { start, end };
+ }
+
+ if (selector.occurrence !== undefined) {
+ if (quoteCount !== selector.occurrence.count || occurrenceMatch === null) return null;
+ if (quoteCount === 1) return occurrenceMatch;
+ return matchesContext(occurrenceMatch.start, occurrenceMatch.end) ? occurrenceMatch : null;
+ }
+ return match ?? (quoteCount === 1 ? onlyQuote : null);
+}