Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
81 changes: 81 additions & 0 deletions apps/mobile/src/components/AssistantCitationSheet.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AppSheet
title="Assistant quote"
onClose={() => {
if (!removedRef.current) props.onClose(comment);
}}
>
<ScrollView
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 12,
gap: 16,
paddingBottom: Math.max(20, insets.bottom),
}}
>
{props.onRemove ? (
<TextInput
autoFocus={props.autoFocus}
multiline
scrollEnabled={false}
textAlignVertical="top"
value={comment}
onChangeText={setComment}
maxLength={ASSISTANT_CITATION_MAX_COMMENT_LENGTH}
placeholder="Add an optional comment..."
accessibilityLabel="Comment on selected text"
className="min-h-24"
/>
) : comment ? (
<View className="gap-1">
<Text className="text-xs text-foreground-muted">Comment</Text>
<Text selectable className="text-base text-foreground">
{comment}
</Text>
</View>
) : null}
<View className="border-l-2 border-border pl-3">
<Text selectable className="text-base text-foreground-muted">
{props.citation.text}
</Text>
</View>
{props.onRemove ? (
<Pressable
accessibilityRole="button"
onPress={() => {
removedRef.current = true;
props.onRemove?.();
}}
className="rounded-xl bg-subtle p-4"
>
<Text className="text-foreground">Remove from draft</Text>
</Pressable>
) : null}
</ScrollView>
</AppSheet>
);
}
123 changes: 102 additions & 21 deletions apps/mobile/src/components/ComposerEditor.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand All @@ -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<NativeComposerEditorProps, "ref"> & {
readonly ref?: Ref<ComposerEditorHandle>;
readonly draftKey?: string | null;
readonly environmentId?: EnvironmentId;
readonly onOpenMention?: (path: string) => void;
Expand All @@ -48,6 +68,7 @@ export type ComposerEditorProps = NativeComposerEditorProps & {
};

export function ComposerEditor({
ref,
draftKey,
environmentId,
onOpenMention,
Expand All @@ -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<NativeComposerEditorHandle>(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<AbortController | null>(null);
const [importing, setImporting] = useState(false);
Expand Down Expand Up @@ -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 (
<>
<NativeComposerEditor
{...props}
ref={nativeRef}
enterBehavior={props.enterBehavior ?? preferredEnterBehavior}
onChangeText={changeText}
readOnly={props.readOnly || importing}
Expand All @@ -190,7 +244,7 @@ export function ComposerEditor({
onOpenAttachment(document);
return;
}
setSelected(selection);
setSelected({ ...selection, draftKey: currentDraftKey });
}}
onSelectionChange={(selection) => {
if (draftKey)
Expand All @@ -205,6 +259,42 @@ export function ComposerEditor({
{importing ? (
<Text className="py-2 text-xs text-foreground-muted">Copying context…</Text>
) : null}
{selected && selectedCitation ? (
<AssistantCitationSheet
key={`${selected.start}:${selected.source}`}
citation={selectedCitation}
initialComment={selected.comment}
autoFocus={selected.isNewCitation === true}
onClose={(comment) => {
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) ? (
<ComposerContextSheet
label={
Expand Down Expand Up @@ -244,25 +334,16 @@ export function ComposerEditor({
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 });
}
editable
? () => {
replaceSelected("");
setSelected(null);
}
: undefined
}
/>
) : null}
</>
);
}
export type {
ComposerEditorHandle,
ComposerEditorSelection,
ComposerTextPaste,
} from "../native/T3ComposerEditor";
export type { ComposerEditorSelection, ComposerTextPaste } from "../native/T3ComposerEditor";
Loading
Loading