diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
index ca2426f8c023f..85c18eeec6f34 100644
--- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
+++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
@@ -110,7 +110,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
val offset = editor.getOffsetForPosition(event.x, event.y)
val token =
tokens.firstOrNull {
- (it.type == "context" || it.type == "mention" || it.type == "skill") &&
+ (it.type == "context" ||
+ it.type == "mention" ||
+ it.type == "skill" ||
+ it.type == "citation") &&
offset >= it.start &&
offset < it.end
}
diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt
index d4f8312b72b3b..85980784a7fd5 100644
--- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt
+++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt
@@ -160,6 +160,25 @@ class T3ContextChip(
line(7f, 8f, 17f, 8f)
line(7f, 12f, 14f, 12f)
}
+ // lucide `quote`, the glyph web draws on a citation: two closing quote marks.
+ "quote.closing" -> {
+ for (x in floatArrayOf(0f, 11f)) {
+ moveTo(x + 5f, 3f)
+ quadTo(x + 3f, 3f, x + 3f, 5f)
+ lineTo(x + 3f, 11f)
+ quadTo(x + 3f, 13f, x + 5f, 13f)
+ quadTo(x + 6f, 13f, x + 6f, 14f)
+ lineTo(x + 6f, 15f)
+ quadTo(x + 6f, 17f, x + 4f, 17f)
+ quadTo(x + 3f, 17f, x + 3f, 18f)
+ lineTo(x + 3f, 20f)
+ quadTo(x + 3f, 21f, x + 4f, 21f)
+ quadTo(x + 10f, 21f, x + 10f, 15f)
+ lineTo(x + 10f, 5f)
+ quadTo(x + 10f, 3f, x + 8f, 3f)
+ close()
+ }
+ }
"terminal", "play.rectangle", "photo" -> {
addRoundRect(2f, 4f, 22f, 20f, 2f, 2f, Path.Direction.CW)
when (symbol) {
diff --git a/apps/mobile/src/components/AssistantCitationSheet.tsx b/apps/mobile/src/components/AssistantCitationSheet.tsx
new file mode 100644
index 0000000000000..b94455c438851
--- /dev/null
+++ b/apps/mobile/src/components/AssistantCitationSheet.tsx
@@ -0,0 +1,81 @@
+import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH, type AssistantCitation } from "@t3tools/contracts";
+import { useRef, useState } from "react";
+import { Platform, Pressable, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AppSheet } from "./AppSheet";
+import { AppText as Text, AppTextInput as TextInput } from "./AppText";
+
+/** A quoted assistant reply in the draft. Closing the sheet keeps the comment as typed. */
+export function AssistantCitationSheet(props: {
+ readonly citation: AssistantCitation;
+ /** Restores a comment the draft could not take, so it can be shortened. */
+ readonly initialComment?: string | undefined;
+ /** Focuses the comment field, for a quote that was just cited. */
+ readonly autoFocus?: boolean;
+ readonly onClose: (comment: string) => void;
+ /** Omitted when the draft is read-only. */
+ readonly onRemove?: () => void;
+}) {
+ const insets = useSafeAreaInsets();
+ const [comment, setComment] = useState(props.initialComment ?? props.citation.comment ?? "");
+ const removedRef = useRef(false);
+ return (
+ {
+ if (!removedRef.current) props.onClose(comment);
+ }}
+ >
+
+ {props.onRemove ? (
+
+ ) : comment ? (
+
+ Comment
+
+ {comment}
+
+
+ ) : null}
+
+
+ {props.citation.text}
+
+
+ {props.onRemove ? (
+ {
+ removedRef.current = true;
+ props.onRemove?.();
+ }}
+ className="rounded-xl bg-subtle p-4"
+ >
+ Remove from draft
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx
index a4e2610dffc05..d976c6fbf2f72 100644
--- a/apps/mobile/src/components/ComposerEditor.tsx
+++ b/apps/mobile/src/components/ComposerEditor.tsx
@@ -1,14 +1,22 @@
-import { ComposerContextId } from "@t3tools/contracts";
+import { ComposerContextId, PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/reactivity";
import { useNavigation } from "@react-navigation/native";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } from "react";
import { Alert } from "react-native";
import type { EnvironmentId } from "@t3tools/contracts";
+import {
+ collectAssistantCitations,
+ serializeAssistantCitation,
+ withAssistantCitationComment,
+} from "@t3tools/shared/assistantCitations";
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import { ComposerEditor as NativeComposerEditor } from "../native/T3ComposerEditor";
-import type { ComposerEditorProps as NativeComposerEditorProps } from "../native/T3ComposerEditor";
+import type {
+ ComposerEditorHandle as NativeComposerEditorHandle,
+ ComposerEditorProps as NativeComposerEditorProps,
+} from "../native/T3ComposerEditor";
import {
appendComposerDraftAttachments,
createComposerDraftContextHistory,
@@ -23,6 +31,7 @@ import {
} from "../state/use-composer-drafts";
import { importComposerContextClipboard } from "../lib/composerContextClipboard";
import { mobilePreferencesAtom } from "../state/preferences";
+import { AssistantCitationSheet } from "./AssistantCitationSheet";
import { ComposerContextSheet } from "./ComposerContextSheet";
import { AppText as Text } from "./AppText";
import {
@@ -31,7 +40,18 @@ import {
type ComposerDocumentAttachment,
} from "../lib/composerContext";
-export type ComposerEditorProps = NativeComposerEditorProps & {
+type ComposerChip = { source: string; start: number; end: number };
+
+export interface ComposerEditorHandle extends NativeComposerEditorHandle {
+ /**
+ * Opens a just-inserted quote's sheet with its comment field focused. Returns false when the
+ * editor has moved on to another draft since the quote went into `draftKey`.
+ */
+ openNewCitation: (draftKey: string, chip: ComposerChip) => boolean;
+}
+
+export type ComposerEditorProps = Omit & {
+ readonly ref?: Ref;
readonly draftKey?: string | null;
readonly environmentId?: EnvironmentId;
readonly onOpenMention?: (path: string) => void;
@@ -48,6 +68,7 @@ export type ComposerEditorProps = NativeComposerEditorProps & {
};
export function ComposerEditor({
+ ref,
draftKey,
environmentId,
onOpenMention,
@@ -74,8 +95,26 @@ export function ComposerEditor({
appendComposerDraftAttachments(draftKey, restored.attachments, { allowOverflow: true });
}
};
- const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>(
- null,
+ const currentDraftKey = draftKey ?? null;
+ const [selected, setSelected] = useState<
+ (ComposerChip & { draftKey: string | null; comment?: string; isNewCitation?: boolean }) | null
+ >(null);
+ // A chip's sheet belongs to the draft it was opened from.
+ if (selected && selected.draftKey !== currentDraftKey) setSelected(null);
+ const nativeRef = useRef(null);
+ useImperativeHandle(
+ ref,
+ () => ({
+ focus: () => nativeRef.current?.focus(),
+ blur: () => nativeRef.current?.blur(),
+ setSelection: (selection) => nativeRef.current?.setSelection(selection),
+ openNewCitation: (chipDraftKey, chip) => {
+ if (chipDraftKey !== currentDraftKey) return false;
+ setSelected({ ...chip, draftKey: chipDraftKey, isNewCitation: true });
+ return true;
+ },
+ }),
+ [currentDraftKey],
);
const importRef = useRef(null);
const [importing, setImporting] = useState(false);
@@ -164,10 +203,25 @@ export function ComposerEditor({
const record = draft.context?.records.find(
(entry) => entry.contextId === selectedReference?.contextId,
);
+ const selectedCitation = selected
+ ? collectAssistantCitations(selected.source).find((entry) => entry.source === selected.source)
+ ?.citation
+ : undefined;
+ const editable = !props.readOnly && props.editable !== false;
+ // Edits apply only while the chip still sits where it was opened.
+ const replaceSelected = (replacement: string) => {
+ if (!selected || props.value.slice(selected.start, selected.end) !== selected.source) return;
+ changeText(
+ props.value.slice(0, selected.start) + replacement + props.value.slice(selected.end),
+ );
+ const caret = selected.start + replacement.length;
+ props.onSelectionChange?.({ start: caret, end: caret });
+ };
return (
<>
{
if (draftKey)
@@ -205,6 +259,42 @@ export function ComposerEditor({
{importing ? (
Copying contextβ¦
) : null}
+ {selected && selectedCitation ? (
+ {
+ setSelected(null);
+ // After citing, go back to writing around the quote.
+ if (selected.isNewCitation) requestAnimationFrame(() => nativeRef.current?.focus());
+ if (!editable || comment.trim() === (selectedCitation.comment ?? "")) return;
+ const source = serializeAssistantCitation(
+ withAssistantCitationComment(selectedCitation, comment),
+ );
+ if (
+ props.value.length - selected.source.length + source.length >
+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS
+ ) {
+ const chip = selected;
+ Alert.alert("The draft is too long", "Shorten the draft or the comment.", [
+ { text: "OK", onPress: () => setSelected({ ...chip, comment }) },
+ ]);
+ return;
+ }
+ replaceSelected(source);
+ }}
+ {...(editable
+ ? {
+ onRemove: () => {
+ replaceSelected("");
+ setSelected(null);
+ },
+ }
+ : {})}
+ />
+ ) : 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 });
- }
+ editable
+ ? () => {
+ replaceSelected("");
setSelected(null);
}
+ : undefined
}
/>
) : null}
>
);
}
-export type {
- ComposerEditorHandle,
- ComposerEditorSelection,
- ComposerTextPaste,
-} from "../native/T3ComposerEditor";
+export type { ComposerEditorSelection, ComposerTextPaste } from "../native/T3ComposerEditor";
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index e4f489115f3ad..6ee64381a2b86 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -47,6 +47,7 @@ import { isProviderNativeSubagentThread } from "@t3tools/contracts";
import type { QueuedRunEdit } from "../../state/queued-run-edit";
import type { FollowUpBehavior } from "../../lib/followUpBehavior";
import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch";
+import { voiceInputBlocksSubmission } from "@t3tools/client-runtime/voice-input";
import * as Haptics from "expo-haptics";
import {
memo,
@@ -142,7 +143,20 @@ import {
ThreadComposer,
} from "./ThreadComposer";
import { ComposerPopoverHost } from "./ComposerPopoverHost";
-import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
+import {
+ type AssistantTextCiteRequest,
+ ThreadFeed,
+ type ThreadFeedHistoryControls,
+} from "./ThreadFeed";
+import {
+ createMobileAssistantCitation,
+ insertAssistantCitation,
+} from "../../lib/assistantCitationSelection";
+import {
+ captureComposerDraftInsertion,
+ rememberComposerDraftSelection,
+ setComposerDraftText,
+} from "../../state/use-composer-drafts";
import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
import { useThreadQueuedCount } from "./ThreadQueueControl";
@@ -1083,6 +1097,64 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
[props.onChangeDraftMessage],
);
+ const composerBusy =
+ props.activePendingApproval !== null || props.activePendingUserInput !== null;
+ const handleCiteAssistantText = useCallback(
+ (request: AssistantTextCiteRequest) => {
+ const draftKey = props.composerDraftKey;
+ if (draftKey === null || composerBusy || composerSlotHidden) {
+ Alert.alert(
+ "The composer is not ready",
+ "Try citing the selection after the connection or pending input is resolved.",
+ );
+ return;
+ }
+ const citation = createMobileAssistantCitation({
+ environmentId: props.environmentId,
+ threadId: props.selectedThread.id,
+ ...request,
+ });
+ if (citation === "too-long") {
+ Alert.alert("Selection is too long to cite", "Select less text and try again.");
+ return;
+ }
+ if (citation === null) return;
+ // Dictation holds this draft read-only and drops its transcript if the draft changes.
+ if (
+ voiceInputSession.ownerKey === draftKey &&
+ voiceInputBlocksSubmission(voiceInputSession.controller.currentState)
+ ) {
+ Alert.alert("The composer is not ready", "Try citing the selection after dictation ends.");
+ return;
+ }
+ const target = captureComposerDraftInsertion(draftKey);
+ const inserted = insertAssistantCitation(target.text, target, citation);
+ if (inserted === null) {
+ Alert.alert("The draft is too long", "Shorten the draft before adding this quote.");
+ return;
+ }
+ draftMessageRef.current = inserted.text;
+ setComposerDraftText(draftKey, inserted.text);
+ const caret = { start: inserted.caret, end: inserted.caret };
+ rememberComposerDraftSelection(draftKey, inserted.text, caret);
+ // Like web, citing goes straight to the quote's comment.
+ requestAnimationFrame(() => {
+ const editor = composerEditorRef.current;
+ // The composer can switch drafts first, e.g. when a queued run being edited starts.
+ if (!editor?.openNewCitation(draftKey, inserted.citation)) return;
+ editor.setSelection(caret);
+ });
+ },
+ [
+ composerBusy,
+ composerSlotHidden,
+ props.composerDraftKey,
+ props.environmentId,
+ props.selectedThread.id,
+ voiceInputSession,
+ ],
+ );
+
const handleScrollToEnd = useCallback(() => {
void Haptics.selectionAsync();
void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => {
@@ -1191,6 +1263,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onEndFollowEnabledChange={setEndFollowEnabled}
skills={selectedProviderSkills}
onUseArtifactTemplate={handleUseArtifactTemplate}
+ {...(isProviderSubagent ? {} : { onCiteAssistantText: handleCiteAssistantText })}
/>
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index 6198174ce8f0f..60e7df03ef22b 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -310,6 +310,15 @@ export interface ThreadFeedProps {
readonly onEndFollowEnabledChange?: (enabled: boolean) => void;
readonly skills?: ReadonlyArray;
readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
+ /** Adds Cite to the selection menu of assistant replies. */
+ readonly onCiteAssistantText?: (request: AssistantTextCiteRequest) => void;
+}
+
+export interface AssistantTextCiteRequest {
+ readonly messageId: MessageId;
+ /** The reply's rendered Markdown, which the selected text is located in. */
+ readonly markdown: string;
+ readonly selectedText: string;
}
async function waitForThreadShell(
@@ -814,9 +823,26 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
readonly renderImage: MarkdownImageRenderer;
readonly skills?: ReadonlyArray | undefined;
+ readonly messageId?: MessageId | undefined;
+ readonly onCiteAssistantText?: ((selection: AssistantTextCiteRequest) => void) | undefined;
}) {
const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]);
+ const { messageId, onCiteAssistantText } = props;
+ const cite = useCallback(
+ (selectedText: string) => {
+ if (messageId === undefined || onCiteAssistantText === undefined) return;
+ // Positions and context come from the whole reply, not the block the selection sits in.
+ const markdown = segments
+ .flatMap((segment) =>
+ segment.kind === "markdown" ? [renderCodexFileCitationsAsMarkdown(segment.markdown)] : [],
+ )
+ .join("\n\n");
+ onCiteAssistantText({ messageId, markdown, selectedText });
+ },
+ [messageId, onCiteAssistantText, segments],
+ );
+ const onCite = messageId !== undefined && onCiteAssistantText !== undefined ? cite : undefined;
return segments.map((segment) => {
if (segment.kind === "artifact-template") {
@@ -839,6 +865,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
textStyle={props.markdownStyles.nativeTextStyle}
{...props.linkHandlers}
renderImage={props.renderImage}
+ onCite={onCite}
/>
);
});
@@ -982,6 +1009,7 @@ function renderFeedEntry(
ThreadFeedProps,
| "environmentId"
| "onUseArtifactTemplate"
+ | "onCiteAssistantText"
| "skills"
| "dispatchingMessageId"
| "onEditPendingMessage"
@@ -1403,6 +1431,8 @@ function renderFeedEntry(
onUseArtifactTemplate={props.onUseArtifactTemplate}
renderImage={props.renderMarkdownImage}
skills={props.skills}
+ messageId={message.id}
+ onCiteAssistantText={props.onCiteAssistantText}
/>
) : null}
@@ -2513,6 +2543,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
dispatchingMessageId: props.dispatchingMessageId,
onEditPendingMessage: props.onEditPendingMessage,
onUseArtifactTemplate: props.onUseArtifactTemplate,
+ onCiteAssistantText: props.onCiteAssistantText,
threadId: props.threadId,
copiedRowId,
expandedWorkRows,
@@ -2590,6 +2621,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onToggleWorkRow,
props.environmentId,
props.onUseArtifactTemplate,
+ props.onCiteAssistantText,
props.threadId,
props.threadTitle,
props.skills,
diff --git a/apps/mobile/src/lib/assistantCitationSelection.test.ts b/apps/mobile/src/lib/assistantCitationSelection.test.ts
new file mode 100644
index 0000000000000..f29d95713a7e3
--- /dev/null
+++ b/apps/mobile/src/lib/assistantCitationSelection.test.ts
@@ -0,0 +1,127 @@
+import {
+ ASSISTANT_CITATION_MAX_TEXT_LENGTH,
+ EnvironmentId,
+ MessageId,
+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
+ ThreadId,
+} from "@t3tools/contracts";
+import { collectAssistantCitations } from "@t3tools/shared/assistantCitations";
+import { findAssistantCitationText } from "@t3tools/shared/assistantTextSelection";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ assistantMarkdownText,
+ createMobileAssistantCitation,
+ insertAssistantCitation,
+} from "./assistantCitationSelection";
+
+const ids = {
+ environmentId: EnvironmentId.make("environment-1"),
+ threadId: ThreadId.make("thread-1"),
+ messageId: MessageId.make("message-1"),
+};
+
+const markdown = [
+ "## Plan",
+ "",
+ "Retry the **upload** once, then [check the logs](https://example.com/logs).",
+ "",
+ "- First `step`",
+ "- Second step",
+ "",
+ "```ts",
+ "retry();",
+ "```",
+ "",
+ "",
+].join("\n");
+
+describe("assistantMarkdownText", () => {
+ it("keeps the words a reader sees, block by block, and drops Markdown syntax", () => {
+ const text = assistantMarkdownText(markdown);
+ expect(text.replace(/\s+/g, " ")).toBe(
+ "Plan Retry the upload once, then check the logs. First step Second step retry();",
+ );
+ expect(text).toMatch(/^Plan\n+Retry/);
+ });
+});
+
+describe("createMobileAssistantCitation", () => {
+ it("records where a unique selection sits so web can find it again", () => {
+ const citation = createMobileAssistantCitation({
+ ...ids,
+ markdown,
+ selectedText: "upload once, then check\nthe logs",
+ });
+ expect(citation).toMatchObject({
+ ...ids,
+ version: 1,
+ text: "upload once, then check\nthe logs",
+ prefix: "Plan Retry the ",
+ suffix: ". First step Second step retry()",
+ });
+ if (citation === null || citation === "too-long") throw new Error("expected a citation");
+ expect(findAssistantCitationText(assistantMarkdownText(markdown), citation)).toEqual({
+ start: citation.start,
+ end: citation.end,
+ });
+ });
+
+ it("keeps a repeated selection without a position web could resolve to the wrong place", () => {
+ expect(createMobileAssistantCitation({ ...ids, markdown, selectedText: "step" })).toEqual({
+ ...ids,
+ version: 1,
+ text: "step",
+ start: 0,
+ end: 4,
+ prefix: "",
+ suffix: "",
+ });
+ });
+
+ it("refuses empty and over-long selections", () => {
+ expect(createMobileAssistantCitation({ ...ids, markdown, selectedText: " \n " })).toBeNull();
+ const long = "word ".repeat(ASSISTANT_CITATION_MAX_TEXT_LENGTH / 4);
+ expect(createMobileAssistantCitation({ ...ids, markdown: long, selectedText: long })).toBe(
+ "too-long",
+ );
+ });
+});
+
+describe("insertAssistantCitation", () => {
+ const citation = {
+ ...ids,
+ version: 1 as const,
+ text: "quote",
+ start: 0,
+ end: 5,
+ prefix: "",
+ suffix: "",
+ };
+
+ it("separates the chip from the text around it and leaves the caret after it", () => {
+ const inserted = insertAssistantCitation(
+ "Explain this please",
+ { start: 12, end: 12 },
+ citation,
+ );
+ const [chip] = collectAssistantCitations(inserted!.text);
+ expect(chip).toMatchObject({ start: inserted!.citation.start, end: inserted!.citation.end });
+ expect(inserted!.text).toBe(`Explain this ${inserted!.citation.source} please`);
+ expect(inserted!.caret).toBe(inserted!.citation.end + 1);
+ });
+
+ it("does not add a leading space at the start or after whitespace", () => {
+ expect(insertAssistantCitation("", { start: 0, end: 0 }, citation)?.citation.start).toBe(0);
+ expect(insertAssistantCitation("Why ", { start: 4, end: 4 }, citation)?.text).toMatch(
+ /^Why \[Assistant quote\]\([^)]+\) $/,
+ );
+ });
+
+ it("refuses a quote that would push the draft past the input limit", () => {
+ const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS - 10);
+ expect(
+ insertAssistantCitation(draft, { start: draft.length, end: draft.length }, citation),
+ ).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/lib/assistantCitationSelection.ts b/apps/mobile/src/lib/assistantCitationSelection.ts
new file mode 100644
index 0000000000000..b7c9713c0d736
--- /dev/null
+++ b/apps/mobile/src/lib/assistantCitationSelection.ts
@@ -0,0 +1,91 @@
+import {
+ ASSISTANT_CITATION_MAX_TEXT_LENGTH,
+ type AssistantCitation,
+ type EnvironmentId,
+ type MessageId,
+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
+ type ThreadId,
+} from "@t3tools/contracts";
+import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
+import { locateAssistantTextSelector } from "@t3tools/shared/assistantTextSelection";
+
+import { type MarkdownNode, parseMarkdownDocument } from "./markdownDocument";
+
+const INLINE_PARENTS = new Set(["emphasis", "strong", "delete", "link", "linkReference"]);
+
+function appendRenderedText(node: MarkdownNode, parts: string[]): void {
+ switch (node.type) {
+ case "text":
+ case "inlineCode":
+ parts.push(node.value);
+ return;
+ case "code":
+ parts.push("\n", node.value, "\n");
+ return;
+ case "break":
+ parts.push("\n");
+ return;
+ }
+ if (!("children" in node)) return;
+ const inline = INLINE_PARENTS.has(node.type);
+ if (!inline) parts.push("\n");
+ for (const child of node.children as ReadonlyArray)
+ appendRenderedText(child, parts);
+ if (!inline) parts.push("\n");
+}
+
+/**
+ * The text a reader sees in rendered assistant Markdown, one block per line. Native selection
+ * only reports the selected text, so this is the stream a citation's position and context come
+ * from.
+ */
+export function assistantMarkdownText(markdown: string): string {
+ const parts: string[] = [];
+ appendRenderedText(parseMarkdownDocument(markdown), parts);
+ return parts.join("").trim();
+}
+
+export function createMobileAssistantCitation(input: {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ readonly messageId: MessageId;
+ /** The Markdown rendered by the view the text was selected in. */
+ readonly markdown: string;
+ readonly selectedText: string;
+}): AssistantCitation | "too-long" | null {
+ const selector = locateAssistantTextSelector(
+ assistantMarkdownText(input.markdown),
+ input.selectedText,
+ );
+ if (selector === null) return null;
+ if (selector.text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH) return "too-long";
+ return {
+ version: 1,
+ environmentId: input.environmentId,
+ threadId: input.threadId,
+ messageId: input.messageId,
+ ...selector,
+ };
+}
+
+/**
+ * Inserts a citation the way web does: apart from the text before it and followed by a space.
+ * Returns null when the draft would pass the input limit the editor enforces on typing and paste.
+ */
+export function insertAssistantCitation(
+ text: string,
+ selection: { readonly start: number; readonly end: number },
+ citation: AssistantCitation,
+) {
+ const source = serializeAssistantCitation(citation);
+ const leading = selection.start > 0 && !/\s/.test(text[selection.start - 1]!) ? " " : "";
+ const end = text[selection.end] === " " ? selection.end + 1 : selection.end;
+ const start = selection.start + leading.length;
+ const next = `${text.slice(0, selection.start)}${leading}${source} ${text.slice(end)}`;
+ if (next.length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) return null;
+ return {
+ text: next,
+ citation: { source, start, end: start + source.length },
+ caret: start + source.length + 1,
+ };
+}
diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts
index 853ae43f87915..975b098a1ac50 100644
--- a/apps/mobile/src/lib/composerChipPresentation.ts
+++ b/apps/mobile/src/lib/composerChipPresentation.ts
@@ -17,6 +17,7 @@ const CONTEXT_CHIP_PRESENTATIONS = {
"review-comment": { accent: "#8a70dd", symbol: "text.bubble" },
"pull-request": { accent: "#7079e4", symbol: "git-pull-request" },
skill: { accent: "#b261be", symbol: "cube" },
+ citation: { accent: "#4684e5", symbol: "quote.closing" },
} as const;
/**
diff --git a/apps/mobile/src/lib/composerContext.test.ts b/apps/mobile/src/lib/composerContext.test.ts
index 4547efaa332cf..f4043114e2394 100644
--- a/apps/mobile/src/lib/composerContext.test.ts
+++ b/apps/mobile/src/lib/composerContext.test.ts
@@ -4,8 +4,12 @@ import {
ProjectId,
ProviderInstanceId,
ComposerContextId,
+ EnvironmentId,
+ MessageId,
+ ThreadId,
type OrchestrationMessageContext,
} from "@t3tools/contracts";
+import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import {
ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
@@ -165,6 +169,28 @@ describe("mobile composer context", () => {
for (const token of tokens) expect(text.slice(token.start, token.end)).toBe(token.source);
});
+ it("collapses an assistant quote into one chip labelled like web", () => {
+ const quote = serializeAssistantCitation({
+ version: 1,
+ environmentId: EnvironmentId.make("environment-1"),
+ threadId: ThreadId.make("thread-1"),
+ messageId: MessageId.make("message-1"),
+ text: "Use the retry\nqueue",
+ comment: "Why here?",
+ start: 0,
+ end: 19,
+ prefix: "",
+ suffix: "",
+ });
+ const text = `Check ${quote} in [app.ts](src/app.ts) now`;
+ const tokens = composerContextEditorTokens(text, collectComposerInlineTokens(text));
+ expect(tokens.map((token) => [token.type, token.value])).toEqual([
+ ["citation", "Why here?"],
+ ["mention", "src/app.ts"],
+ ]);
+ for (const token of tokens) expect(text.slice(token.start, token.end)).toBe(token.source);
+ });
+
it("removes deleted payloads but keeps the screenshot linked to a remaining annotation", () => {
const context: OrchestrationMessageContext = {
version: 1,
diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts
index 45b033dc9488f..fa40ef181df2a 100644
--- a/apps/mobile/src/lib/composerContext.ts
+++ b/apps/mobile/src/lib/composerContext.ts
@@ -1,3 +1,7 @@
+import {
+ assistantCitationLabel,
+ collectAssistantCitations,
+} from "@t3tools/shared/assistantCitations";
import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend";
import { filePreviewKind } from "@t3tools/shared/filePreview";
import { videoMimeType } from "@t3tools/shared/video";
@@ -156,15 +160,24 @@ export function pullRequestComposerContext(
/** Native editors collapse the canonical source range to a single atomic attachment. */
export function composerContextEditorTokens(text: string, tokens: readonly ComposerInlineToken[]) {
const references = collectComposerContextReferences(text);
+ const citations = collectAssistantCitations(text);
+ const atomic = [...references, ...citations];
return [
...tokens.filter(
- (token) => !references.some((ref) => token.start < ref.end && token.end > ref.start),
+ (token) => !atomic.some((range) => token.start < range.end && token.end > range.start),
),
...references.map((ref) => ({
type: "context" as const,
value: ref.label,
...ref,
})),
+ ...citations.map(({ citation, source, start, end }) => ({
+ type: "citation" as const,
+ value: assistantCitationLabel(citation),
+ source,
+ start,
+ end,
+ })),
].sort((a, b) => a.start - b.start);
}
diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx
index d77c9491972f0..bc03d85b7f641 100644
--- a/apps/mobile/src/native/EnrichedMarkdownText.tsx
+++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx
@@ -239,6 +239,20 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
mimeTypes: { "app.t3.context-fragment": props.contextClipboardFragment! },
};
}, [props.contextClipboardFragment, props.markdown]);
+ const { onCite } = props;
+ const contextMenuItems = useMemo(
+ () =>
+ onCite
+ ? [
+ {
+ text: "Cite",
+ icon: "quote.closing",
+ onPress: ({ text }: { text: string }) => onCite(text),
+ },
+ ]
+ : undefined,
+ [onCite],
+ );
const markdownStyle = useMemo(
() => ({ ...enrichedStyle(props.textStyle, themeAppearance === "dark"), linkVariants }),
[props.textStyle, themeAppearance, linkVariants],
@@ -332,6 +346,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
onLinkLongPress={onLinkLongPress}
linkContextMenuItems={linkContextMenuItems}
linkPillContent={linkPillContent}
+ contextMenuItems={contextMenuItems}
/>
);
diff --git a/apps/mobile/src/native/SelectableMarkdownText.types.ts b/apps/mobile/src/native/SelectableMarkdownText.types.ts
index ce8c1330ac8bc..738aa82673031 100644
--- a/apps/mobile/src/native/SelectableMarkdownText.types.ts
+++ b/apps/mobile/src/native/SelectableMarkdownText.types.ts
@@ -79,6 +79,8 @@ export interface SelectableMarkdownTextProps {
readonly renderImage?: MarkdownImageRenderer;
readonly resolveImageSource?: MarkdownImageSourceResolver;
readonly onImagePress?: (href: string) => void;
+ /** Adds Cite to the text selection menu and receives the selected text. */
+ readonly onCite?: (selectedText: string) => void;
readonly marginTop?: number;
readonly marginBottom?: number;
}
diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx
index fc19e032c3c69..45f9bb614bccf 100644
--- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx
+++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx
@@ -178,7 +178,9 @@ export function ComposerEditor({
? (skillLabels.get(token.value) ?? token.value)
: 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) : "",
// Only a mention wears per-filetype artwork. An attachment chip keeps the tinted
// monochrome glyph web draws for it: coloured artwork ignores the chip's accent and
diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx
index 9cc4f4c36d99b..56f5ca5bf267f 100644
--- a/apps/mobile/src/native/T3ComposerEditor.native.tsx
+++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx
@@ -176,7 +176,9 @@ export function ComposerEditor({
? (skillLabels.get(token.value) ?? token.value)
: 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) : "",
// Only a mention wears per-filetype artwork. An attachment chip keeps the tinted
// monochrome glyph web draws for it: coloured artwork ignores the chip's accent and
diff --git a/apps/web/src/lib/assistantTextSelection.test.ts b/apps/web/src/lib/assistantTextSelection.test.ts
index f4725af73ad19..8e8f09f74485f 100644
--- a/apps/web/src/lib/assistantTextSelection.test.ts
+++ b/apps/web/src/lib/assistantTextSelection.test.ts
@@ -1,21 +1,7 @@
import { describe, expect, it } from "vite-plus/test";
-import {
- ASSISTANT_CITATION_CONTEXT_LENGTH,
- EnvironmentId,
- MessageId,
- ThreadId,
-} from "@t3tools/contracts";
-import {
- formatAssistantCitationHref,
- parseAssistantCitationHref,
-} from "@t3tools/shared/assistantCitations";
+import type { AssistantTextSelector } from "@t3tools/shared/assistantTextSelection";
-import {
- type AssistantTextSelector,
- captureAssistantTextSelection,
- createAssistantTextSelector,
- findAssistantCitationText,
-} from "./assistantTextSelection";
+import { captureAssistantTextSelection } from "./assistantTextSelection";
function selector(
text: string,
@@ -31,18 +17,6 @@ function selector(
};
}
-function roundTripSelector(selector: AssistantTextSelector) {
- return parseAssistantCitationHref(
- formatAssistantCitationHref({
- version: 1,
- environmentId: EnvironmentId.make("environment"),
- threadId: ThreadId.make("thread"),
- messageId: MessageId.make("assistant"),
- ...selector,
- }),
- );
-}
-
class SelectionNode {
parentElement: SelectionNode | null = null;
childNodes: SelectionNode[] = [];
@@ -357,230 +331,3 @@ describe("captureAssistantTextSelection", () => {
).toBeNull();
});
});
-
-describe("createAssistantTextSelector", () => {
- it("keeps exact selected whitespace while normalizing its UTF-16 positions", () => {
- const text = "Before \n quote\t \n after";
- expect(
- createAssistantTextSelector(text, "Before \n".length, "Before \n quote\t".length),
- ).toEqual({
- text: " quote\t",
- start: 6,
- end: 13,
- prefix: "Before",
- suffix: "after",
- });
- });
-
- it.each([
- 0,
- ASSISTANT_CITATION_CONTEXT_LENGTH - 2,
- ASSISTANT_CITATION_CONTEXT_LENGTH - 1,
- ASSISTANT_CITATION_CONTEXT_LENGTH,
- ])("keeps complete context code points with %i neighboring UTF-16 units", (paddingLength) => {
- const padding = "x".repeat(paddingLength);
- const text = `π${padding}quote${padding}π`;
- const start = text.indexOf("quote");
- const captured = createAssistantTextSelector(text, start, start + 5);
- const keepEmoji = paddingLength + 2 <= ASSISTANT_CITATION_CONTEXT_LENGTH;
- expect(captured).toEqual({
- text: "quote",
- start,
- end: start + 5,
- prefix: `${keepEmoji ? "π" : ""}${padding}`,
- suffix: `${padding}${keepEmoji ? "π" : ""}`,
- });
- expect(roundTripSelector(captured!)).toMatchObject(captured!);
- });
-
- it("preserves multiline quotes and their matching location through a citation URL", () => {
- const quote = "selected\r\n code π";
- const prefix = "x".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1);
- const suffix = "y".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1);
- const text = `${quote} elsewhere\nπ${prefix}${quote}${suffix}π`;
- const rawStart = text.lastIndexOf(quote);
- const captured = createAssistantTextSelector(text, rawStart, rawStart + quote.length);
- const start = "selected code π elsewhere π".length + prefix.length;
- const end = start + "selected code π".length;
- expect(captured).toEqual({ text: quote, start, end, prefix, suffix });
-
- const parsed = roundTripSelector(captured!);
- expect(parsed).toMatchObject(captured!);
- expect(findAssistantCitationText(text, parsed!)).toEqual({ start, end });
- expect(findAssistantCitationText(`Inserted paragraph.\n${text}`, parsed!)).toEqual({
- start: "Inserted paragraph. ".length + start,
- end: "Inserted paragraph. ".length + end,
- });
- });
-
- it.each(["prefix", "suffix"])(
- "does not relocate to a replacement-character decoy after clipping the %s",
- (side) => {
- const prefix = "x".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - (side === "prefix" ? 1 : 0));
- const suffix = "y".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - (side === "suffix" ? 1 : 0));
- const occurrence = (character: string) =>
- `${side === "prefix" ? character : ""}${prefix}quote${suffix}${side === "suffix" ? character : ""}`;
- const text = `${occurrence("π")} / ${occurrence("\uFFFD")}`;
- const start = text.indexOf("quote");
- const captured = createAssistantTextSelector(text, start, start + 5);
- const parsed = roundTripSelector(captured!);
- expect(parsed).toMatchObject(captured!);
- expect(findAssistantCitationText(text, captured!)).toBeNull();
- expect(findAssistantCitationText(text, parsed!)).toBeNull();
- },
- );
-
- it.each(["", " \n\t\r\n"])("rejects empty or whitespace-only selections: %j", (text) => {
- expect(createAssistantTextSelector(text, 0, text.length)).toBeNull();
- });
-});
-
-describe("findAssistantCitationText", () => {
- it("resolves an exact selection with its saved position and context", () => {
- expect(
- findAssistantCitationText(
- "Before the selected text after.",
- selector("selected text", { start: 11, end: 24, prefix: "Before the ", suffix: " after." }),
- ),
- ).toEqual({ start: 11, end: 24 });
- });
-
- it("finds a unique quote when insertion before it shifts its offsets", () => {
- expect(
- findAssistantCitationText(
- "An inserted paragraph. Before the selected text after.",
- selector("selected text", { start: 11, end: 24, prefix: "Before the ", suffix: " after." }),
- ),
- ).toEqual({ start: 34, end: 47 });
- });
-
- it("still finds a unique quote after its surrounding text changes", () => {
- expect(
- findAssistantCitationText(
- "New selected text nearby.",
- selector("selected text", { start: 11, end: 24, prefix: "Before the ", suffix: " after." }),
- ),
- ).toEqual({ start: 4, end: 17 });
- });
-
- it("uses both context sides when each side alone matches several repeated quotes", () => {
- const text = "left quote one; other quote two; left quote two";
- expect(
- findAssistantCitationText(
- text,
- selector("quote", { start: 5, end: 10, prefix: "left ", suffix: " two" }),
- ),
- ).toEqual({ start: 38, end: 43 });
- });
-
- it("does not trust stale offsets that now point at another occurrence", () => {
- expect(
- findAssistantCitationText(
- "wrong quote here; right quote there",
- selector("quote", { start: 6, end: 11, prefix: "right ", suffix: " there" }),
- ),
- ).toEqual({ start: 24, end: 29 });
- });
-
- it.each([
- { prefix: "first ", suffix: "", expected: { start: 6, end: 11 } },
- { prefix: "", suffix: " last", expected: { start: 14, end: 19 } },
- ])(
- "allows a single context side to disambiguate: $prefix / $suffix",
- ({ expected, ...context }) => {
- expect(
- findAssistantCitationText("first quote / quote last", selector("quote", context)),
- ).toEqual(expected);
- },
- );
-
- it("does not guess between quotes without context, even at the saved position", () => {
- expect(findAssistantCitationText("quote / quote", selector("quote"))).toBeNull();
- });
-
- it("does not use distance or saved offsets to break a context tie", () => {
- expect(
- findAssistantCitationText(
- "same quote end / same quote end",
- selector("quote", { start: 5, end: 10, prefix: "same ", suffix: " end" }),
- ),
- ).toBeNull();
- });
-
- it("rejects repeated quotes when neither occurrence matches all supplied context", () => {
- expect(
- findAssistantCitationText(
- "left quote wrong / wrong quote right",
- selector("quote", { prefix: "left ", suffix: " right" }),
- ),
- ).toBeNull();
- });
-
- it("counts overlapping occurrences when checking ambiguity", () => {
- expect(findAssistantCitationText("banana", selector("ana", { start: 1, end: 4 }))).toBeNull();
- });
-
- it("matches multiline code after indentation, tabs, and line endings change", () => {
- const quote = "if (ready) {\r\n run();\r\n}";
- expect(
- findAssistantCitationText(
- "Example:\nif (ready) {\n\trun();\n}\nDone.",
- selector(quote, { prefix: "Example:\n", suffix: "\nDone." }),
- ),
- ).toEqual({ start: 9, end: 30 });
- });
-
- it("normalizes nonbreaking spaces and keeps selected boundary whitespace", () => {
- expect(
- findAssistantCitationText(
- "before\u00a0\n quoted\t text \nafter",
- selector("\nquoted text\t"),
- ),
- ).toEqual({ start: 6, end: 19 });
- });
-
- it("does not trim the document's leading whitespace out of its offsets", () => {
- expect(findAssistantCitationText("\n\t quote", selector("quote"))).toEqual({
- start: 1,
- end: 6,
- });
- });
-
- it("uses UTF-16 offsets for emoji and combining characters", () => {
- expect(findAssistantCitationText("π cafe\u0301 π done", selector("cafe\u0301 π"))).toEqual({
- start: 3,
- end: 11,
- });
- });
-
- it("uses up to 32 UTF-16 context units without requiring the entire surrounding text", () => {
- const prefix = "x".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1) + " ";
- const suffix = " " + "y".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1);
- const text = `unrelated quote / ${prefix}quote${suffix} changed further away`;
- const start = text.lastIndexOf("quote");
- expect(findAssistantCitationText(text, selector("quote", { prefix, suffix }))).toEqual({
- start,
- end: start + 5,
- });
- });
-
- it.each(["", " \n\t\r\n", "absent", "QUOTE", "qu.te"])(
- "rejects empty or missing literal text: %j",
- (quote) => {
- expect(findAssistantCitationText("quote", selector(quote))).toBeNull();
- },
- );
-
- it.each([
- { start: -1, end: 4 },
- { start: 2.5, end: 7.5 },
- { start: Number.NaN, end: Number.NaN },
- { start: Number.POSITIVE_INFINITY, end: Number.POSITIVE_INFINITY },
- { start: 8, end: 3 },
- ])("treats invalid stored offsets as unusable hints: $start / $end", (offsets) => {
- expect(findAssistantCitationText("a quote", selector("quote", offsets))).toEqual({
- start: 2,
- end: 7,
- });
- });
-});
diff --git a/apps/web/src/lib/assistantTextSelection.ts b/apps/web/src/lib/assistantTextSelection.ts
index 1f388b2e14f94..a1314025e548a 100644
--- a/apps/web/src/lib/assistantTextSelection.ts
+++ b/apps/web/src/lib/assistantTextSelection.ts
@@ -1,12 +1,10 @@
-import { ASSISTANT_CITATION_CONTEXT_LENGTH, type AssistantCitation } from "@t3tools/contracts";
-
-export type AssistantTextSelector = {
- readonly text: string;
- readonly start: number;
- readonly end: number;
- readonly prefix: string;
- readonly suffix: string;
-};
+import type { AssistantCitation } from "@t3tools/contracts";
+import {
+ type AssistantTextSelector,
+ createAssistantTextSelector,
+ findAssistantCitationText,
+ rawTextOffset,
+} from "@t3tools/shared/assistantTextSelection";
/** Live DOM state for an open comment, never part of a saved citation. */
export type AssistantCitationSourceAnchor = {
@@ -38,96 +36,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;
-}
-
-/** Keeps the exact captured text while storing normalized UTF-16 positions and context. */
-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;
- // A selection starting inside a whitespace run includes its normalized space.
- 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),
- };
-}
-
-/**
- * Matches case-sensitive text after collapsing each JS whitespace run to one
- * space, without trimming. All positions and context lengths use UTF-16 units
- * in that normalized stream, not markdown offsets or Unicode code points.
- * `selector.text` can retain the original line breaks and code indentation.
- * Repeated quotes require one match for all supplied context. Even matching
- * saved offsets cannot break a context tie, since those offsets may have drifted.
- */
-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);
-}
-
type TextChunk = { node: Text; start: number; end: number };
/**
@@ -251,22 +159,6 @@ export function captureAssistantTextSelection(
return selector === null ? null : { source, selector, range };
}
-function rawTextOffset(text: string, normalizedOffset: number): number {
- let offset = 0;
- for (const match of text.matchAll(/\s+|\S+/g)) {
- const whitespace = /\s/.test(match[0][0]!);
- const length = whitespace ? 1 : match[0].length;
- if (normalizedOffset <= offset + length) {
- return (
- match.index +
- (whitespace && normalizedOffset > offset ? match[0].length : normalizedOffset - offset)
- );
- }
- offset += length;
- }
- return text.length;
-}
-
/** Resolves against the current DOM without changing the user's selection. */
export function resolveAssistantCitationRange(
root: HTMLElement,
diff --git a/docs/user/composer.md b/docs/user/composer.md
index 831a8ac9792d1..9416fab92f192 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -96,8 +96,9 @@ Leaving reasoning level or service tier unset uses the provider's own configurat
## Quote an assistant response
On web and desktop, select text within one assistant response and choose
-**Cite in composer**. You can add a comment about the quote and write instructions
-around it.
+**Cite in composer**. On iOS and Android, select text in an assistant response and
+choose **Cite** from the selection menu. You can add a comment about the quote and
+write instructions around it.
Select the quote in a draft or sent message to return to its source. If the source
is unavailable or has changed, the saved quote remains readable.
@@ -109,8 +110,9 @@ 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, citing opens the quote so you can add a comment right away. Tap a quote chip in
+the draft later to read the quote, change its comment, or remove it. Mobile does not navigate
+to a quote's source yet.
## Recall a sent prompt
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 98c9379527675..3c7518c30d541 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -251,6 +251,10 @@
"types": "./src/assistantCitations.ts",
"import": "./src/assistantCitations.ts"
},
+ "./assistantTextSelection": {
+ "types": "./src/assistantTextSelection.ts",
+ "import": "./src/assistantTextSelection.ts"
+ },
"./promptLinks": {
"types": "./src/promptLinks.ts",
"import": "./src/promptLinks.ts"
diff --git a/packages/shared/src/assistantTextSelection.test.ts b/packages/shared/src/assistantTextSelection.test.ts
new file mode 100644
index 0000000000000..763731b1a0657
--- /dev/null
+++ b/packages/shared/src/assistantTextSelection.test.ts
@@ -0,0 +1,303 @@
+import { describe, expect, it } from "vite-plus/test";
+import {
+ ASSISTANT_CITATION_CONTEXT_LENGTH,
+ EnvironmentId,
+ MessageId,
+ ThreadId,
+} from "@t3tools/contracts";
+
+import { formatAssistantCitationHref, parseAssistantCitationHref } from "./assistantCitations.ts";
+import {
+ type AssistantTextSelector,
+ createAssistantTextSelector,
+ findAssistantCitationText,
+ locateAssistantTextSelector,
+} from "./assistantTextSelection.ts";
+
+function selector(
+ text: string,
+ overrides: Partial> = {},
+): AssistantTextSelector {
+ return {
+ text,
+ start: 0,
+ end: text.replace(/\s+/g, " ").length,
+ prefix: "",
+ suffix: "",
+ ...overrides,
+ };
+}
+
+function roundTripSelector(selector: AssistantTextSelector) {
+ return parseAssistantCitationHref(
+ formatAssistantCitationHref({
+ version: 1,
+ environmentId: EnvironmentId.make("environment"),
+ threadId: ThreadId.make("thread"),
+ messageId: MessageId.make("assistant"),
+ ...selector,
+ }),
+ );
+}
+
+describe("createAssistantTextSelector", () => {
+ it("keeps exact selected whitespace while normalizing its UTF-16 positions", () => {
+ const text = "Before \n quote\t \n after";
+ expect(
+ createAssistantTextSelector(text, "Before \n".length, "Before \n quote\t".length),
+ ).toEqual({
+ text: " quote\t",
+ start: 6,
+ end: 13,
+ prefix: "Before",
+ suffix: "after",
+ });
+ });
+
+ it.each([
+ 0,
+ ASSISTANT_CITATION_CONTEXT_LENGTH - 2,
+ ASSISTANT_CITATION_CONTEXT_LENGTH - 1,
+ ASSISTANT_CITATION_CONTEXT_LENGTH,
+ ])("keeps complete context code points with %i neighboring UTF-16 units", (paddingLength) => {
+ const padding = "x".repeat(paddingLength);
+ const text = `π${padding}quote${padding}π`;
+ const start = text.indexOf("quote");
+ const captured = createAssistantTextSelector(text, start, start + 5);
+ const keepEmoji = paddingLength + 2 <= ASSISTANT_CITATION_CONTEXT_LENGTH;
+ expect(captured).toEqual({
+ text: "quote",
+ start,
+ end: start + 5,
+ prefix: `${keepEmoji ? "π" : ""}${padding}`,
+ suffix: `${padding}${keepEmoji ? "π" : ""}`,
+ });
+ expect(roundTripSelector(captured!)).toMatchObject(captured!);
+ });
+
+ it("preserves multiline quotes and their matching location through a citation URL", () => {
+ const quote = "selected\r\n code π";
+ const prefix = "x".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1);
+ const suffix = "y".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1);
+ const text = `${quote} elsewhere\nπ${prefix}${quote}${suffix}π`;
+ const rawStart = text.lastIndexOf(quote);
+ const captured = createAssistantTextSelector(text, rawStart, rawStart + quote.length);
+ const start = "selected code π elsewhere π".length + prefix.length;
+ const end = start + "selected code π".length;
+ expect(captured).toEqual({ text: quote, start, end, prefix, suffix });
+
+ const parsed = roundTripSelector(captured!);
+ expect(parsed).toMatchObject(captured!);
+ expect(findAssistantCitationText(text, parsed!)).toEqual({ start, end });
+ expect(findAssistantCitationText(`Inserted paragraph.\n${text}`, parsed!)).toEqual({
+ start: "Inserted paragraph. ".length + start,
+ end: "Inserted paragraph. ".length + end,
+ });
+ });
+
+ it.each(["prefix", "suffix"])(
+ "does not relocate to a replacement-character decoy after clipping the %s",
+ (side) => {
+ const prefix = "x".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - (side === "prefix" ? 1 : 0));
+ const suffix = "y".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - (side === "suffix" ? 1 : 0));
+ const occurrence = (character: string) =>
+ `${side === "prefix" ? character : ""}${prefix}quote${suffix}${side === "suffix" ? character : ""}`;
+ const text = `${occurrence("π")} / ${occurrence("\uFFFD")}`;
+ const start = text.indexOf("quote");
+ const captured = createAssistantTextSelector(text, start, start + 5);
+ const parsed = roundTripSelector(captured!);
+ expect(parsed).toMatchObject(captured!);
+ expect(findAssistantCitationText(text, captured!)).toBeNull();
+ expect(findAssistantCitationText(text, parsed!)).toBeNull();
+ },
+ );
+
+ it.each(["", " \n\t\r\n"])("rejects empty or whitespace-only selections: %j", (text) => {
+ expect(createAssistantTextSelector(text, 0, text.length)).toBeNull();
+ });
+});
+
+describe("findAssistantCitationText", () => {
+ it("resolves an exact selection with its saved position and context", () => {
+ expect(
+ findAssistantCitationText(
+ "Before the selected text after.",
+ selector("selected text", { start: 11, end: 24, prefix: "Before the ", suffix: " after." }),
+ ),
+ ).toEqual({ start: 11, end: 24 });
+ });
+
+ it("finds a unique quote when insertion before it shifts its offsets", () => {
+ expect(
+ findAssistantCitationText(
+ "An inserted paragraph. Before the selected text after.",
+ selector("selected text", { start: 11, end: 24, prefix: "Before the ", suffix: " after." }),
+ ),
+ ).toEqual({ start: 34, end: 47 });
+ });
+
+ it("still finds a unique quote after its surrounding text changes", () => {
+ expect(
+ findAssistantCitationText(
+ "New selected text nearby.",
+ selector("selected text", { start: 11, end: 24, prefix: "Before the ", suffix: " after." }),
+ ),
+ ).toEqual({ start: 4, end: 17 });
+ });
+
+ it("uses both context sides when each side alone matches several repeated quotes", () => {
+ const text = "left quote one; other quote two; left quote two";
+ expect(
+ findAssistantCitationText(
+ text,
+ selector("quote", { start: 5, end: 10, prefix: "left ", suffix: " two" }),
+ ),
+ ).toEqual({ start: 38, end: 43 });
+ });
+
+ it("does not trust stale offsets that now point at another occurrence", () => {
+ expect(
+ findAssistantCitationText(
+ "wrong quote here; right quote there",
+ selector("quote", { start: 6, end: 11, prefix: "right ", suffix: " there" }),
+ ),
+ ).toEqual({ start: 24, end: 29 });
+ });
+
+ it.each([
+ { prefix: "first ", suffix: "", expected: { start: 6, end: 11 } },
+ { prefix: "", suffix: " last", expected: { start: 14, end: 19 } },
+ ])(
+ "allows a single context side to disambiguate: $prefix / $suffix",
+ ({ expected, ...context }) => {
+ expect(
+ findAssistantCitationText("first quote / quote last", selector("quote", context)),
+ ).toEqual(expected);
+ },
+ );
+
+ it("does not guess between quotes without context, even at the saved position", () => {
+ expect(findAssistantCitationText("quote / quote", selector("quote"))).toBeNull();
+ });
+
+ it("does not use distance or saved offsets to break a context tie", () => {
+ expect(
+ findAssistantCitationText(
+ "same quote end / same quote end",
+ selector("quote", { start: 5, end: 10, prefix: "same ", suffix: " end" }),
+ ),
+ ).toBeNull();
+ });
+
+ it("rejects repeated quotes when neither occurrence matches all supplied context", () => {
+ expect(
+ findAssistantCitationText(
+ "left quote wrong / wrong quote right",
+ selector("quote", { prefix: "left ", suffix: " right" }),
+ ),
+ ).toBeNull();
+ });
+
+ it("counts overlapping occurrences when checking ambiguity", () => {
+ expect(findAssistantCitationText("banana", selector("ana", { start: 1, end: 4 }))).toBeNull();
+ });
+
+ it("matches multiline code after indentation, tabs, and line endings change", () => {
+ const quote = "if (ready) {\r\n run();\r\n}";
+ expect(
+ findAssistantCitationText(
+ "Example:\nif (ready) {\n\trun();\n}\nDone.",
+ selector(quote, { prefix: "Example:\n", suffix: "\nDone." }),
+ ),
+ ).toEqual({ start: 9, end: 30 });
+ });
+
+ it("normalizes nonbreaking spaces and keeps selected boundary whitespace", () => {
+ expect(
+ findAssistantCitationText(
+ "before\u00a0\n quoted\t text \nafter",
+ selector("\nquoted text\t"),
+ ),
+ ).toEqual({ start: 6, end: 19 });
+ });
+
+ it("does not trim the document's leading whitespace out of its offsets", () => {
+ expect(findAssistantCitationText("\n\t quote", selector("quote"))).toEqual({
+ start: 1,
+ end: 6,
+ });
+ });
+
+ it("uses UTF-16 offsets for emoji and combining characters", () => {
+ expect(findAssistantCitationText("π cafe\u0301 π done", selector("cafe\u0301 π"))).toEqual({
+ start: 3,
+ end: 11,
+ });
+ });
+
+ it("uses up to 32 UTF-16 context units without requiring the entire surrounding text", () => {
+ const prefix = "x".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1) + " ";
+ const suffix = " " + "y".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1);
+ const text = `unrelated quote / ${prefix}quote${suffix} changed further away`;
+ const start = text.lastIndexOf("quote");
+ expect(findAssistantCitationText(text, selector("quote", { prefix, suffix }))).toEqual({
+ start,
+ end: start + 5,
+ });
+ });
+
+ it.each(["", " \n\t\r\n", "absent", "QUOTE", "qu.te"])(
+ "rejects empty or missing literal text: %j",
+ (quote) => {
+ expect(findAssistantCitationText("quote", selector(quote))).toBeNull();
+ },
+ );
+
+ it.each([
+ { start: -1, end: 4 },
+ { start: 2.5, end: 7.5 },
+ { start: Number.NaN, end: Number.NaN },
+ { start: Number.POSITIVE_INFINITY, end: Number.POSITIVE_INFINITY },
+ { start: 8, end: 3 },
+ ])("treats invalid stored offsets as unusable hints: $start / $end", (offsets) => {
+ expect(findAssistantCitationText("a quote", selector("quote", offsets))).toEqual({
+ start: 2,
+ end: 7,
+ });
+ });
+});
+
+describe("locateAssistantTextSelector", () => {
+ it("adds position and context when the selected text occurs once", () => {
+ const text = "Intro line.\nThe selected text sits here.\nOutro.";
+ const located = locateAssistantTextSelector(text, " selected text\n");
+
+ expect(located).toEqual({
+ text: "selected text",
+ start: 16,
+ end: 29,
+ prefix: "Intro line. The ",
+ suffix: " sits here. Outro.",
+ });
+ expect(findAssistantCitationText(text, roundTripSelector(located!)!)).toEqual({
+ start: 16,
+ end: 29,
+ });
+ });
+
+ it("keeps only the quote when its position is ambiguous or unknown", () => {
+ for (const text of ["quote / quote", "nothing to see"]) {
+ const located = locateAssistantTextSelector(text, "quote");
+ expect(located).toEqual(selector("quote"));
+ expect(findAssistantCitationText(text, located!)).toBeNull();
+ }
+ expect(findAssistantCitationText("one quote", selector("quote"))).toEqual({
+ start: 4,
+ end: 9,
+ });
+ });
+
+ it("rejects whitespace-only selections", () => {
+ expect(locateAssistantTextSelector("text", " \n\t")).toBeNull();
+ });
+});
diff --git a/packages/shared/src/assistantTextSelection.ts b/packages/shared/src/assistantTextSelection.ts
new file mode 100644
index 0000000000000..6a2fecf306842
--- /dev/null
+++ b/packages/shared/src/assistantTextSelection.ts
@@ -0,0 +1,142 @@
+import { ASSISTANT_CITATION_CONTEXT_LENGTH } from "@t3tools/contracts";
+
+export type AssistantTextSelector = {
+ readonly text: string;
+ readonly start: number;
+ readonly end: number;
+ readonly prefix: string;
+ readonly suffix: string;
+};
+
+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;
+}
+
+/** Keeps the exact captured text while storing normalized UTF-16 positions and context. */
+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;
+ // A selection starting inside a whitespace run includes its normalized space.
+ 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),
+ };
+}
+
+/**
+ * Matches case-sensitive text after collapsing each JS whitespace run to one
+ * space, without trimming. All positions and context lengths use UTF-16 units
+ * in that normalized stream, not markdown offsets or Unicode code points.
+ * `selector.text` can retain the original line breaks and code indentation.
+ * Repeated quotes require one match for all supplied context. Even matching
+ * saved offsets cannot break a context tie, since those offsets may have drifted.
+ */
+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);
+}
+
+/** Maps a position in the whitespace-normalized stream back to `text`. */
+export function rawTextOffset(text: string, normalizedOffset: number): number {
+ let offset = 0;
+ for (const match of text.matchAll(/\s+|\S+/g)) {
+ const whitespace = /\s/.test(match[0][0]!);
+ const length = whitespace ? 1 : match[0].length;
+ if (normalizedOffset <= offset + length) {
+ return (
+ match.index +
+ (whitespace && normalizedOffset > offset ? match[0].length : normalizedOffset - offset)
+ );
+ }
+ offset += length;
+ }
+ return text.length;
+}
+
+/**
+ * Builds a selector when a client knows the selected text but not where it sits,
+ * such as native selection menus that report offsets per rendered block.
+ * A unique occurrence gets full position and context. Otherwise the selector keeps
+ * only the quote, so later lookups succeed only where the quote is unambiguous.
+ */
+export function locateAssistantTextSelector(
+ text: string,
+ selectedText: string,
+): AssistantTextSelector | null {
+ const quote = selectedText.trim();
+ if (quote.length === 0) return null;
+ const normalized = normalizeWhitespace(text);
+ const normalizedQuote = normalizeWhitespace(quote);
+ const start = normalized.indexOf(normalizedQuote);
+ if (start !== -1 && normalized.indexOf(normalizedQuote, start + 1) === -1) {
+ const selector = createAssistantTextSelector(
+ text,
+ rawTextOffset(text, start),
+ rawTextOffset(text, start + normalizedQuote.length),
+ );
+ if (selector !== null) return { ...selector, text: quote };
+ }
+ return { text: quote, start: 0, end: normalizedQuote.length, prefix: "", suffix: "" };
+}