From 3486451525ecaf3a8379aa6d08c885c7cc5335e3 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 12 Sep 2026 17:33:27 -0300 Subject: [PATCH 01/17] fix(chat): keep user input outside collapsed work (#11363) --- apps/mobile/src/lib/threadActivity.test.ts | 57 ++++++++++++- apps/mobile/src/lib/threadActivity.ts | 18 +++-- .../chat/MessagesTimeline.logic.test.ts | 79 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 12 ++- .../components/chat/MessagesTimeline.test.tsx | 2 - 5 files changed, 157 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index fb85ce1e1cc3..4e301288c94f 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3360,7 +3360,8 @@ it("accepts ready attachment-only answers while preserving selected options", () ).toBeNull(); }); -it("makes attachment-only question answers expandable in the mobile feed", () => { +it("keeps attachment-only question answers expandable outside mobile work groups and turn folds", () => { + const turnId = TurnId.make("turn-answer"); const answer = { requestId: ApprovalRequestId.make("question-request"), answers: { q: "" }, @@ -3381,17 +3382,46 @@ it("makes attachment-only question answers expandable in the mobile feed", () => id: ThreadId.make("thread-answer"), projectId: ProjectId.make("project-answer"), title: "Answer history", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-09-08T00:00:00.000Z", + startedAt: "2026-09-08T00:00:00.000Z", + completedAt: "2026-09-08T00:00:04.000Z", + assistantMessageId: null, + }, activities: [ + makeActivity({ + id: EventId.make("tool-before-answer"), + createdAt: "2026-09-08T00:00:01.000Z", + kind: "tool.completed", + tone: "tool", + summary: "Read files", + turnId, + payload: { itemType: "command_execution", status: "completed" }, + }), makeActivity({ id: EventId.make("answer-submitted"), - createdAt: "2026-09-08T00:00:00.000Z", + createdAt: "2026-09-08T00:00:02.000Z", kind: "user-input.answer-submitted", summary: "Answered questions", + turnId, payload: answer, }), + makeActivity({ + id: EventId.make("tool-after-answer"), + createdAt: "2026-09-08T00:00:03.000Z", + kind: "tool.completed", + tone: "tool", + summary: "Read files", + turnId, + payload: { itemType: "command_execution", status: "completed" }, + }), ], }); - const [group] = buildThreadFeed(thread); + const feed = buildThreadFeed(thread); + expect(feed).toHaveLength(3); + const group = feed[1]; expect(group?.type).toBe("activity-group"); if (group?.type !== "activity-group") return; expect(group.activities[0]).toMatchObject({ @@ -3399,4 +3429,25 @@ it("makes attachment-only question answers expandable in the mobile feed", () => workEntry: { questionAnswer: answer }, }); expect(group.activities[0]?.getFullDetail()).toBeNull(); + const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + expect(collapsed.map((entry) => entry.type)).toEqual(["turn-fold", "activity-group"]); + expect(collapsed[1]).toBe(group); + const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); + expect(expanded.map((entry) => entry.type)).toEqual([ + "turn-fold", + "work-toggle", + "activity-group", + "work-toggle", + ]); + expect(expanded[2]).toBe(group); + const running = deriveThreadFeedPresentation( + feed, + { ...thread.latestTurn!, state: "running", completedAt: null }, + new Set(), + new Set(), + "2026-09-08T00:00:00.000Z", + ); + expect(running[0]?.type).toBe("work-toggle"); + expect(running[1]).toBe(group); + expect(running[2]?.type).toBe("work-toggle"); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index b7d5018edd98..f1550042eae8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -264,6 +264,10 @@ export function isContextCompactionActivityGroup( ); } +function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean { + return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined); +} + function normalizeDraftAnswer(value: string | undefined): string | null { if (typeof value !== "string") { return null; @@ -1556,13 +1560,15 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th continue; } - const isCompaction = entry.activity.workEntry.sourceActivityKind === "context-compaction"; - if (isCompaction || firstActivityEntry?.turnId !== entry.turnId) { + const isStandalone = + entry.activity.workEntry.sourceActivityKind === "context-compaction" || + entry.activity.workEntry.questionAnswer !== undefined; + if (isStandalone || firstActivityEntry?.turnId !== entry.turnId) { flushGroup(); } firstActivityEntry ??= entry; openGroupActivities.push(entry.activity); - if (isCompaction) { + if (isStandalone) { flushGroup(); } } @@ -1668,7 +1674,9 @@ function deriveThreadFeedTurnFolds( entries .filter( (entry) => - entry.id !== firstAssistantMessageId && entry.id !== terminalAssistantMessageId, + entry.id !== firstAssistantMessageId && + entry.id !== terminalAssistantMessageId && + !(entry.type === "activity-group" && isUserInputActivityGroup(entry)), ) .map((entry) => entry.id), ); @@ -1848,7 +1856,7 @@ function appendPresentedFeedEntry( result.push(entry); return; } - if (isContextCompactionActivityGroup(entry)) { + if (isContextCompactionActivityGroup(entry) || isUserInputActivityGroup(entry)) { result.push(entry); return; } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 0c5e53261b4f..c677e6fff65e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { + ApprovalRequestId, CheckpointRef, EnvironmentId, EventId, @@ -2622,6 +2623,84 @@ describe("deriveMessagesTimelineRows", () => { }); }); + it("keeps user input in its own row through tool grouping and turn folding", () => { + const turnId = TurnId.make("answer-turn"); + const time = (second: number) => new Date(Date.UTC(2026, 8, 8, 0, 0, second)).toISOString(); + const answer: WorkLogEntry = { + id: "answer-submitted", + createdAt: time(3), + turnId, + tone: "info", + label: "User input submitted", + sourceActivityKind: "user-input.answer-submitted", + questionAnswer: { + requestId: ApprovalRequestId.make("answer-request"), + answers: { scope: "Use the private repository" }, + questionTextById: { scope: "Which repository?" }, + attachmentsByQuestionId: {}, + }, + }; + const tools: WorkLogEntry[] = [1, 2, 4, 5].map((second) => ({ + id: `tool-${second}`, + createdAt: time(second), + turnId, + tone: "tool", + label: "Ran command", + command: "git status", + toolCallId: `call-${second}`, + toolLifecycleStatus: "completed", + sourceActivityKind: "tool.completed", + })); + const input = { + timelineEntries: deriveTimelineEntries([], [], [...tools, answer]), + latestTurn: { turnId, state: "completed", startedAt: time(0), completedAt: time(6) }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + } satisfies Parameters[0]; + const collapsed = deriveMessagesTimelineRows(input); + expect(collapsed.map((row) => row.kind)).toEqual(["turn-fold", "work"]); + const expanded = deriveMessagesTimelineRows({ ...input, expandedTurnIds: new Set([turnId]) }); + expect(expanded.map((row) => row.kind)).toEqual([ + "turn-fold", + "work-toggle", + "work", + "work-toggle", + ]); + const expandedGroups = deriveMessagesTimelineRows({ + ...input, + expandedTurnIds: new Set([turnId]), + expandedWorkGroupIds: new Set( + expanded.flatMap((row) => (row.kind === "work-toggle" ? [row.groupId] : [])), + ), + }); + expect( + expandedGroups.flatMap((row) => + row.kind === "work" && row.isExpandedToolGroup ? [row.groupedEntries] : [], + ), + ).toEqual([tools.slice(0, 2), tools.slice(2)]); + const active = deriveMessagesTimelineRows({ + ...input, + latestTurn: { ...input.latestTurn, state: "running", completedAt: null }, + runningTurnId: turnId, + isWorking: true, + activeTurnStartedAt: time(0), + }); + for (const rows of [collapsed, expanded, expandedGroups, active]) { + const answerRows = rows.filter( + (row) => + (row.kind === "work" || row.kind === "work-live") && row.groupedEntries.includes(answer), + ); + expect(answerRows).toMatchObject([ + { kind: "work", groupedEntries: [answer], isExpandedToolGroup: false }, + ]); + } + expect(active.find((row) => row.kind === "work-live")).toMatchObject({ + groupedEntries: tools.slice(2), + }); + }); + it("deduplicates integration sources and uses the first source icon for the group", () => { const chromeSource = { key: "browser-use:chrome", diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 866cbd0201b5..fc3ffdb98a90 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -657,6 +657,10 @@ function deriveTurnFolds(input: { if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) { continue; } + // User input stays visible after the surrounding work settles. + if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) { + continue; + } // Agent-spawn CTA rows never fold: workflows outlive their launching // turn (dynamic spawns, background execution), and folding the CTA // when the turn settles makes a still-running fleet invisible. @@ -919,6 +923,7 @@ export function deriveMessagesTimelineRows(input: { !entryBelongsToActiveTurn(entry, index) || entry.kind !== "work" || entry.entry.agentSpawn !== undefined || + entry.entry.questionAnswer !== undefined || entry.entry.sourceActivityKind === "context-compaction" || entry.entry.tone === "error" ) { @@ -1043,7 +1048,11 @@ export function deriveMessagesTimelineRows(input: { } if (timelineEntry.kind === "work") { - if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") { + if ( + timelineEntry.entry.agentSpawn !== undefined || + timelineEntry.entry.questionAnswer !== undefined || + timelineEntry.entry.tone === "error" + ) { nextRows.push({ kind: "work", id: timelineEntry.id, @@ -1061,6 +1070,7 @@ export function deriveMessagesTimelineRows(input: { !nextEntry || nextEntry.kind !== "work" || nextEntry.entry.agentSpawn !== undefined || + nextEntry.entry.questionAnswer !== undefined || nextEntry.entry.sourceActivityKind === "context-compaction" || nextEntry.entry.tone === "error" || activeWorkEntryIds.has(nextEntry.id) || diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index d9240b21a133..6991f4432594 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -348,8 +348,6 @@ describe("MessagesTimeline", () => { />, ); }); - const toggle = renderer!.root.findByProps({ "aria-expanded": false }); - await act(() => toggle.props.onClick()); const questionToggle = renderer!.root.find( (node) => node.props["aria-label"]?.startsWith("Question answer submitted:") && From cfeaca41ae27bdf2c203158d378c87c7308fea2a Mon Sep 17 00:00:00 2001 From: Simone Date: Sat, 12 Sep 2026 22:45:55 +0200 Subject: [PATCH 02/17] fix(web): preserve preview focus on window return (#11444) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- apps/web/src/components/ChatView.logic.test.ts | 4 ++++ apps/web/src/components/ChatView.logic.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 2b9a18cb90fb..be279f220e7e 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -2180,6 +2180,10 @@ describe("shouldRefocusComposerOnWindowFocus", () => { expect(shouldRefocusComposerOnWindowFocus(element("DIV", { role: "textbox" }))).toBe(false); }); + it.each(["IFRAME", "WEBVIEW"])("leaves a focused %s preview alone", (tagName) => { + expect(shouldRefocusComposerOnWindowFocus(element(tagName))).toBe(false); + }); + it("leaves a focused terminal alone in the drawer and the right panel", () => { expect( shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "data-terminal-owner" })), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 2707050070e9..ed1422a71109 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1350,6 +1350,8 @@ export function shouldRefocusComposerOnWindowFocus( activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.tagName === "SELECT" || + activeElement.tagName === "IFRAME" || + activeElement.tagName === "WEBVIEW" || activeElement.isContentEditable === true || activeElement.getAttribute("role") === "textbox" ) { From 03e135577cbf861c2948981601c070dab038ea11 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 12 Sep 2026 18:38:07 -0300 Subject: [PATCH 03/17] fix(web): complete thread status icons and keep input threads prominent (#11461) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/Sidebar.logic.test.ts | 12 +++++++ apps/web/src/components/Sidebar.logic.ts | 4 +-- apps/web/src/components/Sidebar.tsx | 36 ++++++++++--------- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index f9a62c40a511..e06176dfc48e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -395,6 +395,18 @@ describe("shouldRecedeSidebarThread", () => { expect(shouldRecedeSidebarThread({ ...input, isActive: true })).toBe(false); expect(shouldRecedeSidebarThread({ ...input, isSelected: true })).toBe(false); }); + + it.each([false, true])("keeps input-required threads prominent with unread=%s", (isUnread) => { + expect( + shouldRecedeSidebarThread({ + status: "input", + isUnread, + isWoke: false, + isActive: false, + isSelected: false, + }), + ).toBe(false); + }); }); describe("createThreadJumpHintVisibilityController", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 50650ed389dc..abb67e65a24e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -803,9 +803,9 @@ export function shouldRecedeSidebarThread(input: { isActive: boolean; isSelected: boolean; }): boolean { - if (input.isActive || input.isSelected) return false; + if (input.isActive || input.isSelected || input.status === "input") return false; if (input.status === "working" || input.status === "monitoring") return true; - if (input.status === "ready" || input.status === "approval" || input.status === "input") { + if (input.status === "ready" || input.status === "approval") { return !input.isUnread && !input.isWoke; } return false; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 445ae533c323..792aa3815fcf 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -45,12 +45,15 @@ import { CircleCheckIcon, CircleDashedIcon, ClockIcon, + EyeIcon, FolderIcon, GitBranchIcon, + MessageCircleQuestionIcon, PinIcon, PinOffIcon, PlusIcon, SettingsIcon, + ShieldQuestionIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -1088,8 +1091,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // switching sidebars must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarThreadStatus(thread); - const isInFlight = - status === "working" || status === "monitoring" || status === "approval" || status === "input"; // A woken thread reappears at its original position (the sort is // deliberately static), so the pill has to carry the weight. Snoozing is // an explicit act, so the pill clears only when the user re-engages: @@ -1123,35 +1124,32 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { icon: "working" as const, // No shimmer: a label that animates forever is noise in a sidebar // full of them (and repaints every vsync on high-refresh displays). - // Working is a background state, so it rests at the dim end of what - // the old pulse cycled through; only the thread you have open gets - // the label at full strength. - className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"), + className: "text-sky-600 dark:text-sky-400", } : status === "monitoring" ? { // Monitoring is calm background presence, not active progress // (monitoring-pill D6), so it keeps the label at full strength. label: "Monitoring", - icon: null, - className: "text-sky-600 dark:text-sky-400", + icon: "monitoring" as const, + className: "text-foreground dark:text-white", } : status === "approval" ? { label: "Approval", - icon: null, + icon: "approval" as const, className: "text-amber-700 dark:text-amber-300", } : status === "input" ? { label: "Input", - icon: null, + icon: "input" as const, className: "text-indigo-600 dark:text-indigo-300", } : status === "failed" ? { label: "Failed", - icon: null, + icon: "failed" as const, className: "text-red-700 dark:text-red-300", } : isWoke @@ -1396,10 +1394,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { : shouldRecede ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", - isInFlight && - !props.isActive && - !isSelected && - "opacity-70 transition-opacity hover:opacity-100", isFileDragOver && "ring-1 ring-inset ring-primary/70", // The hover tint must not clobber an active/selected row's own surface. isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover", @@ -1462,7 +1456,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { "truncate", shouldRecede ? "text-secondary-label" - : isUnread || isWoke + : isUnread || isWoke || status === "input" ? "text-foreground" : status === "failed" ? "text-foreground/95" @@ -1472,7 +1466,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground", shouldRecede ? "text-secondary-label/70" - : props.isActive || isWoke + : props.isActive || isWoke || status === "input" ? "text-foreground" : isUnread ? "text-muted-foreground" @@ -1814,6 +1808,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { > {topStatus.icon === "working" ? ( + ) : topStatus.icon === "input" ? ( + + ) : topStatus.icon === "approval" ? ( + + ) : topStatus.icon === "failed" ? ( + + ) : topStatus.icon === "monitoring" ? ( + ) : topStatus.icon === "done" ? ( ) : null} From 75d8b132cd0718d8cd703c97d02b143891d80022 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 12 Sep 2026 19:11:47 -0300 Subject: [PATCH 04/17] feat(web): tint image chips with their average color (#11468) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/contextChipParts.tsx | 53 +++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/contextChipParts.tsx b/apps/web/src/components/contextChipParts.tsx index 55d66063b13e..b3202675de16 100644 --- a/apps/web/src/components/contextChipParts.tsx +++ b/apps/web/src/components/contextChipParts.tsx @@ -1,6 +1,12 @@ import type { PullRequestContextMetadata } from "@t3tools/contracts"; import { CircleDashedIcon, FilmIcon, GitPullRequestIcon, ImageIcon } from "lucide-react"; -import type { ComponentProps, MouseEvent, ReactNode } from "react"; +import { + useState, + type ComponentProps, + type CSSProperties, + type MouseEvent, + type ReactNode, +} from "react"; import { cn } from "~/lib/utils"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; @@ -139,6 +145,34 @@ export function PullRequestChip(props: { ); } +/** Sample the loaded thumbnail once; transparent pixels should not darken its accent. */ +function averageImageColor(image: HTMLImageElement): string | undefined { + try { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 16; + const context = canvas.getContext("2d"); + if (!context) return; + context.drawImage(image, 0, 0, 16, 16); + const { data } = context.getImageData(0, 0, 16, 16); + let red = 0; + let green = 0; + let blue = 0; + let alpha = 0; + for (let index = 0; index < data.length; index += 4) { + const weight = data[index + 3]!; + red += data[index]! * weight; + green += data[index + 1]! * weight; + blue += data[index + 2]! * weight; + alpha += weight; + } + if (alpha === 0) return; + return `rgb(${Math.round(red / alpha)} ${Math.round(green / alpha)} ${Math.round(blue / alpha)})`; + } catch { + // Cross-origin or unavailable pixels keep the default image tone and preview action. + return; + } +} + export function ImageChipButton({ name, previewUrl, @@ -146,6 +180,7 @@ export function ImageChipButton({ labelClassName, size, suffix, + style, ...props }: ComponentProps<"button"> & { name: string; @@ -155,6 +190,9 @@ export function ImageChipButton({ size: string; suffix?: string | null; }) { + const [sample, setSample] = useState<{ url: string; color: string | undefined }>(); + const [corsFailedUrl, setCorsFailedUrl] = useState(); + const accent = sample?.url === previewUrl ? sample?.color : undefined; return ( )} -
+
+ {expanded ? ( +
+ {agents.map((agent) => ( + + ))} + +
+ ) : null} +
+ ); +}); - const dotClass = { - working: "bg-info", - failed: "bg-destructive", - completed: "bg-success", - inactive: "bg-muted-foreground/50", - }[summary.tone]; - const status = - live && livePhase ? `${livePhase.title} · ${livePhase.activeCount} working` : summary.status; +const AGENT_MEMBER_STATUS_LABEL: Record = { + pending: "Working", + running: "Working", + waiting: "Working", + idle: "Idle", + completed: "Completed", + failed: "Failed", + cancelled: "Stopped", + interrupted: "Stopped", +}; + +function AgentSpawnMemberRow({ + agent, + onToggleEntry, +}: { + agent: RuntimeSubagent; + onToggleEntry?: ((collapsed: boolean) => void) | undefined; +}) { + const [open, setOpen] = useState(false); + const activeStatus = isActiveSubagentStatus(agent.status); + const activity = activeStatus + ? (agent.progress ?? (agent.lastToolName ? `▸ ${agent.lastToolName}` : null)) + : (agent.error ?? agent.result ?? agent.progress ?? null); + const durationMs = + agent.startedAt && agent.completedAt + ? Date.parse(agent.completedAt) - Date.parse(agent.startedAt) + : null; + const meta = [ + durationMs !== null && durationMs >= 0 ? formatDuration(durationMs) : null, + agent.usage && agent.usage.totalTokens > 0 + ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tok` + : null, + ] + .filter(Boolean) + .join(" · "); + // Settled members show their metrics; anything other than success keeps + // the status word so the outcome remains explicit. + const statusLabel = + activeStatus || !meta + ? AGENT_MEMBER_STATUS_LABEL[agent.status] + : agent.status === "completed" + ? meta + : `${AGENT_MEMBER_STATUS_LABEL[agent.status]} · ${meta}`; + const role = + agent.role && agent.role.trim().toLowerCase() !== agent.title.trim().toLowerCase() + ? agent.role + : null; + const firstLine = activity?.split("\n").find((line) => line.trim().length > 0) ?? null; + const body = [activity?.trim() || null, formatSubagentModelLabel(agent.model, agent.effort)] + .filter(Boolean) + .join("\n\n"); + const canExpand = body.length > 0; + const toggleOpen = () => { + onToggleEntry?.(open); + setOpen((value) => !value); + }; return ( - +
+

+ + {agent.title} + + {role ? ( + + {role} + + ) : null} +

+ + {statusLabel} + +
+ {!open && firstLine ? ( +

{firstLine}

+ ) : null} + {open ? ( +
+
{body}
+
+ ) : null} +
); -}); +} const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; @@ -3741,9 +3921,15 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { onToggleEntry?: ((collapsed: boolean) => void) | undefined; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; - // Before any hooks: spawn CTA rows render their own component. + // Before any hooks: spawn rows render their own component. if (workEntry.agentSpawn) { - return ; + return ( + + ); } return ( { }); describe("deriveWorkLogEntries quiet-timeline guarantee", () => { - it("N concurrent subagents produce exactly N lifecycle rows, zero attributed tool rows", () => { + it("concurrent subagents replace their launch tools with one lifecycle row", () => { const activities: OrchestrationThreadActivity[] = []; + for (let agent = 0; agent < 5; agent += 1) { + activities.push( + makeActivity({ + kind: "tool.updated", + summary: "Subagent task", + payload: { + toolCallId: `launch-${agent}`, + itemType: "collab_agent_tool_call", + status: "inProgress", + data: { toolName: agent % 2 === 0 ? "Agent" : "Task" }, + }, + turnId: "turn-batch", + sequence: agent - 10, + }), + ); + expect(deriveWorkLogEntries(activities)).toHaveLength(0); + } for (let agent = 0; agent < 5; agent += 1) { const taskId = `task-${agent}`; + const toolUseId = `launch-${agent}`; + expect(deriveWorkLogEntries(activities)).toHaveLength(agent === 0 ? 0 : 1); + activities.push( + makeActivity({ + id: `started-${agent}`, + kind: "task.started", + summary: "Task started", + payload: { taskId, toolUseId, taskType: "local_agent" }, + turnId: "turn-batch", + sequence: agent * 20 - 1, + }), + ); + const runningEntries = deriveWorkLogEntries(activities); + expect(runningEntries).toHaveLength(1); + expect(runningEntries[0]!.id).toBe("started-0"); + expect(runningEntries[0]!.agentSpawn?.agentTaskIds).toHaveLength(agent + 1); // Progress ticks (several per agent) + attributed tool rows. for (let tick = 0; tick < 4; tick += 1) { activities.push( @@ -2136,7 +2169,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { kind: "task.progress", summary: `agent ${agent} tick ${tick}`, tone: "info", - payload: { taskId, summary: `working ${tick}`, role: "explorer" }, + payload: { taskId, toolUseId, summary: `working ${tick}`, role: "explorer" }, turnId: "turn-batch", sequence: agent * 20 + tick, }), @@ -2157,6 +2190,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { tone: "info", payload: { taskId, + toolUseId, status: "completed", summary: `agent ${agent} done`, role: "explorer", @@ -2164,6 +2198,13 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { turnId: "turn-batch", sequence: agent * 20 + 19, }), + makeActivity({ + kind: "tool.completed", + summary: "Subagent task", + payload: { toolCallId: toolUseId, status: "completed" }, + turnId: "turn-batch", + sequence: agent * 20 + 19, + }), ); } @@ -2210,15 +2251,69 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { ); }); - it("keeps unattributed tool rows (over-hiding loses the only signal)", () => { + it("keeps unrelated tools and failed launches, including failures after a task starts", () => { const entries = deriveWorkLogEntries([ makeActivity({ kind: "tool.completed", summary: "Bash", payload: { itemType: "command_execution", command: "ls" }, }), + makeActivity({ + id: "unlinked-failure", + kind: "tool.completed", + summary: "Subagent task", + tone: "error", + payload: { toolCallId: "unlinked", status: "failed" }, + }), + makeActivity({ + id: "linked-task", + kind: "task.started", + summary: "Task started", + payload: { taskId: "agent", toolUseId: "linked", taskType: "local_agent" }, + }), + makeActivity({ + id: "linked-failure", + kind: "tool.completed", + summary: "Subagent task", + payload: { toolCallId: "linked", status: "failed" }, + }), + makeActivity({ + id: "orphan-completion", + kind: "tool.completed", + summary: "Subagent task", + payload: { + toolCallId: "orphan", + itemType: "collab_agent_tool_call", + status: "completed", + data: { toolName: "Agent" }, + }, + }), + makeActivity({ + id: "send-input", + kind: "tool.updated", + payload: { + toolCallId: "send-input", + itemType: "collab_agent_tool_call", + status: "inProgress", + data: { toolName: "send_input" }, + }, + }), + makeActivity({ + id: "active-launch-error", + kind: "tool.updated", + tone: "error", + payload: { + toolCallId: "active-launch-error", + itemType: "collab_agent_tool_call", + status: "inProgress", + data: { toolName: "Task" }, + }, + }), ]); - expect(entries).toHaveLength(1); + expect(entries).toHaveLength(7); + expect(entries.map((entry) => entry.id)).toEqual( + expect.arrayContaining(["unlinked-failure", "linked-task", "linked-failure"]), + ); }); it("folds timelineBypass agent rows into one CTA (Codex children, workflow members)", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index d3d81dde9c45..6a0920681bc3 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -83,10 +83,9 @@ export interface WorkLogEntry { /** Agent role (subagent_type) for labeled timeline rows. */ agentRole?: string; /** - * Present on agent-spawn CTA rows: one per workflow run or per-turn batch - * of direct spawns. The row renders as a call-to-action ("Kicked off N - * subagents") whose live status is derived from the agent panel model at - * render time; clicking opens the Agents panel. + * Present on agent-spawn rows: one per workflow run or per-turn batch of + * direct spawns. The row ("Kicked off N subagents") derives its live + * status and member list from the agent panel model at render time. */ agentSpawn?: { /** Workflow coordinator taskId, or null for a direct-spawn batch. */ @@ -395,7 +394,8 @@ export function hasActionableProposedPlan( * - tool rows attributed to an owning agent (payload.agentId) are re-homed; * - task.progress ticks collapse into one row per taskId; * - task.updated is fold input only (status patches are not narrative). - * Unattributed rows always stay: over-hiding loses the only terminal signal. + * Unattributed rows stay unless a linked agent row replaces their launch; + * failed launches stay so the only terminal signal cannot disappear. */ /** Agent (non-background) task.started rows seed spawn CTA batches. */ function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { @@ -424,7 +424,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean activity.kind === "task.completed"; // Task rows classify by the server stamp: a subagent's own background // shell (agentId + "background") is agent-internal, but a nested AGENT - // (agentId + "agent") stays visible so its rows can anchor a spawn CTA + // (agentId + "agent") stays visible so its rows can anchor a spawn row // (review finding: hiding on agentId alone removed nested agents and // their anchors). Bypassed agent lifecycle rows also pass — collapse // folds every such row into its batch's single CTA row, which is how @@ -452,6 +452,20 @@ export function deriveWorkLogEntries( activities: ReadonlyArray, ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); + // A launch tool and its task lifecycle describe the same run. Only hide + // launch rows once their tool-use id has an agent row to replace them. + const agentLaunchToolIds = new Set(); + for (const activity of ordered) { + if ( + (activity.kind === "task.started" || + activity.kind === "task.progress" || + activity.kind === "task.completed") && + isAgentTaskStartedActivity(activity) + ) { + const toolUseId = asTrimmedString(asRecord(activity.payload)?.toolUseId); + if (toolUseId) agentLaunchToolIds.add(toolUseId); + } + } const entries: DerivedWorkLogEntry[] = []; for (const activity of foldUserInputActivities(ordered)) { if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; @@ -469,7 +483,28 @@ export function deriveWorkLogEntries( if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; - entries.push(toDerivedWorkLogEntry(activity)); + const entry = toDerivedWorkLogEntry(activity); + // Native agent launches get their visible row from task.started. Defer + // their active tool row so another launch cannot duplicate the batch. + if ( + activity.kind === "tool.updated" && + entry.itemType === "collab_agent_tool_call" && + entry.toolLifecycleStatus === "inProgress" && + entry.tone !== "error" + ) { + const toolName = asRecord(asRecord(activity.payload)?.data)?.toolName; + if (toolName === "Agent" || toolName === "Task") continue; + } + if ( + (activity.kind === "tool.updated" || activity.kind === "tool.completed") && + entry.toolCallId && + agentLaunchToolIds.has(entry.toolCallId) && + entry.tone !== "error" && + entry.toolLifecycleStatus !== "failed" + ) { + continue; + } + entries.push(entry); } return collapseDerivedWorkLogEntries(entries); } @@ -681,7 +716,7 @@ function collapseDerivedWorkLogEntries( const collapsed: DerivedWorkLogEntry[] = []; // Subagent rows collapse by spawn group, not adjacency: a workflow run (or // a turn's batch of direct spawns) is ONE narrative event in the chat — a - // CTA row that opens the Agents panel — no matter how many agents it + // spawn row in the timeline — no matter how many agents it // contains or how their progress rows interleave (quiet-timeline // guarantee). const spawnRowIndex = new Map(); From af2baccd100604f9885d6af97a5fa99622dd1c4f Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 12 Sep 2026 19:54:23 -0300 Subject: [PATCH 08/17] fix(web): keep subagent rows visible under folded turns (#11474) --- .../chat/MessagesTimeline.logic.test.ts | 17 +++------ .../components/chat/MessagesTimeline.logic.ts | 36 ++++--------------- .../src/components/chat/MessagesTimeline.tsx | 11 ++---- 3 files changed, 14 insertions(+), 50 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index f5a26ad9b021..5fbc686227d9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1122,7 +1122,7 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("folds a settled subagent spawn row and keeps a live one outside the fold", () => { + it("keeps subagent spawn rows outside turn folds even after they settle", () => { const firstMessage: ChatMessage = { id: MessageId.make("assistant-first-entry"), role: "assistant", @@ -1161,7 +1161,6 @@ describe("deriveMessagesTimelineRows", () => { const derive = ( timelineEntries: typeof direct, liveAgentTaskIds: ReadonlySet | undefined, - expandedSpawnEntryIds?: ReadonlySet, expandedTurnIds?: ReadonlySet, ) => deriveMessagesTimelineRows({ @@ -1171,10 +1170,8 @@ describe("deriveMessagesTimelineRows", () => { turnDiffSummaries: [], supportsConversationRollback: false, liveAgentTaskIds, - expandedSpawnEntryIds, ...(expandedTurnIds ? { expandedTurnIds } : {}), }).map((row) => row.id); - const folded = ["turn-fold:turn-1", "assistant-final-entry"]; const unfolded = ["turn-fold:turn-1", "spawn-entry", "assistant-final-entry"]; const activeRows = ( @@ -1237,19 +1234,15 @@ describe("deriveMessagesTimelineRows", () => { } } - expect(derive(direct, new Set())).toEqual(folded); + expect(derive(direct, new Set())).toEqual(unfolded); expect(derive(direct, new Set(["agent-b"]))).toEqual(unfolded); // A workflow coordinator between phases keeps its batch out of the fold. expect(derive(workflow, new Set(["wf-1"]))).toEqual(unfolded); - expect(derive(workflow, new Set())).toEqual(folded); + expect(derive(workflow, new Set())).toEqual(unfolded); // No live set is known. expect(derive(direct, undefined)).toEqual(unfolded); - // The user has it open: it stays visible under the collapsed fold and - // keeps its place when the fold is expanded. - expect(derive(direct, new Set(), new Set(["spawn-entry"]))).toEqual(unfolded); - expect( - derive(direct, new Set(), new Set(["spawn-entry"]), new Set(["turn-1" as TurnId])), - ).toEqual([ + // Expanding the turn reveals the other work without duplicating the batch. + expect(derive(direct, new Set(), new Set(["turn-1" as TurnId]))).toEqual([ "turn-fold:turn-1", "assistant-first-entry", "spawn-entry", diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 4630aedec1d4..8dcb539fb024 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -577,7 +577,6 @@ function deriveTurnFolds(input: { terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; unfoldedTurnIds: ReadonlySet; - liveAgentTaskIds: ReadonlySet | undefined; }): ReadonlyMap { interface TurnGroup { entries: Array; @@ -658,27 +657,13 @@ function deriveTurnFolds(input: { if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) { continue; } - // User input stays visible after the surrounding work settles. - if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) { + // User input and subagent batches stay visible after their turn settles. + if ( + entry.kind === "work" && + (entry.entry.questionAnswer !== undefined || entry.entry.agentSpawn !== undefined) + ) { continue; } - // Workflows outlive their launching turn (dynamic spawns, background - // execution), so a spawn row with a live member or coordinator stays - // outside the fold instead of hiding a still-running fleet. Settled - // spawns fold with the rest of the turn. Without a live set (no agent - // panel model, as in the held paint during a thread switch) every - // spawn row stays out. - if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { - const live = input.liveAgentTaskIds; - const { workflowId, agentTaskIds } = entry.entry.agentSpawn; - if ( - live === undefined || - (workflowId !== null && live.has(workflowId)) || - agentTaskIds.some((taskId) => live.has(taskId)) - ) { - continue; - } - } hiddenEntryIds.add(entry.id); } if (hiddenEntryIds.size === 0) { @@ -870,13 +855,8 @@ export function deriveMessagesTimelineRows(input: { activeTurnStartedAt: string | null; turnDiffSummaries: ReadonlyArray; supportsConversationRollback: boolean; - /** - * Task ids of subagents still working; their spawn row stays outside turn - * folds. Undefined means unknown, which keeps every spawn row out. - */ + /** Task ids of subagents still working, used by the active tool indicator. */ liveAgentTaskIds?: ReadonlySet | undefined; - /** Spawn rows the user opened stay visible while their turn fold is collapsed. */ - expandedSpawnEntryIds?: ReadonlySet | undefined; }): MessagesTimelineRow[] { const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { @@ -911,15 +891,11 @@ export function deriveMessagesTimelineRows(input: { terminalAssistantMessageIds, latestTurn: input.latestTurn ?? null, unfoldedTurnIds: activeVisualResponseTurnIds, - liveAgentTaskIds: input.liveAgentTaskIds, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { if (!input.expandedTurnIds?.has(fold.turnId)) { for (const entryId of fold.hiddenEntryIds) { - // An opened spawn row keeps its fold membership but is not pulled - // away mid-read when its last member settles. - if (input.expandedSpawnEntryIds?.has(entryId)) continue; collapsedEntryIds.add(entryId); } } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9994f032fa3a..d17c03ed3277 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -465,8 +465,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); - // Expanded spawn rows outlive virtualization and stay visible while their - // turn fold is collapsed, so a settling fleet is not pulled away mid-read. + // Preserve member disclosure state across virtualization. const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState>( new Set(), ); @@ -628,10 +627,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot: string | undefined; projection: MessagesTimelineRowsProjection; } | null>(null); - // Subagents still working keep their spawn row outside the turn fold. Same - // liveness rule as the row header (deriveAgentSpawnSummary): members while - // active, workflow coordinators until terminal. Keyed by content so the - // projection input keeps its identity across unrelated panel updates. + // Match the row header's liveness, retaining projection input identity + // across unrelated panel updates. const liveAgentTaskKey = useMemo(() => { if (agentPanelModel === undefined) return undefined; const ids: string[] = []; @@ -667,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaries, supportsConversationRollback, liveAgentTaskIds, - expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, }, previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot ? previous.projection @@ -689,7 +685,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaries, supportsConversationRollback, liveAgentTaskIds, - paintedExpandedSpawnEntryIds, ]); const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); From fcbe45796aea99e9e2a9ce19dd9409fbd7e720e4 Mon Sep 17 00:00:00 2001 From: Dominic Roy Date: Sat, 12 Sep 2026 19:30:05 -0400 Subject: [PATCH 09/17] fix(usage): make unavailable account limits more visible (#10601) --- .../src/features/usage/UsageLimitsPooled.tsx | 38 +++++++++++++------ .../components/usage/UsageLimitsPooled.tsx | 14 ++++--- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 7c20f802477d..23d9ac3b9a04 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -212,12 +212,7 @@ export function UsageLimitsSection({ const colors = useProviderColors(); return ( - {failedLabels.length ? ( - - {failedLabels.join(", ")} could not refresh limits. Showing the last known values. - - ) : null} - {pools.length === 0 ? ( + {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 ? ( {selected.size === 0 ? "Select an environment to see limits." @@ -243,11 +238,32 @@ export function UsageLimitsSection({ ))} ))} - {notices.map((notice) => ( - - {notice} - - ))} + {notices.length > 0 || failedLabels.length > 0 ? ( + + + + {notices.map((notice) => ( + + {notice} + + ))} + {failedLabels.length > 0 ? ( + + {failedLabels.join(", ")} could not refresh limits. Showing the last known values. + + ) : null} + + + ) : null} ); } diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 37654140c282..1317cef89839 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -10,7 +10,7 @@ import { type LimitPoolWindow, remainingPercent, } from "@t3tools/shared/usageLimits"; -import { TicketIcon } from "lucide-react"; +import { AlertTriangleIcon, TicketIcon } from "lucide-react"; import { type ReactNode, useState } from "react"; import { usePrimarySettings } from "../../hooks/useSettings"; @@ -20,6 +20,7 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { getDriverOption } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { Button } from "../ui/button"; +import { Alert, AlertTitle } from "../ui/alert"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { PaceIcon, @@ -545,7 +546,7 @@ export function UsageLimitsPooled({ const notices = collectLimitNotices(presentations); return (
- {pools.length === 0 ? ( + {pools.length === 0 && notices.length === 0 ? (

No provider on the selected environments reports subscription limits.

@@ -562,10 +563,13 @@ export function UsageLimitsPooled({ function LimitNotices({ notices }: { readonly notices: readonly string[] }) { if (notices.length === 0) return null; return ( -
    + + {notices.map((notice) => ( -
  • {notice}
  • + + {notice} + ))} -
+ ); } From 8ddd9f7efcdfb6e32b614595242b5986942d37b0 Mon Sep 17 00:00:00 2001 From: Ishaan Kothari Date: Sat, 12 Sep 2026 16:43:52 -0700 Subject: [PATCH 10/17] fix(desktop): bound backend shutdown wait during quit (#7599) Co-authored-by: Claude Fable 5.1 --- apps/desktop/src/app/DesktopApp.ts | 33 ++++++--- .../src/backend/DesktopBackendManager.test.ts | 74 +++++++++++++++++++ 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index e84e6d5c730d..e6abaab03251 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -142,6 +143,20 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); +export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( + function* (): Effect.fn.Return { + // Stop every backend in the pool with a timeout to guarantee the quit + // path makes progress even if a backend hangs during teardown. + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const instances = yield* pool.list; + yield* Effect.forEach( + instances, + (instance) => instance.stop({ timeout: Duration.seconds(5) }), + { concurrency: "unbounded" }, + ); + }, +); + const bootstrap = Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; const primaryBackend = yield* pool.primary; @@ -313,18 +328,12 @@ const scopedProgram = Effect.scoped( const shutdown = yield* DesktopShutdown.DesktopShutdown; yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - // Stop every backend in the pool, not just the primary. The - // electronApp.quit() path can race ahead of the layer-scope - // cascade, so leaving the WSL instance for its parent scope - // finalizer means it gets hard-killed by the OS instead of - // receiving SIGTERM + grace. Stops run concurrently. - const instances = yield* pool.list; - yield* Effect.forEach(instances, (instance) => instance.stop(), { - concurrency: "unbounded", - }); - }).pipe(Effect.ensuring(shutdown.markComplete)), + // Stop every backend in the pool, not just the primary. The + // electronApp.quit() path can race ahead of the layer-scope + // cascade, so leaving the WSL instance for its parent scope + // finalizer means it gets hard-killed by the OS instead of + // receiving SIGTERM + grace. + stopAllPoolInstances().pipe(Effect.ensuring(shutdown.markComplete)), ); yield* startup; diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 53ccf5a756eb..5a1ef70ad1c2 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -23,6 +23,8 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopApp from "../app/DesktopApp.ts"; +import * as DesktopBackendPool from "./DesktopBackendPool.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; @@ -1503,4 +1505,76 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("stopAllPoolInstances bounds the quit finalizer when backends hang", () => + Effect.scoped( + Effect.gen(function* () { + // Each backend's process-scope finalizer reports when it starts and + // when it finishes, keyed by instance name, so the test can prove + // both backends reached each milestone instead of inferring it from + // a shared flag or a clock advance. + const teardownStarted = yield* Queue.unbounded(); + const teardownFinished = yield* Queue.unbounded(); + const allowTeardown = yield* Deferred.make(); + + const makeInstance = (name: string) => + makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + yield* Scope.addFinalizer( + scope, + Queue.offer(teardownStarted, name).pipe( + Effect.andThen(Deferred.await(allowTeardown)), + Effect.andThen(Queue.offer(teardownFinished, name)), + Effect.asVoid, + ), + ); + return makeProcess({ exitCode: Effect.never }); + }), + ), + ), + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + const instance1 = yield* makeInstance("instance1"); + const instance2 = yield* makeInstance("instance2"); + + yield* instance1.start; + yield* instance2.start; + + const mockPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([instance1, instance2]), + get: () => Effect.succeed(Option.none()), + primary: Effect.die(new Error("primary not implemented")), + register: () => Effect.die(new Error("register not implemented")), + unregister: () => Effect.die(new Error("unregister not implemented")), + }); + + // Mirror the quit path: register stopAllPoolInstances as a scope + // finalizer and let the scope close run it, rather than calling it + // as an ordinary interruptible effect. + const quitFiber = yield* Effect.scoped( + Effect.addFinalizer(() => DesktopApp.stopAllPoolInstances()), + ).pipe(Effect.provide(mockPool), Effect.forkChild); + + const started = yield* Queue.takeN(teardownStarted, 2); + assert.deepEqual(started.toSorted(), ["instance1", "instance2"]); + + // Both backends are now hung in teardown. Advancing past the 5s + // budget must let the quit finalizer return without them. + yield* TestClock.adjust(Duration.seconds(5)); + yield* Fiber.join(quitFiber); + assert.equal(yield* Queue.size(teardownFinished), 0); + + // The timed-out closes keep running in the background and finish + // once the backends unblock. + yield* Deferred.succeed(allowTeardown, undefined); + const finished = yield* Queue.takeN(teardownFinished, 2); + assert.deepEqual(finished.toSorted(), ["instance1", "instance2"]); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); }); From 36caf200c2b24c12838570966f5c85786a1fc1f8 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 12 Sep 2026 21:25:31 -0300 Subject: [PATCH 11/17] feat(web): choose the default diff file state (#11484) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/clientPersistenceStorage.test.ts | 15 ++++++ apps/web/src/components/DiffPanel.tsx | 31 ++++++++---- .../pullRequest/PullRequestCodeTab.tsx | 7 ++- .../pullRequest/pullRequestDiff.logic.ts | 4 +- .../components/settings/SettingsPanels.tsx | 47 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 +++ packages/contracts/src/settings.test.ts | 14 ++++++ packages/contracts/src/settings.ts | 2 + 9 files changed, 114 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea2a80010124..f6622049ab49 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -34,6 +34,7 @@ const clientSettings: ClientSettings = { contextWindowMeterEnabled: false, composerCollapseOnScroll: true, dismissedProviderUpdateNotificationKeys: [], + diffFilesCollapsed: true, diffIgnoreWhitespace: true, diffLayout: "stacked", environmentIdentificationMode: "artwork", diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index a86177b48eb3..17e531918ab1 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -114,6 +114,21 @@ describe("clientPersistenceStorage", () => { expect(settings).not.toHaveProperty("diffWordWrap"); }); + it("keeps the default diff file state across reloads and defaults it to expanded", async () => { + const testWindow = getTestWindow(); + const { readBrowserClientSettings, writeBrowserClientSettings } = + await import("./clientPersistenceStorage"); + + testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({})); + expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(false); + + writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: true }); + expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(true); + + writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: false }); + expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(false); + }); + it("keeps the diff layout across reloads and defaults it to stacked", async () => { const testWindow = getTestWindow(); const { readBrowserClientSettings, writeBrowserClientSettings } = diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index d764b9c6a9e2..ca0bdaa7b4d5 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -226,10 +226,6 @@ export default function DiffPanel({ ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` : null; const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`; - const collapsedDiffFileKeys = - collapsedDiffFiles.scopeKey === collapseScopeKey - ? collapsedDiffFiles.fileKeys - : EMPTY_COLLAPSED_DIFF_FILE_KEYS; const reviewSectionTitle = selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` : selectedGitScope === "unstaged" @@ -425,6 +421,17 @@ export default function DiffPanel({ })), [renderableFiles], ); + const defaultCollapsedDiffFileKeys = useMemo( + () => + settings.diffFilesCollapsed + ? new Set(renderableFileEntries.map((file) => file.fileKey)) + : EMPTY_COLLAPSED_DIFF_FILE_KEYS, + [renderableFileEntries, settings.diffFilesCollapsed], + ); + const collapsedDiffFileKeys = + collapsedDiffFiles.scopeKey === collapseScopeKey + ? collapsedDiffFiles.fileKeys + : defaultCollapsedDiffFileKeys; const codeViewFiles = useMemo( () => renderableFileEntries.map(({ fileDiff, fileKey, fileVersion }) => { @@ -462,14 +469,16 @@ export default function DiffPanel({ if (!file) return; if (file.collapsed) { setCollapsedDiffFiles((current) => { - const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); + const next = new Set( + current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys, + ); next.delete(file.fileKey); return { scopeKey: collapseScopeKey, fileKeys: next }; }); } requestTreeReveal(file.fileKey); }, - [codeViewFiles, collapseScopeKey, requestTreeReveal], + [codeViewFiles, collapseScopeKey, defaultCollapsedDiffFileKeys, requestTreeReveal], ); const openDiffFile = useCallback( @@ -503,7 +512,9 @@ export default function DiffPanel({ const toggleDiffFileCollapsed = useCallback( (fileKey: string) => { setCollapsedDiffFiles((current) => { - const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); + const next = new Set( + current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys, + ); if (next.has(fileKey)) { next.delete(fileKey); } else { @@ -512,21 +523,21 @@ export default function DiffPanel({ return { scopeKey: collapseScopeKey, fileKeys: next }; }); }, - [collapseScopeKey], + [collapseScopeKey, defaultCollapsedDiffFileKeys], ); const toggleDiffFileCollapse = useCallback(() => { setCodeViewRevision((current) => current + 1); setCollapsedDiffFiles((current) => { const currentKeys = - current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS; + current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys; return { scopeKey: collapseScopeKey, fileKeys: toggleAllDiffFiles(diffFileKeys, currentKeys), }; }); - }, [collapseScopeKey, diffFileKeys]); + }, [collapseScopeKey, defaultCollapsedDiffFileKeys, diffFileKeys]); const selectTurn = (turnId: TurnId) => { if (!routeThreadRef) return; diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index b77a3711d90f..b0bc3fdb8e22 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -485,7 +485,11 @@ function PullRequestCodeTab({ groupAt(anchor.side, anchor.line).draft = true; } - const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); + const collapsed = isFileDiffCollapsed( + fileKey, + foldOverride ?? (settings.diffFilesCollapsed ? "folded" : "expanded"), + toggledFiles, + ); const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ side: toViewerSide(group.side), @@ -538,6 +542,7 @@ function PullRequestCodeTab({ foldOverride, pendingComments, placedThreadIds, + settings.diffFilesCollapsed, toggledFiles, ], ); diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts index a286a4552cb6..e818e989d200 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -30,8 +30,8 @@ export type DiffFoldOverride = "expanded" | "folded" | null; * A diff arrives a slice at a time, so the reader's own choices are kept as the difference from * what the toolbar last said rather than as the set of folded files: a file that has not loaded * yet cannot be in a set, and would otherwise land expanded moments after the reader folded - * everything. Files start expanded so opening the Code tab immediately shows the change; the - * reader can still fold individual files or the whole diff from the toolbar. + * everything. The caller supplies the saved default until the toolbar overrides it; individual + * files can still be toggled independently. */ export function isFileDiffCollapsed( fileKey: string, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8a78d4138c6b..98684f50721b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -533,6 +533,9 @@ export function useSettingsRestore(onRestored?: () => void) { : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...getChangedTypographySettingLabels(settings), + ...(settings.diffFilesCollapsed !== DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed + ? ["Default diff file state"] + : []), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), @@ -608,6 +611,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, + settings.diffFilesCollapsed, settings.diffIgnoreWhitespace, settings.diffLayout, settings.proactivePanelsEnabled, @@ -706,6 +710,7 @@ export function useSettingsRestore(onRestored?: () => void) { diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, + diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout, proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, @@ -2304,6 +2309,48 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ + diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed, + }) + } + /> + ) : null + } + control={ + + } + /> { }); }); +describe("ClientSettings default diff file state", () => { + it("keeps files expanded when existing settings omit the preference", () => { + expect(decodeClientSettings({}).diffFilesCollapsed).toBe(false); + }); + + it.each([true, false])("preserves a saved collapsed preference of %s", (diffFilesCollapsed) => { + const settings = decodeClientSettings({ diffFilesCollapsed }); + expect(encodeClientSettings(settings).diffFilesCollapsed).toBe(diffFilesCollapsed); + expect(decodeClientSettingsPatch({ diffFilesCollapsed }).diffFilesCollapsed).toBe( + diffFilesCollapsed, + ); + }); +}); + describe("ClientSettings diff colors", () => { it("keeps red and green for existing settings without a saved palette", () => { expect(decodeClientSettings({}).diffColorScheme).toBe("red-green"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6b931f4ef3ea..b420b737ee0a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -344,6 +344,7 @@ export const ClientSettingsSchema = Schema.Struct({ dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), + diffFilesCollapsed: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffLayout: DiffLayout.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_DIFF_LAYOUT))), environmentIdentificationMode: EnvironmentIdentificationMode.pipe( @@ -1432,6 +1433,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), + diffFilesCollapsed: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffLayout: Schema.optionalKey(DiffLayout), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), From 68c2277f500bbbb299396bcdcd0aec60dcb5db9d Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sun, 13 Sep 2026 01:28:28 +0100 Subject: [PATCH 12/17] feat(composer): fold large pastes into text attachments (#11442) --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.test.ts | 54 +++ apps/desktop/src/ipc/methods/window.ts | 26 + apps/desktop/src/preload.ts | 1 + .../src/window/DesktopApplicationMenu.test.ts | 30 ++ .../src/window/DesktopApplicationMenu.ts | 36 +- .../t3-composer-editor/android/build.gradle | 12 + .../T3ComposerEditorModule.kt | 7 + .../t3composereditor/T3ComposerEditorView.kt | 104 +++- .../t3composereditor/ComposerPasteTest.kt | 123 +++++ .../ios/T3ComposerEditorModule.swift | 7 + .../ios/T3ComposerEditorView.swift | 71 ++- .../t3-markdown-text/ios/T3MarkdownText.mm | 9 + .../components/ComposerAttachmentStrip.tsx | 4 +- apps/mobile/src/components/ComposerEditor.tsx | 71 +-- .../features/files/AttachmentFileScreen.tsx | 41 +- .../src/features/files/SourceFileSurface.tsx | 143 +++++- .../features/files/ThreadFilesRouteScreen.tsx | 26 + .../files/source-file-document.test.ts | 13 +- .../features/files/source-file-document.ts | 15 + .../features/threads/NewTaskDraftScreen.tsx | 179 ++++++- .../src/features/threads/ThreadComposer.tsx | 128 ++++- .../features/threads/ThreadDetailScreen.tsx | 3 + .../features/threads/ThreadRouteScreen.tsx | 1 + .../threads/new-task-flow-provider.tsx | 12 +- .../src/lib/appearancePreferences.test.ts | 7 + apps/mobile/src/lib/attachmentDocument.ts | 11 +- .../attachmentDocumentPresentation.test.ts | 71 +++ .../src/lib/attachmentDocumentPresentation.ts | 21 + apps/mobile/src/lib/attachmentUpload.test.ts | 12 +- apps/mobile/src/lib/attachmentUpload.ts | 6 +- apps/mobile/src/lib/composer-image-schema.ts | 3 +- apps/mobile/src/lib/composerImages.test.ts | 150 +++++- apps/mobile/src/lib/composerImages.ts | 73 ++- .../src/native/T3ComposerEditor.ios.tsx | 49 +- .../src/native/T3ComposerEditor.native.tsx | 49 +- apps/mobile/src/native/T3ComposerEditor.tsx | 2 + .../src/native/T3ComposerEditor.types.ts | 19 +- .../src/native/composerEditorRevision.test.ts | 22 + .../src/state/use-composer-drafts.test.ts | 444 ++++++++++++++++-- apps/mobile/src/state/use-composer-drafts.ts | 264 +++++++++-- .../src/state/use-thread-composer-state.ts | 170 ++++++- .../provider/Layers/ProviderService.test.ts | 46 ++ .../src/provider/Layers/ProviderService.ts | 29 +- .../acp/AntigravityAcpSupport.test.ts | 45 ++ .../src/provider/acp/AntigravityAcpSupport.ts | 15 + .../opencodeRuntime.cliParsers.test.ts | 14 + apps/server/src/provider/opencodeRuntime.ts | 7 + apps/web/src/components/ChatView.tsx | 31 +- .../components/ComposerPromptEditor.test.ts | 180 +++---- .../src/components/ComposerPromptEditor.tsx | 7 +- apps/web/src/components/chat/ChatComposer.tsx | 261 ++++++++-- .../components/composerInlineTokenPaste.ts | 53 ++- apps/web/src/components/contextChipParts.tsx | 45 +- .../files/AttachmentFilePreview.tsx | 21 + .../src/components/files/FilePreviewPanel.tsx | 22 +- apps/web/src/composerDraftStore.test.ts | 6 +- apps/web/src/composerDraftStore.ts | 5 + .../web/src/lib/attachmentUploadQueue.test.ts | 6 +- apps/web/src/lib/attachmentUploadQueue.ts | 2 +- .../src/lib/composerContextReferences.test.ts | 12 + apps/web/src/lib/composerContextReferences.ts | 27 +- apps/web/src/lib/desktopPasteAsText.test.ts | 32 ++ apps/web/src/lib/desktopPasteAsText.ts | 15 + apps/web/src/questionAttachments.test.ts | 41 ++ apps/web/src/questionAttachments.ts | 10 + apps/web/src/routes/__root.tsx | 2 + docs/user/composer.md | 6 + packages/client-runtime/package.json | 4 + packages/client-runtime/src/textPaste.test.ts | 156 ++++++ packages/client-runtime/src/textPaste.ts | 76 +++ packages/contracts/src/ipc.ts | 2 + packages/contracts/src/orchestration.ts | 7 + .../shared/src/composerContextLegacy.test.ts | 16 + packages/shared/src/composerContextLegacy.ts | 19 +- .../src/composerContextLegacySend.test.ts | 25 + 77 files changed, 3275 insertions(+), 462 deletions(-) create mode 100644 apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt create mode 100644 apps/mobile/src/lib/attachmentDocumentPresentation.test.ts create mode 100644 apps/mobile/src/lib/attachmentDocumentPresentation.ts create mode 100644 apps/web/src/lib/desktopPasteAsText.test.ts create mode 100644 apps/web/src/lib/desktopPasteAsText.ts create mode 100644 packages/client-runtime/src/textPaste.test.ts create mode 100644 packages/client-runtime/src/textPaste.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c6ca676fc467..c1eba805c2b6 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -40,6 +40,7 @@ import { openExternal, openSystemSettings, checkSystemPermission, + pasteAsText, probeRemoteEditors, pickFolder, pickProjectFavicon, @@ -124,6 +125,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openExternal); yield* ipc.handle(openSystemSettings); yield* ipc.handle(checkSystemPermission); + yield* ipc.handle(pasteAsText); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5489e56fea1c..ca6bbd30b3e4 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -7,6 +7,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 203151c2660e..6fcf5e813749 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -6,6 +6,15 @@ import { vi } from "vite-plus/test"; import type * as Electron from "electron"; +const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({ + focusedWebContents: vi.fn(), + ownerWindow: vi.fn(), +})); +vi.mock("electron", () => ({ + webContents: { getFocusedWebContents: focusedWebContents }, + BrowserWindow: { fromWebContents: ownerWindow }, +})); + import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; @@ -13,6 +22,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, + pasteAsText, pickProjectFavicon, } from "./window.ts"; @@ -153,6 +163,50 @@ describe("getWindowFullscreenState", () => { }); }); +describe("pasteAsText", () => { + it.effect( + "pastes into the focused guest only after the main renderer acknowledges the menu action", + () => { + const paste = vi.fn(); + const mainPaste = vi.fn(); + const window = { + webContents: { id: 42, paste: mainPaste }, + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow; + focusedWebContents.mockReturnValue({ paste, isDestroyed: () => false }); + ownerWindow.mockReturnValue(window); + + return Effect.gen(function* () { + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + assert.equal(mainPaste.mock.calls.length, 0); + + yield* pasteAsText.handler(undefined, { sender: { id: 99 } }); + assert.equal(paste.mock.calls.length, 1); + ownerWindow.mockReturnValue({}); // A focused PiP/other BrowserWindow. + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + ownerWindow.mockReturnValue(null); // Detached contents. + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + ownerWindow.mockReturnValue(window); + focusedWebContents.mockReturnValue({ paste, isDestroyed: () => true }); + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + focusedWebContents.mockReturnValue(null); + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + }).pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + main: Effect.succeed(Option.some(window)), + }), + ), + ); + }, + ); +}); + describe("pickProjectFavicon", () => { it.effect("opens a single-image picker from the project directory", () => Effect.gen(function* () { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 5e7c41514a67..284b62ad31ac 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -345,6 +345,32 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ }), }); +export const pasteAsText = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PASTE_AS_TEXT_CHANNEL, + payload: Schema.Undefined, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.pasteAsText")(function* (_input, event) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.main; + if ( + event === undefined || + Option.isNone(window) || + window.value.isDestroyed() || + window.value.webContents.id !== event.sender.id + ) { + return; + } + const focused = Electron.webContents.getFocusedWebContents(); + if ( + focused && + !focused.isDestroyed() && + Electron.BrowserWindow.fromWebContents(focused) === window.value + ) { + focused.paste(); + } + }), +}); + /** Theme files are a few KB; anything larger returns empty text and lets the * renderer reject it by size without the contents ever crossing the bridge. */ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 63041db98c28..d4edb7818180 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -157,6 +157,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { openSystemSettings: (pane: string) => ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), + pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index eeb0c86f031c..bf0c4c3eff6e 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -153,6 +153,36 @@ describe("DesktopApplicationMenu", () => { }), ); + it.effect("owns Paste as Text and routes it through the renderer", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const editMenu = template.find((item) => item.label === "Edit"); + assert.isDefined(editMenu); + if (!Array.isArray(editMenu.submenu)) { + throw new Error("Expected Edit menu submenu to be an array."); + } + const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text"); + assert.isDefined(pasteAsTextItem); + assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V"); + if (typeof pasteAsTextItem.click !== "function") { + throw new Error("Expected Paste as Text menu item to have a click handler."); + } + + pasteAsTextItem.click( + {} as Electron.MenuItem, + {} as Electron.BrowserWindow, + {} as KeyboardEvent, + ); + assert.equal(yield* Deferred.await(selectedAction), "paste-as-text"); + }), + ); + // Zoom must route through DesktopWindow.zoomMain instead of the Electron // zoom roles: the roles zoom whichever webContents has focus, which breaks // app zoom while an embedded preview WebContentsView holds focus. diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index bf7634981f86..a90b9ca63231 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -46,7 +46,9 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function action: string, ): Effect.fn.Return { const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.dispatchMenuAction(action); + yield* desktopWindow.dispatchMenuAction(action, { + reveal: action !== "paste-as-text", + }); }); const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( @@ -135,6 +137,9 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; + const pasteAsTextClick = () => { + runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text")); + }; const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); }; @@ -184,7 +189,34 @@ export const make = Effect.gen(function* () { { role: environment.platform === "darwin" ? "close" : "quit" }, ], }, - { role: "editMenu" }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { + label: "Paste as Text", + accelerator: "CmdOrCtrl+Shift+V", + click: pasteAsTextClick, + }, + { role: "delete" }, + { type: "separator" }, + { role: "selectAll" }, + ...(environment.platform === "darwin" + ? [ + { type: "separator" as const }, + { + label: "Speech", + submenu: [{ role: "startSpeaking" as const }, { role: "stopSpeaking" as const }], + }, + ] + : []), + ], + }, { label: "View", submenu: [ diff --git a/apps/mobile/modules/t3-composer-editor/android/build.gradle b/apps/mobile/modules/t3-composer-editor/android/build.gradle index 489641ec6c6e..0a3e7ed7e17b 100644 --- a/apps/mobile/modules/t3-composer-editor/android/build.gradle +++ b/apps/mobile/modules/t3-composer-editor/android/build.gradle @@ -8,6 +8,10 @@ android { namespace 'expo.modules.t3composereditor' compileSdk rootProject.ext.compileSdkVersion + testOptions { + unitTests.includeAndroidResources = true + } + defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion @@ -17,4 +21,12 @@ android { dependencies { implementation project(':expo-modules-core') implementation project(':t3tools-mobile-markdown-text') + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt index 729fec480068..1703a7670202 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt @@ -116,6 +116,12 @@ class T3ComposerEditorModule : Module() { Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean -> view.setSpellCheck(spellCheck) } + Prop("textPasteThresholdBytes") { view: T3ComposerEditorView, threshold: Int -> + view.setTextPasteThresholdBytes(threshold) + } + Prop("maxInputChars") { view: T3ComposerEditorView, maxInputChars: Int -> + view.setMaxInputChars(maxInputChars) + } Events( "onComposerChange", @@ -125,6 +131,7 @@ class T3ComposerEditorModule : Module() { "onComposerPasteImages", "onComposerContextPress", "onComposerPasteContext", + "onComposerPasteText", "onComposerContentSizeChange", ) 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 e409c791a5e5..a4720a461327 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 @@ -1,7 +1,8 @@ package expo.modules.t3composereditor -import android.content.Context +import android.content.ClipData import android.content.ClipboardManager +import android.content.Context import android.graphics.Color import android.graphics.Canvas import android.graphics.Paint @@ -48,6 +49,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private val onComposerPasteImages by EventDispatcher() private val onComposerContextPress by EventDispatcher() private val onComposerPasteContext by EventDispatcher() + private val onComposerPasteText by EventDispatcher() private val onComposerContentSizeChange by EventDispatcher() private var applyingNativeValue = false private var desiredLineHeightPx = 0 @@ -86,7 +88,16 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.pasteImagesListener = { uris -> onComposerPasteImages(mapOf("uris" to uris)) } - editor.pasteContextListener = { payload -> onComposerPasteContext(payload) } + editor.pasteContextListener = { payload -> + nativeEventCount += 1 + onComposerPasteContext( + payload + mapOf( + "value" to editor.text.toString(), + "eventCount" to nativeEventCount, + "selection" to currentSelectionPayload(), + ) + ) + } val contextGestures = GestureDetector( context, @@ -120,6 +131,17 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( contextGestures.onTouchEvent(event) false } + editor.pasteTextListener = { text, start, end -> + nativeEventCount += 1 + onComposerPasteText( + mapOf( + "value" to editor.text.toString(), + "eventCount" to nativeEventCount, + "text" to text, + "selection" to currentSelectionPayload(start, end), + ), + ) + } editor.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) { onComposerFocus(emptyMap()) @@ -305,6 +327,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( updateInputFlags() } + fun setTextPasteThresholdBytes(threshold: Int) { + editor.textPasteThresholdBytes = threshold + } + + fun setMaxInputChars(maxInputChars: Int) { + editor.maxInputChars = maxInputChars + } + fun focusEditor() { editor.requestFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager @@ -365,10 +395,13 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.highlightColor = defaultHighlightColor } - private fun currentSelectionPayload(): Map = + private fun currentSelectionPayload( + start: Int = editor.selectionStart, + end: Int = editor.selectionEnd + ): Map = mapOf( - "start" to editor.selectionStart.coerceAtLeast(0), - "end" to editor.selectionEnd.coerceAtLeast(0), + "start" to minOf(start, end).coerceAtLeast(0), + "end" to maxOf(start, end).coerceAtLeast(0), ) private fun emitSelectionChange(start: Int, end: Int) { @@ -379,7 +412,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( onComposerSelectionChange( mapOf( "value" to editor.text.toString(), - "selection" to mapOf("start" to start, "end" to end), + "selection" to currentSelectionPayload(start, end), "eventCount" to nativeEventCount, ), ) @@ -544,11 +577,14 @@ private fun parseTokens(value: String): List = try { emptyList() } -private class SelectionAwareEditText(context: Context) : EditText(context) { +internal class SelectionAwareEditText(context: Context) : EditText(context) { var readOnly = false var selectionListener: ((Int, Int) -> Unit)? = null var pasteImagesListener: ((List) -> Unit)? = null var pasteContextListener: ((Map) -> Unit)? = null + var pasteTextListener: ((String, Int, Int) -> Unit)? = null + var textPasteThresholdBytes = 0 + var maxInputChars = Int.MAX_VALUE var clipboardFragment = "" private fun deleteChip(backwards: Boolean): Boolean { @@ -602,7 +638,6 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { super.deleteSurroundingTextInCodePoints(beforeLength, afterLength) } } - override fun onSelectionChanged(selStart: Int, selEnd: Int) { super.onSelectionChanged(selStart, selEnd) selectionListener?.invoke(selStart, selEnd) @@ -615,6 +650,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { } val handled = when { id == android.R.id.copy || id == android.R.id.cut -> copyContext(id == android.R.id.cut) + id == android.R.id.pasteAsPlainText -> { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + pasteInterceptedText(clipboard?.primaryClip, foldLargeText = false) + } pasting -> pasteContextOrImages() else -> false } @@ -636,6 +675,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { pasteContextListener?.invoke(payload) return true } + return pasteImagesOrInterceptedText() + } + + private fun pasteImagesOrInterceptedText(): Boolean { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager val clip = clipboard?.primaryClip val imageUris = buildList { @@ -648,7 +691,48 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { } } } - if (imageUris.isNotEmpty()) pasteImagesListener?.invoke(imageUris) - return imageUris.isNotEmpty() + return when { + imageUris.isNotEmpty() -> { + pasteImagesListener?.invoke(imageUris) + true + } + else -> pasteInterceptedText(clip) + } + } + + private fun pasteInterceptedText(clip: ClipData?, foldLargeText: Boolean = true): Boolean { + val text = if (textPasteThresholdBytes > 0) clip?.plainText() else null + if (text.isNullOrEmpty()) return false + val start = minOf(selectionStart, selectionEnd).coerceIn(0, length()) + val end = maxOf(selectionStart, selectionEnd).coerceIn(start, length()) + val exceedsInputLimit = length().toLong() - (end - start) + text.length > maxInputChars + val shouldFold = foldLargeText && ( + text.length >= textPasteThresholdBytes || + text.toByteArray(Charsets.UTF_8).size >= textPasteThresholdBytes + ) + val shouldIntercept = exceedsInputLimit || shouldFold + if (shouldIntercept) { + pasteTextListener?.invoke(text, start, end) + } + // Let EditText perform ordinary pastes, retaining its native undo history. + return shouldIntercept + } + + // coerceToText opens content: URIs synchronously. Leave URI-backed + // clipboard items to Android's normal paste path so the UI thread never + // reads an arbitrary provider just to measure a text paste. + private fun ClipData.plainText(): String? = + takeIf { itemCount > 0 } + ?.getItemAt(0) + ?.takeIf { it.uri == null } + ?.coerceToText(context) + ?.toString() + ?.takeIf(String::isNotEmpty) + + override fun onKeyShortcut(keyCode: Int, event: KeyEvent): Boolean { + if (keyCode == KeyEvent.KEYCODE_V && event.isCtrlPressed && event.isShiftPressed) { + return onTextContextMenuItem(android.R.id.pasteAsPlainText) + } + return super.onKeyShortcut(keyCode, event) } } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt new file mode 100644 index 000000000000..9838c6314680 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt @@ -0,0 +1,123 @@ +package expo.modules.t3composereditor + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +class ComposerPasteTest { + private val context = RuntimeEnvironment.getApplication() + private val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + private val editor = SelectionAwareEditText(context).apply { + textPasteThresholdBytes = 32 * 1024 + maxInputChars = 120_000 + } + + private fun pasteAsText(): Boolean = editor.onKeyShortcut( + KeyEvent.KEYCODE_V, + KeyEvent( + 0, + 0, + KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_V, + 0, + KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON + ) + ) + + @Test + fun shortcutKeepsLargeTextInlineAndUndoable() { + val pasted = "x".repeat(32 * 1024) + clipboard.setPrimaryClip(ClipData.newPlainText("test", pasted)) + editor.setText("before old after") + editor.setSelection(10, 7) + editor.pasteTextListener = { _, _, _ -> error("Inline paste must stay native") } + + assertTrue(pasteAsText()) + assertEquals("before $pasted after", editor.text.toString()) + assertTrue(editor.onTextContextMenuItem(android.R.id.undo)) + assertEquals("before old after", editor.text.toString()) + } + + @Test + fun shortcutInterceptsInputLimitOverflowWithoutChangingTheSelection() { + clipboard.setPrimaryClip(ClipData.newPlainText("test", "hello")) + editor.setText("x".repeat(119_999)) + editor.setSelection(10, 7) + var intercepted: Triple? = null + editor.pasteTextListener = { text, start, end -> intercepted = Triple(text, start, end) } + + assertTrue(pasteAsText()) + assertEquals(Triple("hello", 7, 10), intercepted) + assertEquals(119_999, editor.length()) + assertEquals(10, editor.selectionStart) + assertEquals(7, editor.selectionEnd) + } + + @Test + fun shortcutAllowsReplacementAtTheInputLimit() { + clipboard.setPrimaryClip(ClipData.newPlainText("test", "hello")) + editor.setText("x".repeat(120_000)) + editor.setSelection(12, 7) + editor.pasteTextListener = { _, _, _ -> error("Replacement fits the input limit") } + + assertTrue(pasteAsText()) + assertEquals("hello", editor.text.substring(7, 12)) + assertEquals(120_000, editor.length()) + } + + @Test + fun regularPasteStillFoldsAtTheUtf8Threshold() { + val pasted = "é".repeat(16 * 1024) + clipboard.setPrimaryClip(ClipData.newPlainText("test", pasted)) + editor.setText("old") + editor.setSelection(0, 3) + var intercepted: String? = null + editor.pasteTextListener = { text, _, _ -> intercepted = text } + + assertTrue(editor.onTextContextMenuItem(android.R.id.paste)) + assertEquals(pasted, intercepted) + assertEquals("old", editor.text.toString()) + } + + @Test + fun shortcutPastesStructuredClipboardAsText() { + clipboard.setPrimaryClip( + ClipData.newHtmlText( + "test", + "plain text", + "
plain text
" + ) + ) + editor.setSelection(0) + editor.pasteContextListener = { error("Paste as Text must not import structured context") } + + assertTrue(pasteAsText()) + assertEquals("plain text", editor.text.toString()) + } + + @Test + fun readOnlyShortcutDoesNotPasteOrEmitAnEvent() { + clipboard.setPrimaryClip(ClipData.newPlainText("test", "x".repeat(120_001))) + editor.setText("unchanged") + editor.setSelection(editor.length()) + editor.readOnly = true + var intercepted: String? = null + editor.pasteTextListener = { text, _, _ -> intercepted = text } + + assertFalse(pasteAsText()) + assertEquals("unchanged", editor.text.toString()) + assertNull(intercepted) + } +} diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index 4f0ead66e5c7..523f0d61e0b6 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -87,6 +87,12 @@ public class T3ComposerEditorModule: Module { Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in view.setSpellCheck(spellCheck) } + Prop("textPasteThresholdBytes") { (view: T3ComposerEditorView, threshold: Int) in + view.setTextPasteThresholdBytes(threshold) + } + Prop("maxInputChars") { (view: T3ComposerEditorView, maxInputChars: Int) in + view.setMaxInputChars(maxInputChars) + } Events( "onComposerChange", @@ -97,6 +103,7 @@ public class T3ComposerEditorModule: Module { "onComposerPasteImages", "onComposerContextPress", "onComposerPasteContext", + "onComposerPasteText", "onComposerContentSizeChange" ) diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 11c62f36f226..6258c81c8f97 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -86,10 +86,14 @@ private final class ComposerTextView: UITextView { var onPasteImages: (([String]) -> Void)? var onPasteContext: (([String: String]) -> Void)? + var onPasteText: ((String, NSRange) -> Void)? var clipboardFragment = "" var onAttributedMutation: (() -> Void)? var onSubmit: (() -> Void)? var isReadOnly = false + var textPasteThresholdBytes = 0 + var maxInputChars = Int.max + private var bypassTextPasteInterception = false override var keyCommands: [UIKeyCommand]? { var commands = super.keyCommands ?? [] @@ -101,6 +105,16 @@ private final class ComposerTextView: UITextView { submit.discoverabilityTitle = "Send Message" submit.wantsPriorityOverSystemBehavior = true commands.append(submit) + if textPasteThresholdBytes > 0 { + let pasteAsText = UIKeyCommand( + input: "v", + modifierFlags: [.command, .shift], + action: #selector(pasteInline(_:)) + ) + pasteAsText.discoverabilityTitle = "Paste as Text" + pasteAsText.wantsPriorityOverSystemBehavior = true + commands.append(pasteAsText) + } return commands } @@ -108,6 +122,15 @@ private final class ComposerTextView: UITextView { onSubmit?() } + @objc private func pasteInline(_ sender: UIKeyCommand) { + guard !isReadOnly else { + return + } + bypassTextPasteInterception = true + defer { bypassTextPasteInterception = false } + paste(sender) + } + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) { return false @@ -150,9 +173,28 @@ private final class ComposerTextView: UITextView { return } } + if !bypassTextPasteInterception, + let text = pasteboard.string, shouldInterceptTextPaste(text) { + onPasteText?(text, selectedRange) + return + } super.paste(sender) } + private func shouldInterceptTextPaste(_ text: String) -> Bool { + guard textPasteThresholdBytes > 0, !text.isEmpty else { return false } + let pastedLength = (text as NSString).length + if pastedLength >= textPasteThresholdBytes || text.utf8.count >= textPasteThresholdBytes { + return true + } + // Chips occupy one display character but expand to their source in the + // submitted message. Measure that source, including the replaced selection. + let sourceLength = sourceOffset(forDisplayOffset: attributedText.length) + let selectedLength = sourceOffset(forDisplayOffset: NSMaxRange(selectedRange)) - + sourceOffset(forDisplayOffset: selectedRange.location) + return sourceLength - selectedLength + pastedLength > maxInputChars + } + override func deleteBackward() { guard !isReadOnly else { return @@ -368,6 +410,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro let onComposerPasteImages = EventDispatcher() let onComposerContextPress = EventDispatcher() let onComposerPasteContext = EventDispatcher() + let onComposerPasteText = EventDispatcher() let onComposerContentSizeChange = EventDispatcher() public required init(appContext: AppContext? = nil) { @@ -387,7 +430,25 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro self?.onComposerPasteImages(["uris": urls]) } textView.onPasteContext = { [weak self] context in - self?.onComposerPasteContext(context) + guard let self else { return } + let selection = self.sourceSelection() + self.nativeEventCount += 1 + var payload: [String: Any] = context + payload["value"] = self.textView.serializedText() + payload["eventCount"] = self.nativeEventCount + payload["selection"] = ["start": selection.start, "end": selection.end] + self.onComposerPasteContext(payload) + } + textView.onPasteText = { [weak self] text, _ in + guard let self else { return } + let selection = self.sourceSelection() + self.nativeEventCount += 1 + self.onComposerPasteText([ + "value": self.textView.serializedText(), + "eventCount": self.nativeEventCount, + "text": text, + "selection": ["start": selection.start, "end": selection.end], + ]) } textView.onAttributedMutation = { [weak self] in self?.emitTextChange() @@ -596,6 +657,14 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.spellCheckingType = spellCheck ? .yes : .no } + func setTextPasteThresholdBytes(_ threshold: Int) { + textView.textPasteThresholdBytes = threshold + } + + func setMaxInputChars(_ maxInputChars: Int) { + textView.maxInputChars = maxInputChars + } + func focusEditor() { textView.becomeFirstResponder() } diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 7430e28a633e..533c13108865 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -33,6 +33,15 @@ @interface T3ContextCopyTextView : UITextView @end @implementation T3ContextCopyTextView +// Read-only text still supports selecting the entire document after selecting a word. +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender +{ + if (action == @selector(selectAll:)) { + return self.selectable && self.text.length > 0 && self.selectedRange.length < self.text.length; + } + return [super canPerformAction:action withSender:sender]; +} + - (void)copy:(id)sender { NSRange selected = self.selectedRange; diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index caf8f676f07b..04382839c909 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -226,13 +226,15 @@ function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { // The document picker types every pick as a plain file, so a picture arrives here as one. // What it *is* decides how it presents, the same way videos are already recognised below. if (attachment.type === "image" || imageMimeType(attachment) !== null) { + // A pasted-text marker does not fit the snapshot source a picture carries. + const { source: _droppedSource, ...rest } = attachment; return ( ); diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 4a5cb017c236..725b3b84389b 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -9,17 +9,16 @@ import type { ComposerEditorProps as NativeComposerEditorProps } from "../native import { appendComposerDraftAttachments, createComposerDraftContextHistory, + getComposerDraftAfterSelection, getComposerDraftSnapshot, insertComposerDraftContext, + insertComposerDraftText, rememberComposerDraftSelection, setComposerDraftContext, setComposerContextImporting, useComposerDraft, } from "../state/use-composer-drafts"; -import { - importComposerContextClipboard, - type NativeContextClipboard, -} from "../lib/composerContextClipboard"; +import { importComposerContextClipboard } from "../lib/composerContextClipboard"; import { ComposerContextSheet } from "./ComposerContextSheet"; import { AppText as Text } from "./AppText"; import { @@ -34,6 +33,14 @@ export type ComposerEditorProps = NativeComposerEditorProps & { readonly onOpenMention?: (path: string) => void; /** Documents open in the file screen; pictures, video and PDF keep their native viewers. */ readonly onOpenAttachment?: (attachment: ComposerDocumentAttachment) => void; + /** + * A resting composer is a target to type in, not a document to navigate. Its chips go inert + * so a draft full of them can still be tapped anywhere to start writing; the caller focuses + * the editor instead. Chips become live again once the composer is open. + */ + readonly chipsInert?: boolean; + /** Called instead of opening a chip while `chipsInert` is set. */ + readonly onInertChipPress?: () => void; }; export function ComposerEditor({ @@ -41,6 +48,8 @@ export function ComposerEditor({ environmentId, onOpenMention, onOpenAttachment, + chipsInert, + onInertChipPress, ...props }: ComposerEditorProps) { const draft = useComposerDraft(draftKey ?? null); @@ -68,40 +77,35 @@ export function ComposerEditor({ }, [draftKey], ); - const pasteContext = async (clipboard: NativeContextClipboard) => { + const pasteContext = async ( + clipboard: Parameters>[0], + ) => { if (!draftKey || importRef.current || props.readOnly || props.editable === false) return; + const insertion = { text: clipboard.value, ...clipboard.selection }; const controller = new AbortController(); importRef.current = controller; setImporting(true); setComposerContextImporting(draftKey, true); try { + const retained = getComposerDraftAfterSelection(draftKey, insertion); const result = await importComposerContextClipboard( clipboard, - getComposerDraftSnapshot(draftKey).attachments.length, + retained.attachments.length, controller.signal, - getComposerDraftSnapshot(draftKey).context?.records.length ?? 0, + retained.context?.records.length ?? 0, ); if (!result) { - insertComposerDraftContext(draftKey, { - text: clipboard.text, - context: { version: 1, records: [] }, - }); + insertComposerDraftText(draftKey, clipboard.text, insertion); return; } - const rejected = appendComposerDraftAttachments(draftKey, result.attachments); - const ids = new Set( - getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id), - ); - insertComposerDraftContext(draftKey, { - text: result.text, - context: { - version: 1, - records: result.context.records.filter( - (record) => !("attachmentId" in record) || ids.has(record.attachmentId), - ), - }, - }); - if (result.failures.length > 0 || rejected > 0) + if (!insertComposerDraftContext(draftKey, result, insertion)) { + Alert.alert( + "Could not paste context", + "Remove some attachments or context items from the draft, then paste again.", + ); + return; + } + if (result.failures.length > 0) Alert.alert( "Some attachments could not be copied", "Reconnect to the source environment and copy them again. References without their files are marked unavailable.", @@ -161,6 +165,10 @@ export function ComposerEditor({ onPasteContext={(clipboard) => void pasteContext(clipboard)} context={draft.context} onContextPress={(selection) => { + if (chipsInert) { + onInertChipPress?.(); + return; + } const path = composerMentionPath(selection.source, draft.context); if (path && onOpenMention) { onOpenMention(path); @@ -174,7 +182,12 @@ export function ComposerEditor({ setSelected(selection); }} onSelectionChange={(selection) => { - if (draftKey) rememberComposerDraftSelection(draftKey, props.value, selection); + if (draftKey) + rememberComposerDraftSelection( + draftKey, + getComposerDraftSnapshot(draftKey).text, + selection, + ); props.onSelectionChange?.(selection); }} /> @@ -229,4 +242,8 @@ export function ComposerEditor({ ); } -export type { ComposerEditorHandle, ComposerEditorSelection } from "../native/T3ComposerEditor"; +export type { + ComposerEditorHandle, + ComposerEditorSelection, + ComposerTextPaste, +} from "../native/T3ComposerEditor"; diff --git a/apps/mobile/src/features/files/AttachmentFileScreen.tsx b/apps/mobile/src/features/files/AttachmentFileScreen.tsx index 46365630b4d2..a1870c00462a 100644 --- a/apps/mobile/src/features/files/AttachmentFileScreen.tsx +++ b/apps/mobile/src/features/files/AttachmentFileScreen.tsx @@ -20,6 +20,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { SourceFileSurface } from "./SourceFileSurface"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; @@ -99,7 +100,7 @@ function AttachmentDocumentBody(props: { ) : null} - {table && document.rendered ? ( + {table && document.activeMode === "table" ? ( {table.truncated ? ( @@ -137,7 +138,7 @@ function AttachmentDocumentBody(props: { - ) : document.kind === "markdown" && document.rendered && props.environmentId ? ( + ) : document.activeMode === "markdown" && props.environmentId ? ( ) : ( - + )} ); @@ -175,6 +176,7 @@ function AttachmentDocumentBody(props: { export function AttachmentFileScreen(props: AttachmentFileScreenProps) { const navigation = useNavigation(); + const { appearance, setCodeWordBreak } = useAppearancePreferences(); const iconColor = useUniwindTheme()["--color-icon"]; const isAndroid = Platform.OS === "android"; const params = props.route.params; @@ -249,7 +251,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { handleBack(); }, [draftKey, handleBack, params.attachmentId]); - const { content, renderedMode, rendered, setRendered, share, sharing } = document; + const { content, renderedMode, activeMode, setRendered, share, sharing } = document; const menuActions = useMemo( () => [ @@ -271,6 +273,15 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { onPress: () => setRendered(false), } as const) : null, + content && activeMode === "source" + ? ({ + id: "word-wrap", + title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap", + icon: "text.alignleft", + inline: false, + onPress: () => setCodeWordBreak(!appearance.codeWordBreak), + } as const) + : null, content ? ({ id: "copy", @@ -312,19 +323,31 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { } as const) : null, ].filter((action) => action !== null), - [content, draftKey, removeFromDraft, renderedMode, setRendered, share, sharing, uri], + [ + appearance.codeWordBreak, + setCodeWordBreak, + content, + draftKey, + removeFromDraft, + activeMode, + renderedMode, + setRendered, + share, + sharing, + uri, + ], ); - const activeMode = rendered ? "preview" : "source"; + const selectedAction = activeMode === "source" ? "source" : "preview"; const androidMenuActions = useMemo( () => menuActions.map((action) => ({ id: action.id, title: action.title, image: action.icon, - state: action.inline ? (action.id === activeMode ? "on" : "off") : undefined, + state: action.inline ? (action.id === selectedAction ? "on" : "off") : undefined, ...("destructive" in action ? { attributes: { destructive: true } } : {}), })), - [activeMode, menuActions], + [selectedAction, menuActions], ); const handleAndroidMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { @@ -372,7 +395,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { {action.title} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 2eabce998e8e..82d69b28e841 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -2,7 +2,14 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import type { ComponentType } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { FlatList, ScrollView, Text as NativeText, useWindowDimensions, View } from "react-native"; +import { + FlatList, + RefreshControl, + ScrollView, + Text as NativeText, + useWindowDimensions, + View, +} from "react-native"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; @@ -23,13 +30,17 @@ import { NATIVE_SOURCE_CONTENT_WIDTH, nativeSourceRowId, } from "./nativeSourceFileAdapter"; -import { prepareSourceFileDocument } from "./source-file-document"; +import { MarkdownTextPrimitive } from "@t3tools/mobile-markdown-text/primitive"; + +import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; interface SourceFileSurfaceProps { readonly contents: string; readonly path: string; readonly initialLine?: number | null; + /** Keep the entire document in one native text-selection scope. */ + readonly selectable?: boolean; /** Enables native pull-to-refresh on the source surface. */ readonly onRefresh?: () => Promise | void; } @@ -129,7 +140,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { ? "ready" : "highlighting"; - return { lines, rowsJson, status, targetIndex, theme, tokens }; + return { normalizedContents, lines, rowsJson, status, targetIndex, theme, tokens }; } function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { @@ -146,17 +157,7 @@ function SourceHighlightStatusView(props: { readonly status: SourceHighlightStat return null; } -function NativeSourceFileSurface( - props: SourceFileSurfaceProps & { - readonly NativeView: ComponentType; - }, -) { - const { NativeView, onRefresh } = props; - const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); - const { themeAppearance, themeId } = useAppearancePreferences(); - const appTheme = useUniwindTheme(); - const { width: viewportWidth } = useWindowDimensions(); - const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); +function useSourceFileRefresh(onRefresh: SourceFileSurfaceProps["onRefresh"]) { const [isPullRefreshing, setIsPullRefreshing] = useState(false); const handlePullToRefresh = useCallback(async () => { if (!onRefresh) { @@ -169,6 +170,21 @@ function NativeSourceFileSurface( setIsPullRefreshing(false); } }, [onRefresh]); + return { isPullRefreshing, handlePullToRefresh }; +} + +function NativeSourceFileSurface( + props: SourceFileSurfaceProps & { + readonly NativeView: ComponentType; + }, +) { + const { NativeView, onRefresh } = props; + const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); + const { themeAppearance, themeId } = useAppearancePreferences(); + const appTheme = useUniwindTheme(); + const { width: viewportWidth } = useWindowDimensions(); + const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); + const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(onRefresh); const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); const selectedRowIdsJson = useMemo( () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), @@ -212,9 +228,18 @@ function NativeSourceFileSurface( } function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { + const foreground = useUniwindTheme()["--color-foreground"]; const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); - const { lines, status, targetIndex, tokens } = useSourceFileModel(props); + const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props); + const selectableTokens = useMemo( + () => (props.selectable ? boundedSelectableSourceTokens(tokens) : null), + [props.selectable, tokens], + ); const listRef = useRef>(null); + const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(props.onRefresh); + const refreshControl = props.onRefresh ? ( + void handlePullToRefresh()} /> + ) : undefined; useEffect(() => { if (targetIndex === null) { @@ -240,9 +265,59 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { [codeSurface, codeWordBreak, targetIndex, tokens], ); + // One selectable text for the whole file. On iOS `uiTextView` renders a real `UITextView`, + // which selects across every line, wraps, and lays out long documents through TextKit; on + // Android the primitive is an RN `Text`, which selects across its nested children. Either + // way "select all" takes the file rather than a line, which a `FlatList` row can never do + // because each row is its own selection scope. + const selectableBlock = props.selectable ? ( + + {selectableTokens + ? lines.map((line, index) => { + const lineTokens = selectableTokens[index] ?? null; + const body = + lineTokens && lineTokens.length > 0 + ? lineTokens.map((token, tokenIndex) => ( + + {token.content} + + )) + : line; + return ( + + {body} + {index < lines.length - 1 ? "\n" : ""} + + ); + }) + : normalizedContents} + + ) : null; + const list = ( String(index)} initialNumToRender={80} @@ -266,14 +341,40 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { /> ); + // Workspace files retain their numbered, virtualized rows, with or without a line target. + // Attachments opt into one selection scope for the entire document. + const usesLineList = !props.selectable; + const padded = ( + + {selectableBlock} + + ); + return ( - {codeWordBreak ? ( - list + {usesLineList ? ( + codeWordBreak ? ( + list + ) : ( + + {list} + + ) + ) : codeWordBreak ? ( + padded ) : ( + // Without wrapping the text keeps its natural width and the reader pans to it. - {list} + {padded} )} @@ -282,7 +383,11 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { export function SourceFileSurface(props: SourceFileSurfaceProps) { const NativeView = resolveNativeReviewDiffView(); - return NativeView ? ( + const { codeWordBreak } = useAppearanceCodeSurface(); + // The native canvas draws source lines without text selection or wrapping. Attachments + // need one selectable text view in either wrap mode; workspace line navigation can still + // use the canvas when wrapping is disabled. + return NativeView && !codeWordBreak && !props.selectable ? ( ) : ( diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 0e58b26bdaf5..a8a0f860cfa8 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -583,6 +583,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { useAdaptiveWorkspacePaneRole("inspector"); const navigation = useNavigation(); const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { appearance, setCodeWordBreak } = useAppearancePreferences(); const iconColor = useUniwindTheme()["--color-icon"]; const isAndroid = Platform.OS === "android"; const params = props.route.params; @@ -763,6 +764,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => setModeOverride({ path: relativePath, mode: "source" }), } as const) : null, + // Only the source body wraps; a rendered preview lays itself out. + resolvedActiveMode === "source" + ? ({ + id: "word-wrap", + title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap", + icon: "text.alignleft", + inline: false, + onPress: () => setCodeWordBreak(!appearance.codeWordBreak), + } as const) + : null, ...(mediaSource ? mediaActions.actions .filter(({ id }) => id !== "open-file") @@ -783,6 +794,17 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => copyTextWithHaptic(relativePath), } as const, ]), + // Selecting a long file by hand is painful on a phone, so copying the whole thing is + // the action most readers actually want. The attachment screen already offers it. + fileData?.contents != null + ? ({ + id: "copy-contents", + title: fileData.truncated ? "Copy preview" : "Copy contents", + icon: "doc.on.doc", + inline: false, + onPress: () => copyTextWithHaptic(fileData.contents), + } as const) + : null, isPdfFile({ name: relativePath }) && previewUri !== null ? ({ id: "open-pdf", @@ -821,6 +843,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { : null, ].filter((action) => action !== null); }, [ + appearance.codeWordBreak, + setCodeWordBreak, assetPreviewUri, assetPreview.refresh, previewUri, @@ -833,6 +857,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { resolvedActiveMode, mediaSource, mediaActions.actions, + fileData?.contents, + fileData?.truncated, ]); const androidFileMenuActions = useMemo( diff --git a/apps/mobile/src/features/files/source-file-document.test.ts b/apps/mobile/src/features/files/source-file-document.test.ts index 04b3046994ad..a490a77da9dc 100644 --- a/apps/mobile/src/features/files/source-file-document.test.ts +++ b/apps/mobile/src/features/files/source-file-document.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { prepareSourceFileDocument } from "./source-file-document"; +import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; describe("prepareSourceFileDocument", () => { it("normalizes and serializes source rows once for repeated consumers", () => { @@ -14,3 +14,14 @@ describe("prepareSourceFileDocument", () => { expect(second).toBe(first); }); }); + +it("bounds selectable highlighting without allocating spans for huge files", () => { + const token = { content: "text", color: "#fff", fontStyle: null }; + const small = [[token]]; + expect(boundedSelectableSourceTokens(small)).toBe(small); + expect(boundedSelectableSourceTokens(null)).toBeNull(); + expect(boundedSelectableSourceTokens(Array.from({ length: 20_000 }, () => [token]))).toBeNull(); + expect(boundedSelectableSourceTokens([Array.from({ length: 2_000 }, () => token)])).toBeNull(); + const longPlainText = "full contents\n".repeat(50_000); + expect(prepareSourceFileDocument(longPlainText).contents).toBe(longPlainText); +}); diff --git a/apps/mobile/src/features/files/source-file-document.ts b/apps/mobile/src/features/files/source-file-document.ts index d78ead3288fc..12f1fce099cf 100644 --- a/apps/mobile/src/features/files/source-file-document.ts +++ b/apps/mobile/src/features/files/source-file-document.ts @@ -1,3 +1,4 @@ +import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; import { buildNativeSourceRows } from "./nativeSourceFileAdapter"; const MAX_CACHED_DOCUMENTS = 8; @@ -52,3 +53,17 @@ export function prepareSourceFileDocument(contents: string): SourceFileDocument return document; } + +// A selectable document cannot virtualize its rows. Cap React spans instead; large +// attachments remain fully selectable as one plain string, including every newline. +export function boundedSelectableSourceTokens( + tokens: ReadonlyArray> | null, +): typeof tokens { + if (!tokens) return null; + let spans = tokens.length; + for (const line of tokens) { + spans += line.length; + if (spans > 2_000) return null; + } + return tokens; +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8b03a2983bfa..71403de9954b 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,4 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { + nextPastedTextFileName, + pastedTextDisposition, + replaceTextSelection, +} from "@t3tools/client-runtime/text-paste"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { CommonActions, @@ -22,11 +28,16 @@ import { useFontFamily } from "../../lib/useFontFamily"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, resolveEnvironmentMachineKind, type EnvironmentId, } from "@t3tools/contracts"; -import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; +import { + ComposerEditor, + type ComposerEditorHandle, + type ComposerTextPaste, +} from "../../components/ComposerEditor"; import { composerContextImportsAtom } from "../../state/use-composer-drafts"; import { composerContextSendBlockReason, @@ -41,7 +52,6 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { composerStripAttachments } from "../../lib/composerImages"; -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { composerAttachmentUploadBlockReason, @@ -73,13 +83,17 @@ import { import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, + createPastedTextComposerAttachment, pickComposerFiles, pickComposerMedia, + removePersistedComposerAttachmentFile, type DraftComposerFileAttachment, } from "../../lib/composerImages"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, + captureComposerDraftInsertion, + countComposerDraftAttachmentsAfterSelection, getComposerDraftSnapshot, mergeComposerDraftContent, restoreComposerDraftSnapshot, @@ -297,6 +311,12 @@ export function NewTaskDraftScreen(props: { const shareImportDraftBackupRef = useRef(new Map()); const activeShareImportTokenRef = useRef(null); const shareImportMountedRef = useRef(true); + const pendingPastedTextAttachmentCountRef = useRef(0); + const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); + const pastedTextFileNamesRef = useRef<{ draftKey: string | null; names: Set }>({ + draftKey: null, + names: new Set(), + }); const latestDraftKeyRef = useRef(flow.draftKey); const latestIncomingShareIdRef = useRef(props.incomingShareId); latestDraftKeyRef.current = flow.draftKey; @@ -344,16 +364,8 @@ export function NewTaskDraftScreen(props: { : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null; // Media needs its thumbnail; every other file already reads as its inline chip. const stripAttachments = useMemo( - () => - composerStripAttachments( - flow.attachments, - new Set( - collectComposerContextReferences(flow.prompt).map( - (occurrence) => occurrence.contextId as string, - ), - ), - ), - [flow.attachments, flow.prompt], + () => composerStripAttachments(flow.attachments), + [flow.attachments], ); const composerMenu = useComposerCommandMenu({ draftMessage: flow.prompt, @@ -903,15 +915,19 @@ export function NewTaskDraftScreen(props: { return; } const capabilities = selectedEnvironmentServerConfig?.environment.capabilities; + const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; const result = await pickComposerMedia({ - existingCount: flow.attachments.length, + existingCount: + flow.draftKey && insertion + ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion) + : flow.attachments.length, maxVideoBytes: capabilities?.attachmentUploads === true ? capabilities.fileAttachments?.maxUploadBytes : undefined, }); const rejectedCount = - result.attachments.length > 0 ? flow.appendAttachments(result.attachments) : 0; + result.attachments.length > 0 ? flow.appendAttachments(result.attachments, insertion) : 0; const problems = [ ...(result.error ? [result.error] : []), ...(rejectedCount > 0 @@ -933,11 +949,16 @@ export function NewTaskDraftScreen(props: { Alert.alert("File attachments are not available on this server."); return; } + const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; const result = await pickComposerFiles({ - existingCount: flow.attachments.length, + existingCount: + flow.draftKey && insertion + ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion) + : flow.attachments.length, maxBytes, }); - const rejectedCount = result.files.length > 0 ? flow.appendAttachments(result.files) : 0; + const rejectedCount = + result.files.length > 0 ? flow.appendAttachments(result.files, insertion) : 0; // The picker error and the live-cap rejection can both happen in one // pick; report both in a single alert. const problems = [ @@ -954,12 +975,16 @@ export function NewTaskDraftScreen(props: { const handleNativePasteImages = useCallback( async (uris: ReadonlyArray) => { try { + const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; const images = await convertPastedImagesToAttachments({ uris, - existingCount: flow.attachments.length, + existingCount: + flow.draftKey && insertion + ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion) + : flow.attachments.length, }); if (images.length > 0) { - flow.appendAttachments(images); + flow.appendAttachments(images, insertion); } } catch (error) { console.error("[native paste] error converting images", error); @@ -968,8 +993,106 @@ export function NewTaskDraftScreen(props: { [flow], ); + const handleNativePasteText = useCallback( + async (paste: ComposerTextPaste) => { + const draftKey = flow.draftKey; + if (!draftKey) return; + const insertion = { text: paste.value, ...paste.selection }; + const insertPaste = () => { + const insertion = replaceTextSelection({ + value: paste.value, + selection: paste.selection, + text: paste.text, + }); + const selection = { start: insertion.cursor, end: insertion.cursor }; + flow.setPrompt(insertion.value); + composerMenu.onSelectionChange(selection); + }; + const capabilities = selectedEnvironmentServerConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + paste.value.length - + Math.max(0, paste.selection.end - paste.selection.start) + + paste.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const canAttach = + maxBytes !== null && + countComposerDraftAttachmentsAfterSelection(draftKey, insertion) < + PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(paste.text).byteLength <= maxBytes; + if ( + pastedTextDisposition({ + text: paste.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment" + ) { + if (canAttach && maxBytes !== null) { + pendingPastedTextAttachmentCountRef.current += 1; + setPendingPastedTextAttachmentCount(pendingPastedTextAttachmentCountRef.current); + try { + if (pastedTextFileNamesRef.current.draftKey !== draftKey) { + pastedTextFileNamesRef.current = { draftKey, names: new Set() }; + } + const reservedNames = pastedTextFileNamesRef.current.names; + for (const attachment of flow.attachments) reservedNames.add(attachment.name); + const name = nextPastedTextFileName([...reservedNames]); + reservedNames.add(name); + const attachment = await createPastedTextComposerAttachment({ + text: paste.text, + name, + maxBytes, + }); + if (latestDraftKeyRef.current !== draftKey) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + return; + } + if (flow.appendAttachments([attachment], insertion) > 0) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + Alert.alert( + "Could not attach pasted text", + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + Alert.alert( + "Could not attach pasted text", + error instanceof Error ? error.message : "Try again.", + ); + } finally { + pendingPastedTextAttachmentCountRef.current = Math.max( + 0, + pendingPastedTextAttachmentCountRef.current - 1, + ); + setPendingPastedTextAttachmentCount(pendingPastedTextAttachmentCountRef.current); + } + } else if (!wouldExceedInputLimit) { + insertPaste(); + } else { + Alert.alert( + wouldExceedInputLimit + ? "Pasted text is too large for this message" + : "Could not attach pasted text", + wouldExceedInputLimit + ? "Remove some text or an attachment, then paste again." + : "Remove an attachment or use a smaller paste, then try again.", + ); + } + return; + } + + insertPaste(); + }, + [composerMenu, flow, selectedEnvironmentServerConfig], + ); + async function handleStart(): Promise { - if (voiceInput.blocksSubmission) return; + if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; if (!selectedProject || !draftKey) { @@ -1139,6 +1262,7 @@ export function NewTaskDraftScreen(props: { isIncomingShareReady && !isImportingShare && !flow.submitting && + pendingPastedTextAttachmentCount === 0 && !voiceInput.blocksSubmission && !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); const openDraftDocument = (attachment: ComposerDocumentAttachment) => { @@ -1195,6 +1319,7 @@ export function NewTaskDraftScreen(props: { onFocus={() => setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} + onPasteText={(paste) => void handleNativePasteText(paste)} placeholder="Ask anything…" singleLineCentered={false} contentInsetVertical={0} @@ -1486,13 +1611,15 @@ export function NewTaskDraftScreen(props: { 0 + ? "Attaching pasted text" + : flow.submitting + ? "Starting task" + : attachmentsUploading + ? "Queue task, sends when uploads finish" + : environmentConnected + ? "Start task" + : "Queue task") } disabled={!canStart} icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7e195510074e..680c9cf0babe 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,14 +1,19 @@ +import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAtomValue } from "@effect/atom-react"; -import type { - EnvironmentId, - MessageId, - ModelSelection, - OrchestrationThreadShell, - ProviderInteractionMode, - RuntimeMode, - ServerConfig as T3ServerConfig, - UsageLimitsReport, +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { pastedTextDisposition, replaceTextSelection } from "@t3tools/client-runtime/text-paste"; +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type EnvironmentId, + type MessageId, + type ModelSelection, + type OrchestrationThreadShell, + type ProviderInteractionMode, + type RuntimeMode, + type ServerConfig as T3ServerConfig, + type UsageLimitsReport, } from "@t3tools/contracts"; import { collectProviderUsageLimits, @@ -46,7 +51,10 @@ import Animated, { import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { composerContextImportsAtom } from "../../state/use-composer-drafts"; +import { + composerContextImportsAtom, + countComposerDraftAttachmentsAfterSelection, +} from "../../state/use-composer-drafts"; import type { ComposerDocumentAttachment } from "../../lib/composerContext"; import { useProject } from "../../state/entities"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; @@ -72,7 +80,6 @@ import { type DraftComposerAttachment, type DraftComposerFileAttachment, } from "../../lib/composerImages"; -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { buildModelOptions, groupByProvider, @@ -135,6 +142,7 @@ export interface ThreadComposerProps { readonly onPickDraftMedia: () => Promise; readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; + readonly onNativePasteText: (paste: ComposerTextPaste) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; @@ -278,6 +286,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); + const pendingPastedTextAttachmentCountRef = useRef(0); + const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: inputRef, isEditorFocused: isFocused, @@ -291,19 +301,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const [previewFile, setPreviewFile] = useState(null); const [previewVideo, setPreviewVideo] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; - // Attachment context ids are the attachment id, so the prompt alone says which attachments - // already read as an inline chip and need no strip tile. + // Only media belongs above the composer; every other file reads as its inline chip. const stripAttachments = useMemo( - () => - composerStripAttachments( - props.draftAttachments, - new Set( - collectComposerContextReferences(props.draftMessage).map( - (occurrence) => occurrence.contextId as string, - ), - ), - ), - [props.draftAttachments, props.draftMessage], + () => composerStripAttachments(props.draftAttachments), + [props.draftAttachments], ); const showStopAction = !hasContent && @@ -422,7 +423,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer states: uploadStates, }); const contextImports = useAtomValue(composerContextImportsAtom); - const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason; + const sendBlockedReason = + props.sendBlockedReason ?? + (pendingPastedTextAttachmentCount > 0 ? "Attaching pasted text" : null) ?? + attachmentBlockReason; const canSend = hasContent && !contextImports[composerOwnerKey] && @@ -478,6 +482,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onEditorFocusChange?.(false); }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); const handleSend = useCallback(async () => { + if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; // Typed out in full rather than picked from the menu. Attachments mean the // user is sending a prompt, so those go through as usual. if ( @@ -488,7 +493,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (openUsageLimits()) onChangeDraftMessage(""); return; } - if (voiceInput.blocksSubmission) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); @@ -736,6 +740,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); }} onOpenAttachment={openDraftDocument} + // A rested composer full of chips left almost nowhere to tap to start typing: + // every chip opened its file instead. Collapsed, they focus the editor. + chipsInert={!isExpanded} + onInertChipPress={() => inputRef.current?.focus()} ref={inputRef} multiline value={props.draftMessage} @@ -745,6 +753,76 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onChangeText={props.onChangeDraftMessage} onSelectionChange={composerMenu.onSelectionChange} onPasteImages={(uris) => void props.onNativePasteImages(uris)} + onPasteText={(paste) => { + const insertPaste = () => { + const insertion = replaceTextSelection({ + value: paste.value, + selection: paste.selection, + text: paste.text, + }); + const selection = { start: insertion.cursor, end: insertion.cursor }; + props.onChangeDraftMessage(insertion.value); + composerMenu.onSelectionChange(selection); + }; + const capabilities = props.serverConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined + ? null + : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + paste.value.length - + Math.max(0, paste.selection.end - paste.selection.start) + + paste.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const canAttach = + maxBytes !== null && + countComposerDraftAttachmentsAfterSelection(composerOwnerKey, { + text: paste.value, + ...paste.selection, + }) < PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(paste.text).byteLength <= maxBytes; + if ( + pastedTextDisposition({ + text: paste.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment" + ) { + if (canAttach) { + pendingPastedTextAttachmentCountRef.current += 1; + setPendingPastedTextAttachmentCount( + pendingPastedTextAttachmentCountRef.current, + ); + const finishAttachment = () => { + pendingPastedTextAttachmentCountRef.current = Math.max( + 0, + pendingPastedTextAttachmentCountRef.current - 1, + ); + setPendingPastedTextAttachmentCount( + pendingPastedTextAttachmentCountRef.current, + ); + }; + void props.onNativePasteText(paste).then(finishAttachment, finishAttachment); + } else if (!wouldExceedInputLimit) { + insertPaste(); + } else { + Alert.alert( + wouldExceedInputLimit + ? "Pasted text is too large for this message" + : "Could not attach pasted text", + wouldExceedInputLimit + ? "Remove some text or an attachment, then paste again." + : "Remove an attachment or use a smaller paste, then try again.", + ); + } + return; + } + insertPaste(); + }} placeholder={props.placeholder} onFocus={handleFocus} onBlur={handleBlur} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 55cd9e9a6c83..2f4ee67a187e 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { appendCodexArtifactTemplateUsePrompt, @@ -155,6 +156,7 @@ export interface ThreadDetailScreenProps { readonly onPickDraftMedia: () => Promise; readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; + readonly onNativePasteText: (paste: ComposerTextPaste) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; @@ -1049,6 +1051,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onPickDraftMedia={props.onPickDraftMedia} onPickDraftFiles={props.onPickDraftFiles} onNativePasteImages={props.onNativePasteImages} + onNativePasteText={props.onNativePasteText} onRemoveDraftImage={props.onRemoveDraftImage} onStopThread={props.onStopThread} onSendMessage={handleSendMessage} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index bb78e189bf43..f22540e768d9 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -891,6 +891,7 @@ function ThreadRouteContent( onPickDraftMedia={composer.onPickDraftMedia} onPickDraftFiles={composer.onPickDraftFiles} onNativePasteImages={composer.onNativePasteImages} + onNativePasteText={composer.onNativePasteText} onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} onStopThread={handleStopThread} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index f6ad273d9037..5c74e52cfac1 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -44,6 +44,7 @@ import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; import { appendComposerDraftAttachments, + type ComposerDraftInsertion, clearComposerDraft, composerDraftsAtom, createNewTaskDraft, @@ -203,7 +204,10 @@ type NewTaskFlowContextValue = { readonly setPrompt: (value: string) => void; readonly replaceAttachments: (attachments: ReadonlyArray) => void; /** Appends draft attachments; returns how many the live cap rejected. */ - readonly appendAttachments: (attachments: ReadonlyArray) => number; + readonly appendAttachments: ( + attachments: ReadonlyArray, + insertion?: ComposerDraftInsertion, + ) => number; readonly removeAttachment: (imageId: string) => void; readonly clearAttachments: () => void; readonly setSubmitting: (value: boolean) => void; @@ -600,12 +604,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // Returns how many attachments the live cap rejected so the caller can // tell the user (a concurrent add can fill the draft mid-pick). const appendAttachments = useCallback( - (nextAttachments: ReadonlyArray): number => { + ( + nextAttachments: ReadonlyArray, + insertion?: ComposerDraftInsertion, + ): number => { if (!selectedProjectDraftKey) { return 0; } return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments, { appendReference: true, + insertion, }); }, [selectedProjectDraftKey], diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index 417a66138d95..1986a3266fa9 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -90,6 +90,13 @@ describe("appearancePreferences", () => { expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true); }); + it("preserves the no-wrap default unless wrapping is explicitly enabled", () => { + expect(resolveAppearancePreferences(undefined).codeWordBreak).toBe(false); + expect(resolveAppearancePreferences({}).codeWordBreak).toBe(false); + expect(resolveAppearancePreferences({ codeWordBreak: null }).codeWordBreak).toBe(false); + expect(resolveAppearancePreferences({ codeWordBreak: false }).codeWordBreak).toBe(false); + }); + it("returns the authored text scale at the 16pt default", () => { expect(DEFAULT_BASE_FONT_SIZE).toBe(16); diff --git a/apps/mobile/src/lib/attachmentDocument.ts b/apps/mobile/src/lib/attachmentDocument.ts index b7b8397cc6ec..b371523a89c2 100644 --- a/apps/mobile/src/lib/attachmentDocument.ts +++ b/apps/mobile/src/lib/attachmentDocument.ts @@ -11,6 +11,7 @@ import type { FileBackedComposerAttachment } from "./composerImages"; import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; import { useRefreshAssetUrl } from "../state/assets"; +import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation"; const isLocalUri = (uri: string) => /^(file|content):/.test(uri); @@ -32,8 +33,6 @@ export function useAttachmentDocument(input: { }) { const kind = filePreviewKind(input); const delimiter = filePreviewDelimiter(input); - const renderedMode = - kind === "markdown" ? "markdown" : kind === "html" ? "html" : delimiter ? "table" : null; const shareController = useRef(null); useEffect(() => () => shareController.current?.abort(), []); const resource = useMemo( @@ -61,6 +60,12 @@ export function useAttachmentDocument(input: { const [contentError, setContentError] = useState(null); const textReadUrl = useRef<{ uri: string; authorizedAt: number } | null>(null); const [rendered, setRendered] = useState(true); + const presentation = attachmentDocumentPresentation({ + kind, + hasTable: table !== null, + hasEnvironment: input.environmentId !== null, + rendered, + }); const [revision, setRevision] = useState(0); const [sharing, setSharing] = useState(false); const uri = input.attachment ? localUri : remoteUri; @@ -185,7 +190,7 @@ export function useAttachmentDocument(input: { }; return { kind, - renderedMode, + ...presentation, uri, /** Native viewers resolve their own fresh URL from this instead of reusing `uri`. */ resource, diff --git a/apps/mobile/src/lib/attachmentDocumentPresentation.test.ts b/apps/mobile/src/lib/attachmentDocumentPresentation.test.ts new file mode 100644 index 000000000000..9f63567cfcd2 --- /dev/null +++ b/apps/mobile/src/lib/attachmentDocumentPresentation.test.ts @@ -0,0 +1,71 @@ +import { expect, it } from "vite-plus/test"; +import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation"; + +it.each([ + { + kind: "markdown", + hasTable: false, + hasEnvironment: false, + rendered: true, + renderedMode: null, + activeMode: "source", + }, + { + kind: "markdown", + hasTable: false, + hasEnvironment: true, + rendered: true, + renderedMode: "markdown", + activeMode: "markdown", + }, + { + kind: "markdown", + hasTable: false, + hasEnvironment: true, + rendered: false, + renderedMode: "markdown", + activeMode: "source", + }, + { + kind: "text", + hasTable: false, + hasEnvironment: true, + rendered: true, + renderedMode: null, + activeMode: "source", + }, + { + kind: "text", + hasTable: true, + hasEnvironment: false, + rendered: true, + renderedMode: "table", + activeMode: "table", + }, + { + kind: "text", + hasTable: true, + hasEnvironment: true, + rendered: false, + renderedMode: "table", + activeMode: "source", + }, + { + kind: "html", + hasTable: false, + hasEnvironment: false, + rendered: true, + renderedMode: "html", + activeMode: "html", + }, + { + kind: "html", + hasTable: false, + hasEnvironment: true, + rendered: false, + renderedMode: "html", + activeMode: "source", + }, +] as const)("matches the available preview for %j", ({ renderedMode, activeMode, ...input }) => { + expect(attachmentDocumentPresentation(input)).toEqual({ renderedMode, activeMode }); +}); diff --git a/apps/mobile/src/lib/attachmentDocumentPresentation.ts b/apps/mobile/src/lib/attachmentDocumentPresentation.ts new file mode 100644 index 000000000000..0d8136262ae9 --- /dev/null +++ b/apps/mobile/src/lib/attachmentDocumentPresentation.ts @@ -0,0 +1,21 @@ +import type { FilePreviewKind } from "@t3tools/shared/filePreview"; + +/** The available preview and selected body must agree, including source-only draft files. */ +export function attachmentDocumentPresentation(input: { + kind: FilePreviewKind; + hasTable: boolean; + hasEnvironment: boolean; + rendered: boolean; +}) { + const renderedMode = input.hasTable + ? "table" + : input.kind === "markdown" && input.hasEnvironment + ? "markdown" + : input.kind === "html" + ? "html" + : null; + return { + renderedMode, + activeMode: input.rendered && renderedMode !== null ? renderedMode : "source", + } as const; +} diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index ea56d3f50808..2c6b27864432 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -360,7 +360,14 @@ describe("prepareTurnAttachments", () => { }); it("uploads generic file bytes directly and keeps mixed attachment order", async () => { - const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] }); + const pastedFile = { + ...file, + source: { _tag: "pasted-text" as const }, + }; + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [pastedFile, image], + }); expect(mocks.upload).toHaveBeenCalledWith( "file:///documents/report.pdf", @@ -379,11 +386,12 @@ describe("prepareTurnAttachments", () => { name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, + source: { _tag: "pasted-text" }, }); expect(prepared.attachments[1]?.type).toBe("image"); expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]); expect(prepared.draftAttachments[0]).toEqual({ - ...file, + ...pastedFile, uploadedAttachmentId: MINTED_ID, uploadEnvironmentId: environmentId, }); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index 815fc9826b34..10f4fdb7c6c7 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -198,7 +198,11 @@ function uploadedReference( // chat view with nothing to show a thumbnail from, on every client. return isComposerImageAttachment(attachment) ? { type: "image", ...fields } - : { type: "file", ...fields }; + : { + type: "file", + ...fields, + ...(attachment.source ? { source: attachment.source } : {}), + }; } function attachmentUploadInput(attachment: DraftComposerAttachment) { diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts index 41aced8c98e5..bf87e1e000da 100644 --- a/apps/mobile/src/lib/composer-image-schema.ts +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, PastedTextAttachmentSource } from "@t3tools/contracts"; export const DraftComposerImageAttachmentSchema = Schema.Struct({ id: Schema.String, @@ -29,6 +29,7 @@ export const DraftComposerFileAttachmentSchema = Schema.Struct({ mimeType: Schema.String, sizeBytes: Schema.Number, fileUri: Schema.String, + source: Schema.optional(PastedTextAttachmentSource), uploadedAttachmentId: Schema.optional(Schema.String), uploadEnvironmentId: Schema.optional(EnvironmentId), }); diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 1eab26588286..3e05c578f3f3 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -1,14 +1,28 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; -const files = new Map(); +const files = new Map(); + +const clipboard = vi.hoisted(() => ({ + hasImageAsync: vi.fn(), + getImageAsync: vi.fn(), + hasStringAsync: vi.fn(), + getStringAsync: vi.fn(), +})); + +vi.mock("expo-clipboard", () => clipboard); vi.mock("expo-file-system", () => ({ File: class { readonly uri: string; + readonly name: string; + readonly parentDirectory: { readonly uri: string }; - constructor(uri: string) { - this.uri = uri; + constructor(parent: string | { readonly uri: string }, name?: string) { + const parentUri = typeof parent === "string" ? parent : parent.uri; + this.uri = name ? `${parentUri}/${name}` : parentUri; + this.name = name ?? this.uri.split("/").at(-1) ?? "file"; + this.parentDirectory = { uri: this.uri.slice(0, -(this.name.length + 1)) }; } get exists(): boolean { @@ -29,14 +43,90 @@ vi.mock("expo-file-system", () => ({ entry.deleted = true; } } + + create(): void { + files.set(this.uri, { base64: "", deleted: false }); + } + + write(text: string): void { + files.set(this.uri, { base64: "", deleted: false, text }); + } + + moveSync(destination: { readonly uri: string }): void { + const entry = files.get(this.uri); + if (!entry) throw new Error("missing staged file"); + files.set(destination.uri, entry); + files.delete(this.uri); + } }, + Directory: class { + readonly uri: string; + + constructor(parent: string, name: string) { + this.uri = `${parent}/${name}`; + } + + create(): void {} + }, + Paths: { document: "file:///documents" }, })); vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id", })); -import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; +import { + convertPastedImagesToAttachments, + createPastedTextComposerAttachment, + isOwnedPastedImageUri, + pasteComposerClipboard, +} from "./composerImages"; + +describe("composer clipboard paste", () => { + beforeEach(() => { + vi.clearAllMocks(); + clipboard.hasImageAsync.mockResolvedValue(false); + clipboard.hasStringAsync.mockResolvedValue(true); + clipboard.getStringAsync.mockResolvedValue("clipboard text"); + clipboard.getImageAsync.mockResolvedValue({ data: "data:image/png;base64,aGVsbG8=" }); + }); + + it("returns only the image when the clipboard contains both image and text", async () => { + clipboard.hasImageAsync.mockResolvedValue(true); + const result = await pasteComposerClipboard({ existingCount: 0 }); + expect(result).toEqual({ + images: [expect.objectContaining({ type: "image", name: "pasted-image.png" })], + text: null, + error: null, + }); + expect(clipboard.getStringAsync).not.toHaveBeenCalled(); + }); + + it("does not paste alternate text when the image cannot fit", async () => { + clipboard.hasImageAsync.mockResolvedValue(true); + expect( + await pasteComposerClipboard({ existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS }), + ).toEqual({ images: [], text: null, error: expect.stringContaining("up to") }); + expect(clipboard.getStringAsync).not.toHaveBeenCalled(); + }); + + it("returns plain text without image chips", async () => { + expect(await pasteComposerClipboard({ existingCount: 0 })).toEqual({ + images: [], + text: "clipboard text", + error: null, + }); + }); + + it("reports an empty text clipboard", async () => { + clipboard.getStringAsync.mockResolvedValue(""); + expect(await pasteComposerClipboard({ existingCount: 0 })).toEqual({ + images: [], + text: null, + error: "Clipboard is empty.", + }); + }); +}); describe("native pasted image cleanup", () => { beforeEach(() => { @@ -91,6 +181,26 @@ describe("native pasted image cleanup", () => { expect(files.get(overflow)?.deleted).toBe(true); expect(files.get(userOwned)?.deleted).toBe(false); }); + + it("persists folded text unchanged in the app-owned attachment directory", async () => { + const text = "first line\nUnicode: 🙂\n"; + const attachment = await createPastedTextComposerAttachment({ + text, + name: "pasted-text.txt", + maxBytes: 1024, + }); + + expect(attachment).toEqual({ + id: "attachment-id", + type: "file", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: new TextEncoder().encode(text).byteLength, + fileUri: "file:///documents/t3-composer-attachments/attachment-id-pasted-text.txt", + source: { _tag: "pasted-text" }, + }); + expect(files.get(attachment.fileUri)?.text).toBe(text); + }); }); describe("composerStripAttachments", () => { @@ -119,21 +229,39 @@ describe("composerStripAttachments", () => { fileUri: "file:///notes.txt", }; - it("keeps media even when it already has an inline chip", async () => { + it("keeps media, because a thumbnail is the only way to see it", async () => { const { composerStripAttachments } = await import("./composerImages"); - const kept = composerStripAttachments([image, video] as never, new Set(["img-1", "vid-1"])); - // A thumbnail is the only way to see media, so it stays regardless of the chip. + const kept = composerStripAttachments([image, video] as never); expect(kept.map((a) => a.id)).toEqual(["img-1", "vid-1"]); }); - it("drops a plain file once its inline chip represents it", async () => { + it("never shows a non-media file above the composer", async () => { const { composerStripAttachments } = await import("./composerImages"); - expect(composerStripAttachments([doc] as never, new Set(["doc-1"]))).toEqual([]); + // A document reads as its inline chip. A tile with a generic glyph says less than the + // chip does, so it is not a fallback worth having, chip present or not. + expect(composerStripAttachments([doc] as never)).toEqual([]); + }); + + it("keeps media beside a document rather than dropping the whole strip", async () => { + const { composerStripAttachments } = await import("./composerImages"); + expect(composerStripAttachments([doc, image, video] as never).map((a) => a.id)).toEqual([ + "img-1", + "vid-1", + ]); }); - it("keeps a plain file that has no inline chip", async () => { + it("treats a picture picked through the document picker as media", async () => { const { composerStripAttachments } = await import("./composerImages"); - expect(composerStripAttachments([doc] as never, new Set()).map((a) => a.id)).toEqual(["doc-1"]); + // The document picker types every pick as a plain file; what it *is* decides the strip. + const pickedImage = { + id: "pick-1", + type: "file" as const, + name: "photo.png", + mimeType: "image/png", + sizeBytes: 30, + fileUri: "file:///photo.png", + }; + expect(composerStripAttachments([pickedImage] as never).map((a) => a.id)).toEqual(["pick-1"]); }); }); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 86658db8dc3b..391bf633e557 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -8,6 +8,7 @@ import { PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type EnvironmentId, + type PastedTextAttachmentSource, type UploadChatImageAttachment, } from "@t3tools/contracts"; import type { DocumentPickerResult } from "expo-document-picker"; @@ -21,6 +22,7 @@ import { imageMimeType } from "@t3tools/shared/image"; import { videoMimeType } from "@t3tools/shared/video"; import { beginForegroundHandoff } from "./foreground-handoff"; import { uuidv4 } from "./uuid"; +import { writeFileAtomically } from "./atomic-file"; export interface DraftComposerImageAttachment extends Omit { readonly id: string; @@ -40,27 +42,59 @@ export interface DraftComposerFileAttachment { readonly mimeType: string; readonly sizeBytes: number; readonly fileUri: string; + readonly source?: PastedTextAttachmentSource; readonly uploadedAttachmentId?: string; readonly uploadEnvironmentId?: EnvironmentId; } +export async function createPastedTextComposerAttachment(input: { + readonly text: string; + readonly name: string; + readonly maxBytes: number; +}): Promise { + const bytes = new TextEncoder().encode(input.text).byteLength; + if (bytes <= 0) { + throw new Error("Clipboard is empty."); + } + if (bytes > input.maxBytes) { + throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes)); + } + + const { Directory, File, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, COMPOSER_ATTACHMENT_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const file = new File(directory, `${uuidv4()}-${input.name}`); + await writeFileAtomically(file, input.text); + return { + id: uuidv4(), + type: "file", + name: input.name, + mimeType: "text/plain;charset=utf-8", + sizeBytes: bytes, + fileUri: file.uri, + source: { _tag: "pasted-text" }, + }; +} + export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment; /** - * What the strip above the composer shows. Media previews there because a thumbnail is the - * only way to see it; every other file is already legible as its inline chip, so it only - * falls back to the strip when the prompt carries no reference to it. Mirrors web's - * `composerOtherFilesForPresentation`. + * What the strip above the composer shows: media, and nothing else. A thumbnail is the only + * way to see a picture or a video, so those always preview there. Everything else reads as + * its inline chip, which carries the name, the type and the size in the line of prose the + * file belongs to — a square tile showing a generic document glyph says strictly less. + * + * The chip is not optional for a non-media file. Every path that attaches one also writes + * its reference, so a file with no chip means the draft lost it rather than that the strip + * should stand in. Which attachments carry a chip is therefore not consulted at all; surfaces + * whose attachments never get chips (a question answer) pass them to the strip directly + * instead of through this filter. */ export function composerStripAttachments( attachments: ReadonlyArray, - inlineAttachmentIds: ReadonlySet, ): ReadonlyArray { return attachments.filter( - (attachment) => - isComposerImageAttachment(attachment) || - videoMimeType(attachment) !== null || - !inlineAttachmentIds.has(attachment.id), + (attachment) => isComposerImageAttachment(attachment) || videoMimeType(attachment) !== null, ); } @@ -539,11 +573,19 @@ export async function pickComposerMedia(input: { }; } -export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<{ - readonly images: ReadonlyArray; - readonly text: string | null; - readonly error: string | null; -}> { +/** Clipboard images take priority over their alternate text representation. */ +export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise< + | { + readonly images: ReadonlyArray; + readonly text: null; + readonly error: string | null; + } + | { + readonly images: readonly []; + readonly text: string; + readonly error: null; + } +> { let clipboard: Awaited>; try { clipboard = await loadClipboard(); @@ -605,8 +647,7 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu const text = await clipboard.getStringAsync(); return { images: [], - text: text.length > 0 ? text : null, - error: text.length > 0 ? null : "Clipboard is empty.", + ...(text.length > 0 ? { text, error: null } : { text: null, error: "Clipboard is empty." }), }; } diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index a9b3111aa67a..eecae6660ab9 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -1,3 +1,5 @@ +import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { composerContextEditorTokens } from "../lib/composerContext"; import { requireNativeView } from "expo"; @@ -52,6 +54,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ readonly uris: ReadonlyArray; }>; +type NativePasteTextEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly eventCount: number; + readonly text: string; + readonly selection: ComposerEditorSelection; +}>; + interface NativeComposerEditorRef { focus: () => Promise; blur: () => Promise; @@ -81,8 +90,11 @@ interface NativeComposerEditorProps extends ViewProps { event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, ) => void; readonly onComposerPasteContext?: ( - event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>, + event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>, ) => void; + readonly textPasteThresholdBytes: number; + readonly maxInputChars: number; + readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; readonly onComposerSubmit?: () => void; @@ -108,6 +120,7 @@ export function ComposerEditor({ onChangeText, onSelectionChange, onPasteImages, + onPasteText, onFocus, onBlur, onSubmit, @@ -282,6 +295,8 @@ export function ComposerEditor({ autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} + textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0} + maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} style={style as StyleProp} onComposerChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -315,7 +330,36 @@ export function ComposerEditor({ }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} - onComposerPasteContext={(event) => props.onPasteContext?.(event.nativeEvent)} + onComposerPasteContext={(event) => { + const paste = event.nativeEvent; + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + props.onPasteContext?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender((sequence) => sequence + 1); + }} + onComposerPasteText={(event) => { + const paste = event.nativeEvent; + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + // Synchronize the draft before an async paste captures its insertion target. + // React props can still precede the last native keystroke. + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + onPasteText?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender((sequence) => sequence + 1); + }} onComposerFocus={onFocus} onComposerBlur={onBlur} onComposerSubmit={onSubmit} @@ -327,4 +371,5 @@ export type { ComposerEditorHandle, ComposerEditorProps, ComposerEditorSelection, + ComposerTextPaste, } from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 9aa7e9e72814..92a4e1e2c180 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -1,3 +1,5 @@ +import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { composerContextEditorTokens } from "../lib/composerContext"; import { requireNativeView } from "expo"; @@ -55,6 +57,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ readonly uris: ReadonlyArray; }>; +type NativePasteTextEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly eventCount: number; + readonly text: string; + readonly selection: ComposerEditorSelection; +}>; + interface NativeComposerEditorRef { focus: () => Promise; blur: () => Promise; @@ -85,8 +94,11 @@ interface NativeComposerEditorProps extends ViewProps { event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, ) => void; readonly onComposerPasteContext?: ( - event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>, + event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>, ) => void; + readonly textPasteThresholdBytes: number; + readonly maxInputChars: number; + readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; } @@ -111,6 +123,7 @@ export function ComposerEditor({ onChangeText, onSelectionChange, onPasteImages, + onPasteText, onFocus, onBlur, contentInsetVertical = 0, @@ -291,6 +304,8 @@ export function ComposerEditor({ autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} + textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0} + maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} style={{ flex: 1, minHeight: 0 }} onComposerChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -325,7 +340,36 @@ export function ComposerEditor({ }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} - onComposerPasteContext={(event) => props.onPasteContext?.(event.nativeEvent)} + onComposerPasteContext={(event) => { + const paste = event.nativeEvent; + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + props.onPasteContext?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender((sequence) => sequence + 1); + }} + onComposerPasteText={(event) => { + const paste = event.nativeEvent; + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + // Synchronize the draft before an async paste captures its insertion target. + // React props can still precede the last native keystroke. + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + onPasteText?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender((sequence) => sequence + 1); + }} onComposerFocus={onFocus} onComposerBlur={onBlur} /> @@ -337,4 +381,5 @@ export type { ComposerEditorHandle, ComposerEditorProps, ComposerEditorSelection, + ComposerTextPaste, } from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 07a409c9a48f..9ff7f41a6eba 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -12,6 +12,7 @@ export function ComposerEditor({ skills: _skills, selection, onPasteImages, + onPasteText: _onPasteText, style, textStyle, contentInsetVertical = 0, @@ -65,4 +66,5 @@ export type { ComposerEditorHandle, ComposerEditorProps, ComposerEditorSelection, + ComposerTextPaste, } from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index dc1448e87d76..8985add81925 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -7,6 +7,13 @@ export type ComposerEditorSelection = { readonly end: number; }; +export type ComposerTextPaste = { + readonly value: string; + readonly eventCount: number; + readonly text: string; + readonly selection: ComposerEditorSelection; +}; + export interface ComposerEditorHandle { focus: () => void; blur: () => void; @@ -18,11 +25,12 @@ export interface ComposerEditorProps { readonly value: string; readonly context?: OrchestrationMessageContext; readonly clipboardFragment?: string; - readonly onPasteContext?: (clipboard: { - readonly text: string; - readonly fragment: string; - readonly html: string; - }) => void; + readonly onPasteContext?: ( + clipboard: ComposerTextPaste & { + readonly fragment: string; + readonly html: string; + }, + ) => void; readonly skills?: ReadonlyArray< Pick & Partial> @@ -50,6 +58,7 @@ export interface ComposerEditorProps { readonly start: number; readonly end: number; }) => void; + readonly onPasteText?: (paste: ComposerTextPaste) => void; readonly onFocus?: () => void; readonly onBlur?: () => void; /** Invoked by the native editor when Command-Return is pressed on a hardware keyboard. */ diff --git a/apps/mobile/src/native/composerEditorRevision.test.ts b/apps/mobile/src/native/composerEditorRevision.test.ts index ccc2214e24c2..30f3ba23eef8 100644 --- a/apps/mobile/src/native/composerEditorRevision.test.ts +++ b/apps/mobile/src/native/composerEditorRevision.test.ts @@ -192,3 +192,25 @@ describe("assumeComposerControlledState", () => { ); }); }); + +describe("typing immediately before an intercepted paste", () => { + it("keeps the pre-paste React value behind the native paste revision", () => { + const snapshots = [ + { eventCount: 0, value: "", selection: { start: 0, end: 0 } }, + { eventCount: 1, value: "typed", selection: { start: 5, end: 5 } }, + { eventCount: 2, value: "typed", selection: { start: 0, end: 5 } }, + ]; + // Both native platforms stamp the paste with its current value and selection. + // A render still carrying the typing caret cannot overwrite that selection. + expect(resolveComposerControlledEventCount("typed", { start: 5, end: 5 }, 2, snapshots)).toBe( + 1, + ); + expect(isComposerNativeEcho("typed", { start: 0, end: 5 }, 2, snapshots)).toBe(true); + // The replacement is a parent edit at the acknowledged paste revision. + expect(resolveComposerControlledEventCount("pasted", { start: 6, end: 6 }, 2, snapshots)).toBe( + 2, + ); + expect(isComposerNativeEcho("pasted", { start: 6, end: 6 }, 2, snapshots)).toBe(false); + expect(acknowledgeComposerNativeEvent(2, 1)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index a97d84d256c4..6da72860009e 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -148,10 +148,14 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ })); import type { DraftComposerAttachment } from "../lib/composerImages"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; import { appAtomRegistry } from "./atom-registry"; import { threadOutboxManager } from "./thread-outbox"; import { appendComposerDraftAttachments, + captureComposerDraftInsertion, + countComposerDraftAttachmentsAfterSelection, + getComposerDraftAfterSelection, archiveCloudComposerDrafts, clearComposerDraftContent, clearComposerDraftContentState, @@ -181,6 +185,7 @@ import { retargetNewTaskDraft, setComposerDraftText, insertComposerDraftContext, + insertComposerDraftText, rememberComposerDraftSelection, setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, @@ -233,6 +238,81 @@ function contextDraft(start: number, count: number): ComposerDraft { } describe("mobile composer drafts", () => { + it.each([false, true])( + "restores visible file chips from legacy drafts (archived: %s)", + async (archived) => { + const file = { + type: "file" as const, + id: "legacy-file", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 10, + fileUri: "file:///notes.txt", + }; + const image = { ...file, id: "photo", name: "photo.png", mimeType: "image/png" }; + const video = { ...file, id: "video", name: "clip.mp4", mimeType: "video/mp4" }; + const legacy = { text: "Review these", attachments: [file, image, video] }; + const document = { + schemaVersion: 1, + drafts: archived ? {} : { thread: legacy }, + ...(archived + ? { signedOutDrafts: { account: { drafts: { thread: legacy }, queuedMessages: [] } } } + : {}), + }; + const decoded = decodePersistedComposerState(document); + const restored = archived + ? decoded.cloudDrafts.signedOut.account?.drafts.thread + : decoded.drafts.thread; + expect(restored?.text).toBe("Review these [notes.txt](t3-context://v1/file/legacy-file) "); + expect(restored?.attachments).toEqual(legacy.attachments); + expect(restored?.context?.records).toEqual([ + expect.objectContaining({ kind: "file", attachmentId: file.id }), + ]); + expect( + decodePersistedComposerState({ schemaVersion: 1, drafts: { thread: restored } }).drafts + .thread, + ).toEqual(restored); + appAtomRegistry.set(composerDraftsAtom, { thread: restored! }); + setComposerDraftText("thread", "Review these"); + expect(getComposerDraftSnapshot("thread").attachments).toEqual([image, video]); + await releaseUnusedComposerAttachmentFiles([file]); + }, + ); + + it("restores a missing file reference without duplicating its record or replacing another record's id", () => { + const file = { + type: "file" as const, + id: "file", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 10, + fileUri: "file:///notes.txt", + }; + const existing = { + version: 1, + contextId: "original", + kind: "file", + label: file.name, + attachmentId: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }; + const skill = { version: 1, contextId: "file", kind: "skill", label: "Skill", name: "skill" }; + for (const records of [[existing, skill], [skill]]) { + const restored = decodePersistedComposerState({ + schemaVersion: 1, + drafts: { + thread: { text: "", attachments: [file], context: { version: 1, records } }, + }, + }).drafts.thread; + expect(restored?.context?.records).toHaveLength(2); + expect(restored?.context?.records).toContainEqual(skill); + expect(restored?.text).toBe( + `[notes.txt](t3-context://v1/file/${records.length === 2 ? "original" : "file_2"}) `, + ); + } + }); it.each([false, true])( "restores deleted file chips and releases undo history (uploaded: %s)", async (uploaded) => { @@ -303,6 +383,30 @@ describe("mobile composer drafts", () => { expect(reloaded?.context?.records[0]?.label.length).toBeLessThanOrEqual(200); }); + it("gives a folded paste a chip that survives the send", () => { + const key = "environment-1:thread-1"; + // What `createPastedTextComposerAttachment` produces for a long paste. + const pasted = { + type: "file" as const, + id: "pasted-1", + name: "pasted-text.txt", + mimeType: "text/plain", + sizeBytes: 40_000, + fileUri: "file:///pasted-text.txt", + }; + appendComposerDraftAttachments(key, [pasted], { appendReference: true }); + + const draft = getComposerDraftSnapshot(key); + // Visible in the composer before sending, not only once the message lands. + expect(draft.text).toContain("pasted-text.txt"); + expect(draft.context?.records).toMatchObject([ + { kind: "file", attachmentId: pasted.id, name: pasted.name }, + ]); + // The reference points at the record, so the chip stays a chip in the sent message. + const [record] = draft.context?.records ?? []; + expect(draft.text).toContain(String(record?.contextId)); + }); + it("drops chips and records for attachments a replace no longer keeps", () => { const key = "new-task:draft-1"; const kept = { @@ -426,41 +530,57 @@ describe("mobile composer drafts", () => { expect(reloaded.cloudDrafts.signedOut).toEqual({}); }); - it("removes a file only after its last reference is deleted, while retaining images", async () => { - const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); - onTestFinished(() => outboxLoad.mockRestore()); - const cleanup = Promise.withResolvers(); - composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { - cleanup.resolve(); - }); - const key = "environment-1:remove-context-files"; - const file = { - id: "file-1", - type: "file" as const, - name: "notes.txt", - mimeType: "text/plain", - sizeBytes: 4, - fileUri: "file:///notes.txt", - }; - const image = { - ...file, - id: "image-1", - type: "image" as const, - name: "image.png", - mimeType: "image/png", - fileUri: "file:///image.png", - previewUri: "file:///image.png", - }; - appendComposerDraftAttachments(key, [file, image], { appendReference: true }); - const fileLink = "[notes.txt](t3-context://v1/file/file-1)"; - setComposerDraftText(key, `${fileLink} ${fileLink}`); - expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); - setComposerDraftText(key, fileLink); - expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); - setComposerDraftText(key, "plain text"); - expect(getComposerDraftSnapshot(key).attachments).toEqual([image]); - await cleanup.promise; - }); + it.each([ + { name: "notes.txt", mimeType: "text/plain" }, + { name: "document-photo.png", mimeType: "image/png" }, + { name: "recording.mp4", mimeType: "video/mp4" }, + ])( + "removes $name after its last reference is deleted, while retaining native images", + async ({ name, mimeType }) => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const cleanup = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + cleanup.resolve(); + }); + const key = "environment-1:remove-context-files"; + const file = { + id: "file-1", + type: "file" as const, + name, + mimeType, + sizeBytes: 4, + fileUri: `file:///${name}`, + }; + const image = { + ...file, + id: "image-1", + type: "image" as const, + name: "image.png", + mimeType: "image/png", + fileUri: "file:///image.png", + previewUri: "file:///image.png", + }; + appendComposerDraftAttachments(key, [file, image], { appendReference: true }); + const fileLink = formatComposerContextReference( + getComposerDraftSnapshot(key).context!.records[0]!, + ); + setComposerDraftText(key, `${fileLink} ${fileLink}`); + expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); + setComposerDraftText(key, fileLink); + expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); + expect( + countComposerDraftAttachmentsAfterSelection(key, { + text: fileLink, + start: 0, + end: fileLink.length, + }), + ).toBe(1); + setComposerDraftText(key, "plain text"); + expect(getComposerDraftSnapshot(key).attachments).toEqual([image]); + await cleanup.promise; + }, + ); it("rejects attachments atomically when no context slots remain", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); @@ -506,6 +626,190 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(key)).toEqual(before); await cleanup.promise; }); + it.each(["environment-1:thread", "environment-1:new-task:draft"])( + "preserves the captured paste selection across async writes in %s", + async (key) => { + const file = { + id: "paste", + type: "file" as const, + name: "pasted-text.txt", + mimeType: "text/plain", + sizeBytes: 40_000, + fileUri: "file:///paste.txt", + }; + setComposerDraftText(key, "before selected after"); + const insertion = captureComposerDraftInsertion(key, { start: 7, end: 15 }); + const write = Promise.withResolvers(); + const pending = write.promise.then((attachment) => + appendComposerDraftAttachments(key, [attachment], { appendReference: true, insertion }), + ); + rememberComposerDraftSelection(key, insertion.text, { start: 0, end: 6 }); + rememberComposerDraftSelection("another-draft", "unrelated", { start: 0, end: 9 }); + write.resolve(file); + expect(await pending).toBe(0); + const draft = getComposerDraftSnapshot(key); + expect(draft.text).toBe("before [pasted-text.txt](t3-context://v1/file/paste) after"); + expect(draft.attachments).toEqual([file]); + }, + ); + + it("preserves edits made while a paste is pending instead of deleting stale offsets", async () => { + const key = "environment-1:typing-during-paste"; + setComposerDraftText(key, "old selection"); + const insertion = captureComposerDraftInsertion(key, { start: 0, end: 13 }); + const write = Promise.withResolvers(); + const pending = write.promise.then(() => insertComposerDraftText(key, " pasted", insertion)); + setComposerDraftText(key, "keep newly typed text"); + write.resolve(); + await pending; + expect(getComposerDraftSnapshot(key).text).toBe("keep newly typed text pasted"); + }); + + it.each(["attachment", "context", "imported context"])( + "releases a selected file when replaced by %s", + async (kind) => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const cleanup = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + cleanup.resolve(); + return undefined; + }); + const key = "environment-1:replace-file"; + const files = Array.from({ length: 8 }, (_, index) => ({ + id: `file-${index}`, + type: "file" as const, + name: `notes-${index}.txt`, + mimeType: "text/plain", + sizeBytes: 4, + fileUri: `file:///notes-${index}.txt`, + })); + appendComposerDraftAttachments(key, files, { appendReference: true }); + const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)"; + const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length }); + expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(7); + expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(7); + const replacement = { ...files[0]!, id: "replacement", fileUri: "file:///replacement.txt" }; + if (kind === "attachment") { + expect( + appendComposerDraftAttachments(key, [replacement], { appendReference: true, insertion }), + ).toBe(0); + } else if (kind === "imported context") { + const record = { + version: 1 as const, + kind: "file" as const, + contextId: ComposerContextId.make("replacement"), + attachmentId: replacement.id, + label: replacement.name, + name: replacement.name, + mimeType: replacement.mimeType, + sizeBytes: replacement.sizeBytes, + }; + expect( + insertComposerDraftContext( + key, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + attachments: [replacement], + }, + insertion, + ), + ).toBe(true); + expect(getComposerDraftSnapshot(key).attachments).toHaveLength(8); + expect(getComposerDraftSnapshot(key).context?.records).toContainEqual(record); + expect(getComposerDraftSnapshot(key).text).toBe( + `${formatComposerContextReference(record)}${insertion.text.slice(firstLink.length)}`, + ); + } else { + insertComposerDraftContext( + key, + { text: "replacement", context: { version: 1, records: [] } }, + insertion, + ); + } + const draft = getComposerDraftSnapshot(key); + expect(draft.attachments.map((file) => file.id)).not.toContain("file-0"); + expect(draft.attachments.slice(0, 7)).toEqual(files.slice(1)); + expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false); + await cleanup.promise; + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(files[0]!.fileUri); + }, + ); + + it("rejects an imported file atomically when edits during import use up its replacement slot", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const cleanup = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + cleanup.resolve(); + return undefined; + }); + const key = "environment-1:concurrent-import"; + const files = Array.from({ length: 8 }, (_, index) => ({ + id: `existing-${index}`, + type: "file" as const, + name: `notes-${index}.txt`, + mimeType: "text/plain", + sizeBytes: 4, + fileUri: `file:///notes-${index}.txt`, + })); + appendComposerDraftAttachments(key, files, { appendReference: true }); + const firstLink = "[notes-0.txt](t3-context://v1/file/existing-0)"; + const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length }); + setComposerDraftText(key, `New edit ${insertion.text}`); + const edited = getComposerDraftSnapshot(key); + const imported = { ...files[0]!, id: "imported", fileUri: "file:///imported.txt" }; + const record = { + version: 1 as const, + kind: "file" as const, + contextId: ComposerContextId.make("imported"), + attachmentId: imported.id, + label: imported.name, + name: imported.name, + mimeType: imported.mimeType, + sizeBytes: imported.sizeBytes, + }; + expect( + insertComposerDraftContext( + key, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + attachments: [imported], + }, + insertion, + ), + ).toBe(false); + expect(getComposerDraftSnapshot(key)).toEqual(edited); + await cleanup.promise; + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(imported.fileUri); + }); + + it("retains a file when replacing only one of its repeated references", () => { + const key = "environment-1:repeat-reference"; + const file = { + id: "repeat", + type: "file" as const, + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 4, + fileUri: "file:///repeat.txt", + }; + appendComposerDraftAttachments(key, [file], { appendReference: true }); + const link = getComposerDraftSnapshot(key).text; + setComposerDraftText(key, `${link} ${link}`); + const insertion = captureComposerDraftInsertion(key, { start: 0, end: link.length }); + expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(1); + insertComposerDraftContext( + key, + { text: "replaced", context: { version: 1, records: [] } }, + insertion, + ); + expect(getComposerDraftSnapshot(key).attachments).toEqual([file]); + expect(getComposerDraftSnapshot(key).text).toBe(`replaced ${link}`); + }); + it("inserts context at the saved caret and retains its payload through persistence and restore", () => { const draftKey = "context-environment:context-thread"; const record = { @@ -564,7 +868,25 @@ describe("mobile composer drafts", () => { }, }).drafts, ).toEqual({ - "environment-1:thread-1": { text: "Review this file", attachments: [file] }, + "environment-1:thread-1": { + text: "Review this file [report.pdf](t3-context://v1/file/file-1) ", + attachments: [file], + context: { + version: 1, + records: [ + { + version: 1, + contextId: file.id, + kind: "file", + label: file.name, + attachmentId: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }, + ], + }, + }, }); }); @@ -882,8 +1204,34 @@ describe("mobile composer drafts", () => { const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); onTestFinished(() => enqueue.mockRestore()); await restoreCloudComposerDrafts("account-a"); - expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); - expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); + expect(getComposerDraftSnapshot(key)).toEqual( + type === "image" + ? { text: "Unsent notes", attachments: [file] } + : { + text: "Unsent notes [notes.pdf](t3-context://v1/file/local-notes) ", + attachments: [file], + context: { + version: 1, + records: [ + { + version: 1, + contextId: file.id, + kind: "file", + label: file.name, + attachmentId: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }, + ], + }, + }, + ); + expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe( + type === "image" + ? "Edited queued task" + : "Edited queued task [notes.pdf](t3-context://v1/file/local-notes) ", + ); expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); const persisted = decodePersistedComposerState( @@ -2314,7 +2662,25 @@ describe("mobile composer drafts", () => { await fresh.releaseUnusedComposerAttachmentFiles([file]); expect(freshRegistry.get(fresh.composerDraftsAtom)).toEqual({ - "environment-1:thread-1": { text: "Persisted draft", attachments: [file] }, + "environment-1:thread-1": { + text: "Persisted draft [report.pdf](t3-context://v1/file/file-cold-start) ", + attachments: [file], + context: { + version: 1, + records: [ + { + version: 1, + contextId: file.id, + kind: "file", + label: file.name, + attachmentId: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }, + ], + }, + }, }); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index b8ec4276b3cf..734199dcef5e 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -24,11 +24,13 @@ import { Atom } from "effect/unstable/reactivity"; import { writeFileAtomically } from "../lib/atomic-file"; import { createComposerContextHistory, referencedComposerContext } from "../lib/composerContext"; import { + collectComposerContextReferences, formatComposerContextReference, sanitizeComposerContextLabel, replaceComposerContextReferences, } from "@t3tools/shared/composerContextReferences"; import { imageMimeType } from "@t3tools/shared/image"; +import { videoMimeType } from "@t3tools/shared/video"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import { composerAttachmentFileReferenceKey, @@ -95,6 +97,89 @@ export function readComposerDraftSelection( return { start: lastComposerSelection.start, end: lastComposerSelection.end }; } +export interface ComposerDraftInsertion { + readonly text: string; + readonly start: number; + readonly end: number; +} + +/** Capture the paste target before any clipboard reads, downloads, or file writes. */ +export function captureComposerDraftInsertion( + draftKey: string, + selection?: { start: number; end: number }, +): ComposerDraftInsertion { + const { text } = getComposerDraftSnapshot(draftKey); + return { + text, + ...(selection ?? + readComposerDraftSelection(draftKey, text) ?? { start: text.length, end: text.length }), + }; +} + +function contextInsertionRange( + draftKey: string, + draft: ComposerDraft, + target?: ComposerDraftInsertion, +) { + const captured = + target ?? (lastComposerSelection?.draftKey === draftKey ? lastComposerSelection : null); + // Edits made during a file write must not be replaced using stale offsets. A changed + // draft receives the file at its end; moving only the caret preserves the captured range. + const selection = captured?.text === draft.text ? captured : null; + const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length)); + return { start, end: Math.max(start, Math.min(selection?.end ?? start, draft.text.length)) }; +} + +function draftWithoutInsertionSelection( + draftKey: string, + draft: ComposerDraft, + target?: ComposerDraftInsertion, +) { + const { start, end } = contextInsertionRange(draftKey, draft, target); + const text = `${draft.text.slice(0, start)} ${draft.text.slice(end)}`; + return withReferencedContextFiles(draft, text, referencedComposerContext(text, draft.context)); +} + +export function countComposerDraftAttachmentsAfterSelection( + draftKey: string, + target: ComposerDraftInsertion, +): number { + return getComposerDraftAfterSelection(draftKey, target).attachments.length; +} + +export function getComposerDraftAfterSelection( + draftKey: string, + target: ComposerDraftInsertion, +): ComposerDraft { + return draftWithoutInsertionSelection(draftKey, getComposerDraftSnapshot(draftKey), target); +} + +function withReferencedContextFiles( + draft: ComposerDraft, + text: string, + context: OrchestrationMessageContext | undefined, +): ComposerDraft { + const previousIds = new Set( + draft.context?.records.flatMap((record) => + "attachmentId" in record ? [record.attachmentId] : [], + ), + ); + const retainedIds = new Set( + context?.records.flatMap((record) => ("attachmentId" in record ? [record.attachmentId] : [])), + ); + return { + ...draft, + text, + context, + attachments: draft.attachments.filter( + (attachment) => + attachment.type === "image" || + !previousIds.has(attachment.id) || + retainedIds.has(attachment.id), + ), + }; +} + /** Retains file bytes while native text undo can restore their references. */ export function createComposerDraftContextHistory() { const restoreContext = createComposerContextHistory(); @@ -162,16 +247,36 @@ export function setComposerDraftContext( export function insertComposerDraftContext( draftKey: string, - content: { text: string; context: OrchestrationMessageContext }, + content: { + text: string; + context: OrchestrationMessageContext; + attachments?: ReadonlyArray; + }, + target?: ComposerDraftInsertion, ): boolean { let inserted = false; + let removed: ReadonlyArray = []; updateComposerDrafts((current) => { const draft = normalizeDraft(current[draftKey]); - const nextDraft = draftWithInsertedContext(draftKey, draft, content); + const attachments = content.attachments ?? []; + const retained = draftWithoutInsertionSelection(draftKey, draft, target); + if ( + attachments.length > 0 && + retained.attachments.length + attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) + return current; + const nextDraft = draftWithInsertedContext( + draftKey, + { ...draft, attachments: [...draft.attachments, ...attachments] }, + content, + target, + ); if (!nextDraft) return current; inserted = true; + removed = draft.attachments.filter((attachment) => !nextDraft.attachments.includes(attachment)); return { ...current, [draftKey]: nextDraft }; }); + scheduleUnusedComposerAttachmentCleanup(inserted ? removed : (content.attachments ?? [])); return inserted; } @@ -179,13 +284,9 @@ function draftWithInsertedContext( draftKey: string, draft: ComposerDraft, content: { text: string; context: OrchestrationMessageContext }, + target?: ComposerDraftInsertion, ): ComposerDraft | null { - const selection = - lastComposerSelection?.draftKey === draftKey && lastComposerSelection.text === draft.text - ? lastComposerSelection - : null; - const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length)); - const end = Math.max(start, Math.min(selection?.end ?? start, draft.text.length)); + const { start, end } = contextInsertionRange(draftKey, draft, target); const before = draft.text.slice(0, start); const after = draft.text.slice(end); const insertion = `${before.length > 0 && !/\s$/.test(before) && !/^\s/.test(content.text) ? " " : ""}${content.text}${!/\s$/.test(content.text) && (after.length === 0 || !/^\s/.test(after)) ? " " : ""}`; @@ -200,7 +301,7 @@ function draftWithInsertedContext( start: start + insertion.length, end: start + insertion.length, }; - return { ...draft, text, context }; + return withReferencedContextFiles(draft, text, context); } export class ComposerDraftPersistenceError extends Schema.TaggedError()( @@ -352,6 +453,68 @@ export function resetComposerDraftsLoadState(): void { persistRetryNeeded = false; } +function attachmentContextRecord( + attachment: DraftComposerAttachment, + contextId = ComposerContextId.make(attachment.id), +) { + const common = { + version: 1 as const, + contextId, + label: sanitizeComposerContextLabel(attachment.name, attachment.type), + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }; + // A picture picked through the document picker is typed as a plain file, but the + // record has to say what it is or no client will offer to open it as an image. + return attachment.type === "image" || imageMimeType(attachment) !== null + ? { ...common, kind: "image" as const } + : { ...common, kind: "file" as const }; +} + +/** Older drafts stored documents only in the attachment strip. Restore their missing chips. */ +function restoreMissingComposerFileReferences(draft: ComposerDraft): ComposerDraft { + const records = [...(draft.context?.records ?? [])]; + const usedIds = new Set(records.map((record) => record.contextId)); + const referenced = new Set( + collectComposerContextReferences(draft.text).map( + (reference) => `${reference.kind}:${reference.contextId}`, + ), + ); + let text = draft.text; + let changed = false; + for (const attachment of draft.attachments) { + if ( + attachment.type === "image" || + imageMimeType(attachment) !== null || + videoMimeType(attachment) !== null + ) + continue; + let record = records.find( + (candidate) => + candidate.kind === "file" && + "attachmentId" in candidate && + candidate.attachmentId === attachment.id, + ); + if (!record) { + const baseId = attachment.id.replace(/[^a-z0-9_-]/gi, "_").slice(0, 110) || "file"; + let contextId = baseId; + for (let suffix = 2; usedIds.has(contextId); suffix += 1) contextId = `${baseId}_${suffix}`; + usedIds.add(contextId); + record = attachmentContextRecord(attachment, ComposerContextId.make(contextId)); + records.push(record); + changed = true; + } + const key = `${record.kind}:${record.contextId}`; + if (referenced.has(key)) continue; + text += `${text.length > 0 && !/\s$/.test(text) ? " " : ""}${formatComposerContextReference(record)} `; + referenced.add(key); + changed = true; + } + return changed ? { ...draft, text, context: { version: 1, records } } : draft; +} + function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { if (!draft) { return EMPTY_DRAFT; @@ -421,14 +584,15 @@ export function migrateLegacyNewTaskDraft( draft: ComposerDraft, now: string, ): readonly [key: string, draft: ComposerDraft] { + const restored = restoreMissingComposerFileReferences(draft); const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null; if (legacy === null) { - return [key, draft]; + return [key, restored]; } return [ newTaskDraftKey(newDraftId()), { - ...draft, + ...restored, project: { environmentId: EnvironmentIdSchema.make(legacy.environmentId), projectId: ProjectIdSchema.make(legacy.projectId), @@ -1089,28 +1253,28 @@ export function setComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); const context = referencedComposerContext(value, existing.context); - const retainedIds = new Set(context?.records.map((record) => record.contextId)); - const removedFileIds = new Set( - existing.context?.records.flatMap((record) => - record.kind === "file" && "attachmentId" in record && !retainedIds.has(record.contextId) - ? [record.attachmentId] - : [], - ), - ); - removed = existing.attachments.filter( - (attachment) => attachment.type !== "image" && removedFileIds.has(attachment.id), - ); - const draft = { - ...existing, - text: value, - context, - attachments: existing.attachments.filter((attachment) => !removed.includes(attachment)), - }; + const draft = withReferencedContextFiles(existing, value, context); + removed = existing.attachments.filter((attachment) => !draft.attachments.includes(attachment)); return withComposerDraft(current, draftKey, draft); }); scheduleUnusedComposerAttachmentCleanup(removed); } +export function insertComposerDraftText( + draftKey: string, + value: string, + target: ComposerDraftInsertion, +): void { + const draft = getComposerDraftSnapshot(draftKey); + const { start, end } = contextInsertionRange(draftKey, draft, target); + const text = draft.text.slice(0, start) + value + draft.text.slice(end); + setComposerDraftText(draftKey, text); + rememberComposerDraftSelection(draftKey, text, { + start: start + value.length, + end: start + value.length, + }); +} + export function appendComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); @@ -1137,6 +1301,7 @@ export function appendComposerDraftAttachments( options?: { readonly allowOverflow?: boolean; readonly appendReference?: boolean; + readonly insertion?: ComposerDraftInsertion; readonly maxAttachments?: number; }, ): number { @@ -1144,8 +1309,12 @@ export function appendComposerDraftAttachments( return 0; } let rejected: ReadonlyArray = []; + let removed: ReadonlyArray = []; updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); + const retained = options?.appendReference + ? draftWithoutInsertionSelection(draftKey, existing, options.insertion) + : existing; const remaining = options?.allowOverflow ? attachments.length : Math.max( @@ -1153,10 +1322,10 @@ export function appendComposerDraftAttachments( Math.min( PROVIDER_SEND_TURN_MAX_ATTACHMENTS, options?.maxAttachments ?? PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - ) - existing.attachments.length, + ) - retained.attachments.length, ); const contextCapacity = options?.appendReference - ? Math.max(0, COMPOSER_CONTEXT_MAX_RECORDS - (existing.context?.records.length ?? 0)) + ? Math.max(0, COMPOSER_CONTEXT_MAX_RECORDS - (retained.context?.records.length ?? 0)) : attachments.length; const accepted = attachments.slice(0, Math.min(remaining, contextCapacity)); rejected = attachments.slice(accepted.length); @@ -1165,38 +1334,31 @@ export function appendComposerDraftAttachments( } let draft = { ...existing, attachments: [...existing.attachments, ...accepted] }; if (options?.appendReference) { - const records = accepted.map((attachment) => { - const common = { - version: 1 as const, - contextId: ComposerContextId.make(attachment.id), - label: sanitizeComposerContextLabel(attachment.name, attachment.type), - attachmentId: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - }; - // A picture picked through the document picker is typed as a plain file, but the - // record has to say what it is or no client will offer to open it as an image. - return attachment.type === "image" || imageMimeType(attachment) !== null - ? { ...common, kind: "image" as const } - : { ...common, kind: "file" as const }; - }); - const inserted = draftWithInsertedContext(draftKey, draft, { - text: records.map(formatComposerContextReference).join(" "), - context: { version: 1, records }, - }); + const records = accepted.map((attachment) => attachmentContextRecord(attachment)); + const inserted = draftWithInsertedContext( + draftKey, + draft, + { + text: records.map(formatComposerContextReference).join(" "), + context: { version: 1, records }, + }, + options?.insertion, + ); if (!inserted) { rejected = attachments; return current; } draft = { ...inserted, attachments: [...inserted.attachments] }; + removed = existing.attachments.filter( + (attachment) => !draft.attachments.includes(attachment), + ); } return { ...current, [draftKey]: draft, }; }); - scheduleUnusedComposerAttachmentCleanup(rejected); + scheduleUnusedComposerAttachmentCleanup([...rejected, ...removed]); return rejected.length; } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index caa976beef90..d21af7d79cc3 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,5 +1,6 @@ +import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "react-native"; import { @@ -7,6 +8,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, type EnvironmentId, type ModelSelection, type ProviderInteractionMode, @@ -14,6 +16,8 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { nextPastedTextFileName, pastedTextDisposition } from "@t3tools/client-runtime/text-paste"; import { parseCodexFeedbackCommand, submitCodexFeedback, @@ -29,9 +33,11 @@ import { isModelSelectionUnavailable } from "../lib/modelOptions"; import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; import { convertPastedImagesToAttachments, + createPastedTextComposerAttachment, pasteComposerClipboard, pickComposerFiles, pickComposerMedia, + removePersistedComposerAttachmentFile, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; @@ -42,7 +48,9 @@ import { appAtomRegistry } from "../state/atom-registry"; import { pendingThreadCreationMessage } from "./pending-thread-creation"; import { appendComposerDraftAttachments, - appendComposerDraftText, + captureComposerDraftInsertion, + countComposerDraftAttachmentsAfterSelection, + insertComposerDraftText, insertComposerDraftContext, clearComposerDraftContent, composerDraftsAtom, @@ -132,6 +140,23 @@ export function useThreadComposerState() { const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); + const pastedTextFileNamesRef = useRef<{ threadKey: string | null; names: Set }>({ + threadKey: null, + names: new Set(), + }); + const reservePastedTextFileName = useCallback( + (threadKey: string, existingNames: ReadonlyArray) => { + if (pastedTextFileNamesRef.current.threadKey !== threadKey) { + pastedTextFileNamesRef.current = { threadKey, names: new Set() }; + } + const names = pastedTextFileNamesRef.current.names; + for (const name of existingNames) names.add(name); + const nextName = nextPastedTextFileName([...names]); + names.add(nextName); + return nextName; + }, + [], + ); useEffect(() => { ensureComposerDraftsLoaded(); @@ -479,9 +504,10 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = captureComposerDraftInsertion(threadKey); const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; const result = await pickComposerMedia({ - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), maxVideoBytes: capabilities?.attachmentUploads === true ? capabilities.fileAttachments?.maxUploadBytes @@ -489,6 +515,7 @@ export function useThreadComposerState() { }); const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments, { appendReference: true, + insertion, }); const problems = [ ...(result.error ? [result.error] : []), @@ -514,13 +541,15 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = captureComposerDraftInsertion(threadKey); // pickComposerFiles clamps the advertised limit to the contract maximum. const result = await pickComposerFiles({ - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), maxBytes, }); const rejectedCount = appendComposerDraftAttachments(threadKey, result.files, { appendReference: true, + insertion, }); // The picker error and the live-cap rejection can both happen in one // pick; report both in a single alert. @@ -541,14 +570,81 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = captureComposerDraftInsertion(threadKey); const result = await pasteComposerClipboard({ - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), }); const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images, { appendReference: true, + insertion, }); if (result.text) { - appendComposerDraftText(threadKey, result.text); + const currentDraft = getComposerDraftSnapshot(threadKey); + const currentAttachments = currentDraft.attachments; + const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + currentDraft.text.length - + (currentDraft.text === insertion.text + ? Math.max(0, insertion.end - insertion.start) + : 0) + + result.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const shouldFold = + pastedTextDisposition({ + text: result.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment"; + const canAttach = + maxBytes !== null && + countComposerDraftAttachmentsAfterSelection(threadKey, insertion) < + PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(result.text).byteLength <= maxBytes; + if (shouldFold && canAttach && maxBytes !== null) { + try { + const attachment = await createPastedTextComposerAttachment({ + text: result.text, + name: reservePastedTextFileName( + threadKey, + currentAttachments.map((item) => item.name), + ), + maxBytes, + }); + // Same reference the pasted images above get: a folded paste is only visible + // as its chip until the message is sent. + if ( + appendComposerDraftAttachments(threadKey, [attachment], { + appendReference: true, + insertion, + }) > 0 + ) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + setPendingConnectionError( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Could not attach pasted text.", + ); + } + } else if (shouldFold && !wouldExceedInputLimit) { + insertComposerDraftText(threadKey, result.text, insertion); + } else if (shouldFold) { + setPendingConnectionError( + wouldExceedInputLimit + ? "Pasted text is too large for this message. Remove some text or an attachment, then paste again." + : "Could not attach pasted text. Remove an attachment or use a smaller paste, then try again.", + ); + } else { + insertComposerDraftText(threadKey, result.text, insertion); + } } if (result.error) { setPendingConnectionError(result.error); @@ -557,7 +653,12 @@ export function useThreadComposerState() { `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, ); } - }, [composerDrafts, selectedThreadShell]); + }, [ + composerDrafts, + reservePastedTextFileName, + selectedEnvironmentRuntime?.serverConfig, + selectedThreadShell, + ]); const onNativePasteImages = useCallback( async (uris: ReadonlyArray) => { @@ -566,13 +667,14 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = captureComposerDraftInsertion(threadKey); try { const images = await convertPastedImagesToAttachments({ uris, - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), }); if (images.length > 0) { - appendComposerDraftAttachments(threadKey, images, { appendReference: true }); + appendComposerDraftAttachments(threadKey, images, { appendReference: true, insertion }); } } catch (error) { console.error("[native paste] error converting images", { @@ -586,6 +688,55 @@ export function useThreadComposerState() { [composerDrafts, selectedThreadShell], ); + const onNativePasteText = useCallback( + async (paste: ComposerTextPaste) => { + if (!selectedThreadShell) return; + const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + if (advertisedMax === undefined) return; + + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = { text: paste.value, ...paste.selection }; + const currentAttachments = getComposerDraftSnapshot(threadKey).attachments; + try { + const attachment = await createPastedTextComposerAttachment({ + text: paste.text, + name: reservePastedTextFileName( + threadKey, + currentAttachments.map((item) => item.name), + ), + maxBytes: clampFileAttachmentUploadBytes(advertisedMax), + }); + // The chip is how a folded paste stays visible: without it the attachment is in the + // draft but nothing in the composer says so until the message is sent. Web folds + // through its ordinary attach path, which always writes a reference; match that. + const rejectedCount = appendComposerDraftAttachments(threadKey, [attachment], { + appendReference: true, + insertion, + }); + if (rejectedCount > 0) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + setPendingConnectionError( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Could not attach pasted text.", + ); + } + }, + [ + composerDrafts, + reservePastedTextFileName, + selectedEnvironmentRuntime?.serverConfig, + selectedThreadShell, + ], + ); + const onRemoveDraftImage = useCallback( (imageId: string) => { if (!selectedThreadShell) { @@ -663,6 +814,7 @@ export function useThreadComposerState() { onPickDraftFiles, onPasteIntoDraft, onNativePasteImages, + onNativePasteText, onRemoveDraftImage, onSendMessage, onUpdateModelSelection, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index b68f8f219954..927d2a7d4b6f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -2228,6 +2228,25 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: '); assert.deepEqual(fileOnlyInput.attachments, [fileAttachment]); + const pastedTextAttachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-txt", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: 32_768, + source: { _tag: "pasted-text" as const }, + }; + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "Investigate this crash", + attachments: [pastedTextAttachment], + }); + const pastedInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.include(pastedInput.input ?? "", '[Pasted text "pasted-text.txt" is saved at: '); + assert.include(pastedInput.input ?? "", ". Inspect it as needed.]"); + assert.deepEqual(pastedInput.attachments, [pastedTextAttachment]); + yield* provider.stopSession({ threadId: session.threadId }); }), ); @@ -4683,6 +4702,33 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { + it.effect("rejects input that leaves no room for pasted-text attachment context", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const attachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-txt", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: 32_768, + source: { _tag: "pasted-text" as const }, + }; + validation.codex.sendTurn.mockClear(); + + const failure = yield* provider + .sendTurn({ + threadId: asThreadId("thread-pasted-text-context-limit"), + input: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + attachments: [attachment], + }) + .pipe(Effect.flip); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, String(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)); + assert.equal(validation.codex.sendTurn.mock.calls.length, 0); + }), + ); + it.effect("rejects citation-expanded input over the provider character limit", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 17ece3767792..cdac979c4dfd 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1593,30 +1593,45 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Every attachment gets an on-disk path in the prompt so the model's tools // can dereference the actual file. All attachments then go to the adapter, - // and each adapter decides what its provider ingests natively: OpenCode - // sends generic files as file parts, the others send images only and rely - // on the path line for everything else. Unresolvable ids are skipped here - // and surface as adapter errors when the file is read. + // and each adapter decides what its provider ingests natively. Folded + // clipboard text remains path-only everywhere: eagerly embedding it would + // spend the same context the client deliberately preserved by folding it. + // Unresolvable ids are skipped here and surface as adapter errors when the + // file is read. let inputTextWithAttachmentContext = inputTextWithCitations; const appendAttachmentContext = (context: string | undefined) => { - if (context === undefined) return; + if (context === undefined) return true; const candidate = inputTextWithAttachmentContext ? `${inputTextWithAttachmentContext}\n\n${context}` : context; if (candidate.length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { inputTextWithAttachmentContext = candidate; + return true; } + return false; }; for (const attachment of attachments) { const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment, }); - appendAttachmentContext( + const isPastedText = + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text"; + const appended = appendAttachmentContext( attachmentPath === null ? undefined - : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, + : isPastedText + ? `[Pasted text "${attachment.name}" is saved at: ${attachmentPath}. Inspect it as needed.]` + : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, ); + if (isPastedText && !appended) { + return yield* toValidationError( + "ProviderService.sendTurn", + `Input plus pasted-text attachment context exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character limit`, + ); + } } for (const attachment of attachments) { const source = diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 15ba88da3b95..aee10782a613 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -279,6 +279,51 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); + it.effect("keeps folded clipboard text out of native context", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pastedText = { + ...textAttachment, + name: "pasted-text.txt", + mimeType: "text/plain", + source: { _tag: "pasted-text" as const }, + } satisfies ChatAttachment; + yield* fixture.write(pastedText, "A very large crash report"); + + const prompt = yield* buildAntigravityPrompt({ + input: "Inspect the pasted text only as needed.", + attachments: [pastedText], + attachmentsDir: fixture.attachmentsDir, + }); + + expect(prompt).toEqual([{ type: "text", text: "Inspect the pasted text only as needed." }]); + }), + ); + + it.effect("rejects missing folded clipboard text", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pastedText = { + ...textAttachment, + name: "pasted-text.txt", + mimeType: "text/plain", + source: { _tag: "pasted-text" as const }, + } satisfies ChatAttachment; + + const error = yield* buildAntigravityPrompt({ + input: "Inspect the pasted text only as needed.", + attachments: [pastedText], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: "Could not read attachment 'pasted-text.txt'.", + }); + }), + ); + it.effect("sends supported audio files as native audio content", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 17b73e552006..f2f370068181 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -264,6 +264,13 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi let totalBytes = 0; for (const attachment of input.attachments ?? []) { + const isPastedText = + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text"; + // ProviderService has already put the file path in the text block. Keep a + // folded clipboard paste lazy so the agent can search or sample it rather + // than paying to embed the entire resource in context immediately. const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); @@ -296,6 +303,14 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi ), ), ); + if (isPastedText) { + if (info.type !== "File") { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Could not read attachment '${attachment.name}'.`, + ); + } + continue; + } const size = Number(info.size); const limit = image ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 35a7791c62f0..c66a2f36a207 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -318,4 +318,18 @@ describe("toOpenCodeFileParts", () => { ["application/pdf", "text/markdown", "image/png"], ); }); + + it("keeps folded clipboard text on the lazy path fallback", () => { + const parts = toOpenCodeFileParts({ + attachments: [ + { + ...attachment("text/plain"), + source: { _tag: "pasted-text" as const }, + }, + ], + resolveAttachmentPath: () => "/tmp/pasted-text.txt", + }); + + NodeAssert.deepEqual(parts, []); + }); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 6d7187792a99..a79eff843cc7 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -462,6 +462,13 @@ export function toOpenCodeFileParts(input: { const parts: Array = []; for (const attachment of input.attachments ?? []) { + if ( + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text" + ) { + continue; + } if (!isOpenCodeNativeFilePart(attachment)) { continue; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c67639ac8e2d..4e8b370d382c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -46,6 +46,8 @@ import { } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; +import { readPastedComposerContext } from "./composerInlineTokenPaste"; +import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { @@ -95,6 +97,7 @@ import { flushSync } from "react-dom"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; +import { isMacPlatform } from "../lib/utils"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -1608,6 +1611,7 @@ export default function ChatView(props: ChatViewProps) { const composerTerminalContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const pasteAsTextShortcutUntilRef = useRef(0); const [restingComposerControlsHost, setRestingComposerControlsHost] = useState(null); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); @@ -6519,24 +6523,38 @@ export default function ChatView(props: ChatViewProps) { // so a paste that follows has no editable target and would be dropped. // Route it to the composer like a typed key, which also expands it. useEffect(() => { + const keyHandler = (event: KeyboardEvent) => { + if ( + shouldRedirectInputToComposer(event) && + isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) + ) { + pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; + } + }; const handler = (event: ClipboardEvent) => { if (!activeThreadId || isCommandPaletteOpen()) return; if (getTerminalFocusOwner() !== null) return; if (composerRef.current?.isModelPickerOpen()) return; const text = pasteTextToFocusComposer(event); - if (text === null) return; + const clipboardData = event.clipboardData; + if (text === null || clipboardData === null) return; + const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; if ( - composerRef.current?.insertTextAtEnd( - text, - event.clipboardData ? { clipboardData: event.clipboardData } : undefined, - ) + ((readPastedComposerContext(clipboardData)?.records.length ?? 0) === 0 && + composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment })) || + composerRef.current?.insertTextAtEnd(text, { clipboardData }) ) { event.preventDefault(); event.stopPropagation(); } }; + window.addEventListener("keydown", keyHandler, true); window.addEventListener("paste", handler, true); - return () => window.removeEventListener("paste", handler, true); + return () => { + window.removeEventListener("keydown", keyHandler, true); + window.removeEventListener("paste", handler, true); + }; }, [activeThreadId, composerRef]); const [pendingRevert, setPendingRevert] = useState<{ @@ -7257,6 +7275,7 @@ export default function ChatView(props: ChatViewProps) { mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, downloadable: false, + ...(attachment.source ? { source: attachment.source } : {}), }, ); const shouldAnchorFirstMessage = diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 0cf866c20696..9d46174d174f 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -18,6 +18,7 @@ import { import { importPastedComposerText, + readPastedComposerContext, registerComposerInlineTokenPaste, } from "./composerInlineTokenPaste"; import { @@ -498,92 +499,103 @@ describe("registerComposerInlineTokenPaste", () => { }); describe("context reference paste", () => { - it.each(["focused", "blurred"])("imports structured paste when %s", (focus) => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createEditor({ nodes: [ComposerCitationNode] }); - editor.update( - () => { - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.selectEnd(); - }, - { discrete: true }, - ); - const imported: string[] = []; - const importFragment = ( - fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, - ) => { - imported.push(...fragment.records.map((record) => record.contextId)); - return new Map([["img-old", "img-new"]]); - }; - registerComposerInlineTokenPaste(editor, { - createMentionNode: (path) => $createTextNode(``), - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: (reference) => - $createTextNode(``), - getExpandedAbsoluteOffsetForPoint: () => 0, - importContextFragment: importFragment, - }); - const event = new TestClipboardEvent( - "![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)", - { - "web application/x-t3-context-fragment+json": JSON.stringify({ - version: 1, - source: { environmentId: "env-1" }, - records: [ - { - version: 1, - contextId: "img-old", - kind: "image", - label: "shot", - attachmentId: "a", - name: "shot.png", - mimeType: "image/png", - sizeBytes: 1, - }, - { - version: 1, - contextId: "ctx-t", - kind: "terminal", - label: "T", - terminalId: "t", - terminalLabel: "T", - lineStart: 1, - lineEnd: 1, - text: "x", - }, - { - version: 1, - contextId: "img-unrelated", - kind: "image", - label: "other", - attachmentId: "b", - name: "other.png", - mimeType: "image/png", - sizeBytes: 1, - }, - ], - }), - }, - ); - if (focus === "blurred") { - expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( - "![shot](t3-context://v1/image/img-new) and [T](t3-context://v1/terminal/ctx-t)", + it.each([ + { focus: "focused", prefix: "" }, + { focus: "blurred", prefix: "" }, + { focus: "focused", prefix: "log ".repeat(10_000) }, + { focus: "blurred", prefix: "log ".repeat(10_000) }, + ])( + "imports structured paste when $focus with $prefix.length extra characters", + ({ focus, prefix }) => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor({ nodes: [ComposerCitationNode] }); + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + const imported: string[] = []; + const importFragment = ( + fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, + ) => { + imported.push(...fragment.records.map((record) => record.contextId)); + return new Map([["img-old", "img-new"]]); + }; + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + createCitationNode: $createComposerCitationNode, + createContextReferenceNode: (reference) => + $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 0, + importContextFragment: importFragment, + }); + const event = new TestClipboardEvent( + `${prefix}![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)`, + { + "web application/x-t3-context-fragment+json": JSON.stringify({ + version: 1, + source: { environmentId: "env-1" }, + records: [ + { + version: 1, + contextId: "img-old", + kind: "image", + label: "shot", + attachmentId: "a", + name: "shot.png", + mimeType: "image/png", + sizeBytes: 1, + }, + { + version: 1, + contextId: "ctx-t", + kind: "terminal", + label: "T", + terminalId: "t", + terminalLabel: "T", + lineStart: 1, + lineEnd: 1, + text: "x", + }, + { + version: 1, + contextId: "img-unrelated", + kind: "image", + label: "other", + attachmentId: "b", + name: "other.png", + mimeType: "image/png", + sizeBytes: 1, + }, + ], + }), + }, + ); + expect( + readPastedComposerContext(event.clipboardData)?.records.map((record) => record.contextId), + ).toEqual(["img-old", "ctx-t"]); + if (focus === "blurred") { + expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( + `${prefix}![shot](t3-context://v1/image/img-new) and [T](t3-context://v1/terminal/ctx-t)`, + ); + expect(imported).toEqual(["img-old", "ctx-t"]); + return; + } + editor.update( + () => { + editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, ); expect(imported).toEqual(["img-old", "ctx-t"]); - return; - } - editor.update( - () => { - editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); - }, - { discrete: true }, - ); - expect(imported).toEqual(["img-old", "ctx-t"]); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - " and ", - ); - }); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( + `${prefix} and `, + ); + }, + ); it("converts a copied legacy element into a sendable annotation and rewrites its link", () => { const copied = upgradeLegacyContextMessage( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index fbd839629182..3ba086e0e981 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -838,6 +838,7 @@ export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; + readSelectionRange: () => { start: number; end: number }; requestCitationComment: (request: ComposerCitationCommentRequest) => void; readSnapshot: () => { value: string; @@ -1854,6 +1855,10 @@ function ComposerPromptEditorInner({ ), ); }, + readSelectionRange: () => { + readSnapshot(); + return selectionRangeRef.current; + }, requestCitationComment: (request) => { citationCommentRequestRef.current = request; const target = editor @@ -2021,7 +2026,7 @@ function ComposerPromptEditorInner({ }} onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} onBlur={onPageScrollRelease} - onPaste={onPaste} + onPasteCapture={onPaste} /> } placeholder={ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index cb2143ab65aa..e3ae13911036 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,13 +1,15 @@ +import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; import { useRightPanelStore } from "~/rightPanelStore"; import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; -import { importPastedComposerText } from "../composerInlineTokenPaste"; +import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { questionAttachmentDraftId, + countQuestionAttachments, useQuestionAttachmentPreparation, changeQuestionAttachmentPreparation, } from "../../questionAttachments"; @@ -34,8 +36,15 @@ import { ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; +import { + isPasteAsTextShortcut, + nextPastedTextFileName, + pastedTextDisposition, + wouldTextPasteExceedLimit, +} from "@t3tools/client-runtime/text-paste"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; @@ -194,6 +203,7 @@ import { ensureInlineContextReferences, formatInlineContextReference, insertInlineContextReference, + inlineContextReferenceReplacement, toKindScopedComposerContextId, } from "~/lib/composerContextReferences"; import { @@ -284,7 +294,7 @@ import { } from "../../lib/snapShotAnimation"; import { resizeSnapShotSource } from "../../lib/snapShotSource"; import { basenameOfPath } from "../../pierre-icons"; -import { cn, randomUUID } from "~/lib/utils"; +import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; import { getComposerPromptLengthValidationMessage, getComposerSubmissionValidationMessage, @@ -1212,6 +1222,8 @@ export interface ChatComposerHandle { text: string, options?: { ensureLeadingBoundary?: boolean; clipboardData?: DataTransfer }, ) => boolean; + /** Apply large-paste folding for text redirected from a blurred composer. */ + pasteTextAtEnd: (text: string, options?: { bypassAutoAttachment?: boolean }) => boolean; citeAssistantText: ( citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor, @@ -2064,6 +2076,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Refs // ------------------------------------------------------------------ const composerEditorRef = useRef(null); + const pasteAsTextShortcutUntilRef = useRef(0); + const pastedTextFileNamesRef = useRef<{ targetKey: string; names: Set }>({ + targetKey: "", + names: new Set(), + }); const attachmentInputRef = useRef(null); const composerFormRef = useRef(null); const composerFooterControlsRef = useRef(null); @@ -2099,6 +2116,49 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); isRevertingCheckpointRef.current = isRevertingCheckpoint; + useEffect(() => { + const armPasteAsTextShortcut = () => { + // Electron can deliver its native menu action just before the paste + // event, while browsers normally deliver keydown first. A short deadline + // bridges both event paths without leaving later pastes in bypass mode. + pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; + }; + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof Node && + composerFormRef.current?.contains(event.target) && + isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) + ) { + armPasteAsTextShortcut(); + } + }; + const onBlur = () => { + pasteAsTextShortcutUntilRef.current = 0; + }; + const onDesktopPasteAsText = () => { + const activeElement = document.activeElement; + const blocksPasteToFocus = + activeElement instanceof Element && + activeElement.closest( + 'input, textarea, select, button, a[href], summary, [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"], [role="button"], [role="menuitem"], [role="option"]', + ) !== null; + if ( + (activeElement instanceof Node && composerFormRef.current?.contains(activeElement)) || + !blocksPasteToFocus + ) { + armPasteAsTextShortcut(); + } + }; + window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("blur", onBlur); + }; + }, []); + // ------------------------------------------------------------------ // Derived: composer send state // ------------------------------------------------------------------ @@ -4041,6 +4101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mimeType: file.mimeType, sizeBytes: file.sizeBytes, file: null, + ...(file.source ? { source: file.source } : {}), // An expired upload carries no ids, so it hydrates as a // needs-reattach row and the "Attach again" flow takes over. ...(expired @@ -4319,6 +4380,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sizeBytes: file.sizeBytes, attachmentId: upload.attachmentId, environmentId, + ...(file.source ? { source: file.source } : {}), }); } // A repeat ⌘S on the *same* still-unencoded snapshot would stash it @@ -5032,8 +5094,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: attachments // ------------------------------------------------------------------ + const countReservedAttachments = () => { + const questionRequest = pendingUserInputs[0]; + const otherQuestionKeys = + questionAttachmentTarget && questionRequest && activeThreadId + ? questionRequest.questions + .map((question) => + questionAttachmentDraftId( + environmentId, + activeThreadId, + questionRequest.requestId, + question.id, + ), + ) + .filter((key) => key !== questionAttachmentTarget) + : []; + return ( + composerImagesRef.current.length + + composerFilesRef.current.length + + (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) + + countQuestionAttachments(otherQuestionKeys) + ); + }; /** Resolves true when at least one chip was inserted for the accepted attachments. */ - const addComposerAttachments = async (files: File[]): Promise => { + const addComposerAttachments = async ( + files: File[], + options?: { + readonly source?: ChatFileAttachment["source"]; + readonly selection?: { start: number; end: number }; + }, + ): Promise => { if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return false; if ( pendingUserInputs.length > 0 && @@ -5056,30 +5146,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // accepted files reserve their attachment slots (via the pending counter) // before the first await, keeping the total under the limit. const pendingCount = pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0; - const otherQuestionAttachments = - questionAttachmentTarget && pendingUserInputs[0] - ? pendingUserInputs[0].questions.reduce((count, question) => { - const target = questionAttachmentDraftId( - environmentId, - threadId, - pendingUserInputs[0]!.requestId, - question.id, - ); - if (target === questionAttachmentTarget) return count; - const draft = getComposerDraft(target); - return ( - count + - (draft?.images.length ?? 0) + - (draft?.files.length ?? 0) + - (useQuestionAttachmentPreparation.getState().counts[target] ?? 0) - ); - }, 0) - : 0; - let reservedCount = - composerImagesRef.current.length + - composerFilesRef.current.length + - pendingCount + - otherQuestionAttachments; + let reservedCount = countReservedAttachments(); // A pick that matches a needs-reattach marker replaces it in the draft, so // it must not consume a slot; a draft full of markers would otherwise hit // the capacity error before the replacement path could run. @@ -5146,6 +5213,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mimeType: fileMimeType, sizeBytes: attachmentFile.size, file: attachmentFile, + ...(options?.source ? { source: options.source } : {}), }); } if (!matchingReattachMarker) { @@ -5160,7 +5228,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const storedIds = new Set(addComposerFilesToDraft(acceptedFiles)); const storedFiles = acceptedFiles.filter((file) => storedIds.has(file.id)); if (storedFiles.length > 0) { - insertedAny = insertAttachmentReferences(storedFiles.map(fileContextReference)); + insertedAny = insertAttachmentReferences( + storedFiles.map(fileContextReference), + options?.selection, + ); + } + if (options?.source?._tag === "pasted-text" && storedFiles.length > 0) { + const attached = storedFiles[0]!; + toastManager.add({ + type: "info", + title: `Large paste attached as ${attached.name}`, + description: `${formatAttachmentSize(attached.sizeBytes)} · Use ${ + isMacPlatform(navigator.platform) ? "⌘⇧V" : "Ctrl+Shift+V" + } to keep a large paste inline.`, + data: { hideCopyButton: true }, + }); } } if (acceptedImages.length === 0) return insertedAny; @@ -5246,11 +5328,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) */ const insertAttachmentReferences = ( references: ReadonlyArray, + selection?: { start: number; end: number }, ): boolean => { if (references.length === 0) return false; // Question answers carry attachments beside the answer, never as chips. Falling back to // the thread prompt here would hide the file behind a reference the question never shows. if (questionAttachmentTarget) return false; + if (selection) { + const edit = inlineContextReferenceReplacement(promptRef.current, selection, references); + return applyPromptReplacement(edit.start, edit.end, edit.text); + } const text = references.map(formatInlineContextReference).join(" "); const inserted = insertComposerText(`${text} `, "cursor", { ensureLeadingBoundary: true }); if (!inserted) { @@ -5284,24 +5371,110 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: paste / drag // ------------------------------------------------------------------ + const foldPastedText = ( + plainText: string, + bypassAutoAttachment: boolean, + selectionOverride?: { start: number; end: number }, + ): boolean => { + const questionCanAttach = + pendingUserInputs.length === 0 || + (supportsQuestionAttachments && + activePendingProgress?.activeQuestion?.allowCustomAnswer !== false && + !activePendingIsResponding); + const hasAttachmentSlot = countReservedAttachments() < PROVIDER_SEND_TURN_MAX_ATTACHMENTS; + const selection = selectionOverride ?? composerEditorRef.current?.readSelectionRange(); + const wouldExceedInputLimit = wouldTextPasteExceedLimit({ + valueLength: promptRef.current.length, + selection: selection ?? { start: 0, end: 0 }, + textLength: plainText.length, + maxLength: PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + }); + const shouldFold = + pastedTextDisposition({ + text: plainText, + bypassAutoAttachment, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment"; + if (!shouldFold) { + return false; + } + + const canStageAttachment = + Boolean(activeThreadId) && + !isRevertingCheckpointRef.current && + questionCanAttach && + hasAttachmentSlot; + if (!canStageAttachment || fileStagingLimit === null) { + if (!wouldExceedInputLimit) { + return false; + } + toastManager.add({ + type: "error", + title: "Pasted text is too large for this message", + description: "Remove some text or an attachment, then paste again.", + data: { hideCopyButton: true }, + }); + return true; + } + + if (pastedTextFileNamesRef.current.targetKey !== attachmentTargetKey) { + pastedTextFileNamesRef.current = { targetKey: attachmentTargetKey, names: new Set() }; + } + const reservedNames = pastedTextFileNamesRef.current.names; + for (const file of composerFilesRef.current) reservedNames.add(file.name); + const foldedFileName = nextPastedTextFileName([...reservedNames]); + reservedNames.add(foldedFileName); + const foldedFile = new File([plainText], foldedFileName, { + type: "text/plain;charset=utf-8", + }); + if (foldedFile.size > fileStagingLimit) { + reservedNames.delete(foldedFileName); + if (!wouldExceedInputLimit) return false; + toastManager.add({ + type: "error", + title: "Pasted text is too large to attach", + description: "Reduce the clipboard contents or save a smaller excerpt as a file.", + data: { hideCopyButton: true }, + }); + return true; + } + + void addComposerAttachments([foldedFile], { + source: { _tag: "pasted-text" }, + ...(selection ? { selection } : {}), + }); + return true; + }; + const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); + const plainText = event.clipboardData.getData("text/plain"); + const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; // Claimable pastes go through even when agent questions are pending or the // composer is at its attachment limit: `addComposerAttachments` surfaces // those as a toast and a thread error. An early return here would swallow // the paste with no feedback. if ( - files.length === 0 || - !activeThreadId || - !shouldHandleComposerAttachmentPaste({ - files, - plainText: event.clipboardData.getData("text/plain"), - }) + files.length > 0 && + activeThreadId && + shouldHandleComposerAttachmentPaste({ files, plainText }) ) { + event.preventDefault(); + event.stopPropagation(); + void addComposerAttachments(files); return; } + + // Copied T3 chips need the structured importer to bring their records and files along. + if ((readPastedComposerContext(event.clipboardData)?.records.length ?? 0) > 0) return; + if (!foldPastedText(plainText, bypassAutoAttachment)) { + return; + } + event.preventDefault(); - void addComposerAttachments(files); + event.stopPropagation(); }; const insertComposerText = useCallback( @@ -5526,6 +5699,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hasPendingAttachments: () => (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0, insertTextAtEnd: insertComposerTextAtEnd, + pasteTextAtEnd: (text: string, options) => { + const bypassAutoAttachment = + options?.bypassAutoAttachment === true || + Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; + const promptLength = promptRef.current.length; + if ( + !foldPastedText(text, bypassAutoAttachment, { + start: promptLength, + end: promptLength, + }) + ) { + return false; + } + focusComposer(); + return true; + }, citeAssistantText: (citation, sourceAnchor) => insertComposerText( formatAssistantCitationForComposer(citation, citation.comment), @@ -5626,6 +5816,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ activeThread, addComposerAttachments, + foldPastedText, composerDraftTarget, composerCursor, composerTerminalContexts, diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index a6e1a25cf967..33eac5d58aa3 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -144,40 +144,43 @@ export function registerComposerInlineTokenPaste( ); } -/** Imports the same structured clipboard payload for focused paste and paste-to-focus. */ -export function importPastedComposerText( +/** Clipboard records referenced by the copied text, including dependent screenshots. */ +export function readPastedComposerContext( clipboardData: Pick, - importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"], -): string { +): ComposerContextClipboardFragment | null { const pastedText = clipboardData.getData("text/plain"); // Only records whose links are in the pasted text get imported; a fragment may carry // more (it was built for a larger copy) and must not start transfers for those. - const decodedFragment = importContextFragment - ? (decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? - decodeComposerContextClipboardHtml(clipboardData.getData("text/html"))) - : null; + const decodedFragment = + decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? + decodeComposerContextClipboardHtml(clipboardData.getData("text/html")); + if (decodedFragment === null) return null; const pastedIds = new Set( collectComposerContextReferences(pastedText).map((occurrence) => occurrence.contextId), ); - if (decodedFragment) { - for (const record of decodedFragment.records) { - if ( - record.kind === "preview-annotation" && - !("payload" in record) && - pastedIds.has(record.contextId) && - record.screenshotContextId - ) { - pastedIds.add(record.screenshotContextId); - } + for (const record of decodedFragment.records) { + if ( + record.kind === "preview-annotation" && + !("payload" in record) && + pastedIds.has(record.contextId) && + record.screenshotContextId + ) { + pastedIds.add(record.screenshotContextId); } } - const fragment = - decodedFragment === null - ? null - : { - ...decodedFragment, - records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)), - }; + return { + ...decodedFragment, + records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)), + }; +} + +/** Imports the same structured clipboard payload for focused paste and paste-to-focus. */ +export function importPastedComposerText( + clipboardData: Pick, + importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"], +): string { + const pastedText = clipboardData.getData("text/plain"); + const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; const rewrittenIds = fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null; const text = diff --git a/apps/web/src/components/contextChipParts.tsx b/apps/web/src/components/contextChipParts.tsx index b3202675de16..125a537bc272 100644 --- a/apps/web/src/components/contextChipParts.tsx +++ b/apps/web/src/components/contextChipParts.tsx @@ -119,29 +119,30 @@ export function PullRequestChip(props: { onOpen: (event: MouseEvent, url: string) => void; }) { return ( - - - {props.label} - - } - > -
+ + props.onOpen(event, props.metadata.url)} + > + + {props.label} + + } + /> + -

Captured pull request context

- -
-
+ + ); } diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index b96b42970b99..3152e4f4a9c5 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -12,6 +12,7 @@ import { Eye, Table2, Trash2Icon, + WrapTextIcon, XIcon, } from "lucide-react"; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; @@ -21,6 +22,7 @@ import ChatMarkdown from "~/components/ChatMarkdown"; import { ScrollArea } from "~/components/ui/scroll-area"; import { toastManager } from "~/components/ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { AudioPreview } from "./AudioPreview"; @@ -180,6 +182,16 @@ export function AttachmentFilePreview(props: { return () => controller.abort(); }, [url, needsText, revision, props.sizeBytes, props.file, refresh]); const failure = error ?? (needsText ? contentError : null); + const wordWrap = useClientSettings((settings) => settings.wordWrap); + const updateClientSettings = useUpdateClientSettings(); + // Only the raw-text body honours word wrap. A rendered table or Markdown lays itself out, + // so offering the toggle there would be a control that visibly does nothing. + const showsRawText = + failure === null && + needsText && + content !== null && + !(delimiter && rendered) && + !(kind === "markdown" && rendered); const save = () => { setSaving(true); @@ -302,6 +314,15 @@ export function AttachmentFilePreview(props: { )} ) : null} + {showsRawText ? ( + updateClientSettings({ wordWrap: !wordWrap })} + > + + + ) : null} {content ? (