Skip to content

Commit dd4e7fc

Browse files
committed
feat: collapse large pasted text into expandable blocks
- Keep pasted content intact for provider submissions - Show compact expandable pasted-text blocks across web and mobile
1 parent fa86904 commit dd4e7fc

24 files changed

Lines changed: 766 additions & 114 deletions

apps/mobile/src/features/threads/ThreadFeed.tsx

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { type LegendListRef } from "@legendapp/list/react-native";
44
import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
55
import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
66
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
7+
import { materializePastedText, splitPastedTextSegments } from "@t3tools/shared/pastedText";
78
import { SymbolView } from "../../components/AppSymbol";
89
import { HeaderHeightContext } from "@react-navigation/elements";
910
import { useNavigation } from "@react-navigation/native";
@@ -924,7 +925,7 @@ function renderFeedEntry(
924925
{message.text.trim().length > 0 ? (
925926
<CopyTextButton
926927
accessibilityLabel="Copy message"
927-
text={message.text}
928+
text={materializePastedText(message.text)}
928929
tintColor={iconSubtleColor}
929930
buttonSize={28}
930931
iconSize={13}
@@ -1040,6 +1041,33 @@ function UserMessageContent(props: {
10401041
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
10411042
readonly onLinkPress: (href: string) => void;
10421043
}) {
1044+
const pastedTextSegments = splitPastedTextSegments(props.text);
1045+
if (pastedTextSegments.some((segment) => segment.type === "pasted-text")) {
1046+
let pastedTextOrdinal = 0;
1047+
let segmentOffset = 0;
1048+
return (
1049+
<View className="w-full gap-2">
1050+
{pastedTextSegments.map((segment) => {
1051+
const currentOffset = segmentOffset;
1052+
segmentOffset += segment.type === "text" ? segment.text.length : segment.source.length;
1053+
if (segment.type === "pasted-text") {
1054+
pastedTextOrdinal += 1;
1055+
return (
1056+
<MobilePastedTextBlock
1057+
key={`pasted-text:${currentOffset}`}
1058+
text={segment.text}
1059+
ordinal={pastedTextOrdinal}
1060+
/>
1061+
);
1062+
}
1063+
return segment.text.trim().length > 0 ? (
1064+
<UserMessageContent key={`text:${currentOffset}`} {...props} text={segment.text} />
1065+
) : null;
1066+
})}
1067+
</View>
1068+
);
1069+
}
1070+
10431071
const segments = parseReviewCommentMessageSegments(props.text);
10441072
const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");
10451073
if (!hasReviewComment) {
@@ -1109,6 +1137,41 @@ function UserMessageContent(props: {
11091137
);
11101138
}
11111139

1140+
const MobilePastedTextBlock = memo(function MobilePastedTextBlock(props: {
1141+
readonly text: string;
1142+
readonly ordinal: number;
1143+
}) {
1144+
const [expanded, setExpanded] = useState(false);
1145+
const iconColor = useThemeColor("--color-icon-muted");
1146+
1147+
return (
1148+
<View className="max-w-full items-start gap-1">
1149+
<Pressable
1150+
accessibilityRole="button"
1151+
accessibilityState={{ expanded }}
1152+
accessibilityLabel={`${expanded ? "Hide" : "Show"} pasted text ${props.ordinal}`}
1153+
className="flex-row items-center gap-1.5 rounded-lg border border-border bg-foreground/5 px-2 py-1"
1154+
onPress={() => setExpanded((value) => !value)}
1155+
>
1156+
<Text className="font-t3-medium text-xs text-foreground">Pasted text #{props.ordinal}</Text>
1157+
<SymbolView
1158+
name={expanded ? "chevron.down" : "chevron.right"}
1159+
size={12}
1160+
tintColor={iconColor}
1161+
type="monochrome"
1162+
/>
1163+
</Pressable>
1164+
{expanded ? (
1165+
<ScrollView className="max-h-72 max-w-full rounded-xl border border-border bg-background/50 p-2.5">
1166+
<Text selectable className="font-mono text-xs leading-5 text-foreground">
1167+
{props.text}
1168+
</Text>
1169+
</ScrollView>
1170+
) : null}
1171+
</View>
1172+
);
1173+
});
1174+
11121175
const ReviewCommentCard = memo(function ReviewCommentCard(props: {
11131176
readonly comment: ReviewInlineComment;
11141177
readonly colors: ReviewCommentColors;

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type VcsRef,
1313
} from "@t3tools/contracts";
1414
import { createModelSelection } from "@t3tools/shared/model";
15+
import { serializePastedText } from "@t3tools/shared/pastedText";
1516
import {
1617
ApprovalRequestId,
1718
CheckpointRef,
@@ -713,6 +714,7 @@ describe("ProviderCommandReactor", () => {
713714
it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => {
714715
const harness = await createHarness();
715716
const now = "2026-01-01T00:00:00.000Z";
717+
const storedMessageText = `hello ${serializePastedText("exact pasted body")} reactor`;
716718

717719
await Effect.runPromise(
718720
harness.engine.dispatch({
@@ -722,7 +724,7 @@ describe("ProviderCommandReactor", () => {
722724
message: {
723725
messageId: asMessageId("user-message-1"),
724726
role: "user",
725-
text: "hello reactor",
727+
text: storedMessageText,
726728
attachments: [],
727729
},
728730
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -742,12 +744,16 @@ describe("ProviderCommandReactor", () => {
742744
},
743745
runtimeMode: "approval-required",
744746
});
747+
expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
748+
input: "hello exact pasted body reactor",
749+
});
745750

746751
const readModel = await harness.readModel();
747752
const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
748753
expect(thread?.session?.threadId).toBe("thread-1");
749754
expect(thread?.session?.status).toBe("starting");
750755
expect(thread?.session?.runtimeMode).toBe("approval-required");
756+
expect(thread?.messages[0]?.text).toBe(storedMessageText);
751757
});
752758

753759
it("recreates a pruned managed worktree before resuming a provider turn", async () => {

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import * as Option from "effect/Option";
3434
import * as Schema from "effect/Schema";
3535
import * as Stream from "effect/Stream";
3636
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
37+
import { materializePastedText } from "@t3tools/shared/pastedText";
3738

3839
import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
3940
import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";
@@ -171,7 +172,9 @@ function formatThreadTitleSection(message: ThreadTitleMessage): string | undefin
171172
if (message.role === "system") {
172173
return undefined;
173174
}
174-
const text = message.text.trim();
175+
const text = (
176+
message.role === "user" ? materializePastedText(message.text) : message.text
177+
).trim();
175178
const attachmentSummary = (message.attachments ?? [])
176179
.map((attachment) => attachment.name)
177180
.join(", ");
@@ -1213,6 +1216,7 @@ const make = Effect.gen(function* () {
12131216
});
12141217
return;
12151218
}
1219+
const messageText = materializePastedText(message.text);
12161220

12171221
// First gate: when retract won before provider startup, persist the
12181222
// send-cancelled handoff and do not create a provider session. WO3 owns
@@ -1236,7 +1240,7 @@ const make = Effect.gen(function* () {
12361240
projects: project ? [project] : [],
12371241
}) ?? process.cwd();
12381242
const generationInput = {
1239-
messageText: message.text,
1243+
messageText,
12401244
...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
12411245
...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}),
12421246
};
@@ -1296,7 +1300,7 @@ const make = Effect.gen(function* () {
12961300

12971301
const sendTurnRequest = yield* buildSendTurnRequestForThread({
12981302
threadId: event.payload.threadId,
1299-
messageText: message.text,
1303+
messageText,
13001304
...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
13011305
...(event.payload.modelSelection !== undefined
13021306
? { modelSelection: event.payload.modelSelection }

apps/web/src/components/ChatView.logic.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type TerminalContextDraft,
2222
} from "../lib/terminalContext";
2323
import type { DraftThreadEnvMode } from "../composerDraftStore";
24+
import { materializePastedText } from "@t3tools/shared/pastedText";
2425

2526
export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
2627
export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
@@ -289,7 +290,9 @@ export function deriveComposerSendState(options: {
289290
expiredTerminalContextCount: number;
290291
hasSendableContent: boolean;
291292
} {
292-
const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim();
293+
const trimmedPrompt = stripInlineTerminalContextPlaceholders(
294+
materializePastedText(options.prompt),
295+
).trim();
293296
const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts);
294297
const expiredTerminalContextCount =
295298
options.terminalContexts.length - sendableTerminalContexts.length;

apps/web/src/components/ChatView.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
5050
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
5151
import { truncate } from "@t3tools/shared/String";
5252
import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
53+
import { materializePastedText } from "@t3tools/shared/pastedText";
5354
import { Debouncer } from "@tanstack/react-pacer";
5455
import { useAtomValue } from "@effect/atom-react";
5556
import {
@@ -214,6 +215,7 @@ import {
214215
import {
215216
appendTerminalContextsToPrompt,
216217
formatTerminalContextLabel,
218+
stripInlineTerminalContextPlaceholders,
217219
type TerminalContextDraft,
218220
type TerminalContextSelection,
219221
} from "../lib/terminalContext";
@@ -5201,7 +5203,7 @@ function ChatViewContent(props: ChatViewProps) {
52015203
});
52025204
if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) {
52035205
const followUp = resolvePlanFollowUpSubmission({
5204-
draftText: trimmed,
5206+
draftText: trimmed.length > 0 ? stripInlineTerminalContextPlaceholders(promptForSend) : "",
52055207
planMarkdown: activeProposedPlan.planMarkdown,
52065208
});
52075209
const outgoingFollowUpText = formatOutgoingPrompt({
@@ -5211,7 +5213,10 @@ function ChatViewContent(props: ChatViewProps) {
52115213
effort: ctxSelectedPromptEffort,
52125214
text: followUp.text.trim(),
52135215
});
5214-
if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) {
5216+
if (
5217+
composerRef.current?.validateProviderInput(materializePastedText(outgoingFollowUpText)) ===
5218+
false
5219+
) {
52155220
return;
52165221
}
52175222
promptRef.current = "";
@@ -5307,7 +5312,10 @@ function ChatViewContent(props: ChatViewProps) {
53075312
effort: ctxSelectedPromptEffort,
53085313
text: messageTextForSend || IMAGE_ONLY_MESSAGE_PLACEHOLDER,
53095314
});
5310-
if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) {
5315+
if (
5316+
composerRef.current?.validateProviderInput(materializePastedText(outgoingMessageText)) ===
5317+
false
5318+
) {
53115319
return;
53125320
}
53135321

apps/web/src/components/ComposerPromptEditor.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ describe("registerComposerInlineTokenPaste", () => {
5959
);
6060
registerComposerInlineTokenPaste(editor, {
6161
createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
62+
createPastedTextNode: (text) => $createTextNode(`<paste:${text}>`),
6263
getExpandedAbsoluteOffsetForPoint: () => 0,
6364
});
6465
editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);
@@ -104,6 +105,7 @@ describe("registerComposerInlineTokenPaste", () => {
104105
);
105106
registerComposerInlineTokenPaste(editor, {
106107
createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
108+
createPastedTextNode: (text) => $createTextNode(`<paste:${text}>`),
107109
getExpandedAbsoluteOffsetForPoint: () => 0,
108110
});
109111
editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);
@@ -138,6 +140,7 @@ describe("registerComposerInlineTokenPaste", () => {
138140
);
139141
registerComposerInlineTokenPaste(editor, {
140142
createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
143+
createPastedTextNode: (text) => $createTextNode(`<paste:${text}>`),
141144
getExpandedAbsoluteOffsetForPoint: () => 0,
142145
});
143146
editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);
@@ -158,4 +161,37 @@ describe("registerComposerInlineTokenPaste", () => {
158161
"<mention:@scope/pkg/sub> ",
159162
);
160163
});
164+
165+
it("turns a large plain-text paste into one atomic presentation node", () => {
166+
vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
167+
const editor = createEditor();
168+
const pastedText = "line of pasted text\n".repeat(20);
169+
const plainTextFallback = vi.fn(() => true);
170+
171+
editor.update(
172+
() => {
173+
const paragraph = $createParagraphNode();
174+
$getRoot().append(paragraph);
175+
paragraph.selectEnd();
176+
},
177+
{ discrete: true },
178+
);
179+
registerComposerInlineTokenPaste(editor, {
180+
createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
181+
createPastedTextNode: (text) => $createTextNode(`<paste:${text}>`),
182+
getExpandedAbsoluteOffsetForPoint: () => 0,
183+
});
184+
editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);
185+
186+
const event = new TestClipboardEvent(pastedText);
187+
editor.update(() => editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent), {
188+
discrete: true,
189+
});
190+
191+
expect(plainTextFallback).not.toHaveBeenCalled();
192+
expect(event.defaultPrevented).toBe(true);
193+
expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
194+
`<paste:${pastedText}>`,
195+
);
196+
});
161197
});

0 commit comments

Comments
 (0)