Skip to content
126 changes: 126 additions & 0 deletions apps/mobile/src/components/AssistantCitationSheet.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Sheet title="Quote" onClose={close} dismissDisabled={commentTooLong}>
<ScrollView
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 12,
gap: 24,
paddingBottom: Math.max(20, insets.bottom),
}}
>
<View className="flex-row gap-3 rounded-2xl bg-grouped-card p-4">
<View className="w-1 rounded-full bg-primary" />
<Text selectable textRole="body" className="min-w-0 flex-1 text-foreground-muted">
{contextBefore(citation.prefix)}
<Text className="font-t3-medium text-foreground">{citation.text}</Text>
{contextAfter(citation.suffix)}
</Text>
</View>
{props.onCommentChange ? (
<ListSection title="Comment">
<TextInput
accessibilityLabel="Comment on quote"
placeholder="Add an optional comment…"
placeholderTextColorClassName="accent-foreground-muted"
multiline
maxLength={ASSISTANT_CITATION_MAX_COMMENT_LENGTH}
value={comment}
onChangeText={setComment}
className="min-h-24 px-4 py-3 text-base text-foreground"
style={{ textAlignVertical: "top" }}
/>
{commentTooLong ? (
<Text textRole="body" className="px-4 pb-3 text-foreground-muted">
This comment makes the message too long to send. Shorten it to save and close.
</Text>
) : null}
</ListSection>
) : citation.comment ? (
<ListSection title="Comment">
<Text selectable textRole="body" className="px-4 py-3 text-foreground">
{citation.comment}
</Text>
</ListSection>
) : null}
<ListSection>
<ListActionRow
icon="arrow.up.right"
label="Go to message"
onPress={() => {
if (commentTooLong) return;
close();
requestThreadMessageJump(scopedThreadKey(citation), citation.messageId, {
text: citation.text,
occurrence: citation.occurrence,
});
openLinkedThread(citation);
}}
/>
<ListActionRow
icon="doc.on.doc"
label="Copy quote"
onPress={() => copyTextWithHaptic(citation.text)}
/>
{props.onRemove ? (
<ListActionRow
icon="trash"
label="Remove from draft"
tone="danger"
onPress={props.onRemove}
/>
) : null}
</ListSection>
</ScrollView>
</Sheet>
);
}
1 change: 1 addition & 0 deletions apps/mobile/src/components/ComposerEditor.ios.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => [] }));
Expand Down
77 changes: 64 additions & 13 deletions apps/mobile/src/components/ComposerEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -169,13 +176,49 @@ 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)
: undefined;
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. */}
Expand Down Expand Up @@ -226,6 +269,26 @@ export function ComposerEditor({
{importing ? (
<Text className="py-2 text-xs text-foreground-muted">Copying context…</Text>
) : null}
{selectedCitation ? (
<AssistantCitationSheet
citation={selectedCitation}
onClose={() => 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) ? (
<ComposerContextSheet
label={
Expand Down Expand Up @@ -264,19 +327,7 @@ export function ComposerEditor({
records={draft.context?.records}
attachments={draft.attachments}
onClose={() => 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}
</>
Expand Down
45 changes: 44 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1518,6 +1560,7 @@ function ThreadDetailBody(
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
onIsAtEndChange={setIsAtEnd}
skills={selectedProviderSkills}
onCiteAssistantText={canCiteAssistantText ? handleCiteAssistantText : undefined}
onUseArtifactTemplate={handleUseArtifactTemplate}
/>
</ColumnWidth>
Expand Down
Loading
Loading