diff --git a/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx
index 29b47a9ddd462..85420f6af53fa 100644
--- a/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx
@@ -10,6 +10,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/
import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsScreen } from "./components/SettingsScreen";
+import { ShareTemplatesSection } from "./ShareTemplatesSection";
const FOLLOW_UP_OPTIONS: ReadonlyArray<{
readonly behavior: FollowUpBehavior;
@@ -62,6 +63,7 @@ export function SettingsFollowUpRouteScreen() {
Long-press the send button to use the other option for a single message. With a hardware
keyboard, hold Command while sending.
+
);
diff --git a/apps/mobile/src/features/settings/ShareTemplatesSection.tsx b/apps/mobile/src/features/settings/ShareTemplatesSection.tsx
new file mode 100644
index 0000000000000..d618912741407
--- /dev/null
+++ b/apps/mobile/src/features/settings/ShareTemplatesSection.tsx
@@ -0,0 +1,91 @@
+import { useAtomSet, useAtomValue } from "@effect/atom-react";
+import { AsyncResult } from "effect/reactivity";
+import { useState } from "react";
+import { TextInput, View } from "react-native";
+
+import * as Dialog from "../../components/ui/Dialog";
+import { IconButton } from "../../components/ui/IconButton";
+import { ListActionRow } from "../../components/ui/ListActionRow";
+import { uuidv4 } from "../../lib/uuid";
+import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
+import { SHARE_TEMPLATE_PLACEHOLDER, type ShareTemplate } from "../sharing/share-templates";
+import { SettingsSection } from "./components/SettingsSection";
+
+export function ShareTemplatesSection() {
+ const preferencesResult = useAtomValue(mobilePreferencesAtom);
+ const savePreferences = useAtomSet(updateMobilePreferencesAtom);
+ const [addedTemplateId, setAddedTemplateId] = useState(null);
+ const templates = AsyncResult.isSuccess(preferencesResult)
+ ? (preferencesResult.value.shareTemplates ?? [])
+ : null;
+
+ const updateTemplates = (
+ transform: (templates: ReadonlyArray) => ReadonlyArray,
+ ) =>
+ savePreferences({
+ transform: (current) => ({ shareTemplates: transform(current.shareTemplates ?? []) }),
+ });
+ const addTemplate = () => {
+ const id = uuidv4();
+ setAddedTemplateId(id);
+ updateTemplates((current) => [...current, { id, prompt: "" }]);
+ };
+ const removeTemplate = (template: ShareTemplate) => {
+ const remove = () =>
+ updateTemplates((current) => current.filter(({ id }) => id !== template.id));
+ if (!template.prompt.trim()) {
+ remove();
+ return;
+ }
+ Dialog.confirm({
+ title: "Delete this template?",
+ confirmText: "Delete",
+ destructive: true,
+ onConfirm: remove,
+ });
+ };
+
+ return (
+
+ {(templates ?? []).map((template) => (
+
+ {/* Uncontrolled so saving each keystroke never moves the cursor. */}
+
+ updateTemplates((current) =>
+ current.map((candidate) =>
+ candidate.id === template.id ? { ...candidate, prompt } : candidate,
+ ),
+ )
+ }
+ />
+ removeTemplate(template)}
+ />
+
+ ))}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts
index c11a89a7c3a10..d0a5f328442f8 100644
--- a/apps/mobile/src/features/settings/settings-search.ts
+++ b/apps/mobile/src/features/settings/settings-search.ts
@@ -56,7 +56,14 @@ const SETTINGS_SEARCH_TERMS = {
"auto-resume",
"thread behavior",
],
- SettingsFollowUp: ["follow-ups", "queue", "steer", "while the agent is running"],
+ SettingsFollowUp: [
+ "follow-ups",
+ "queue",
+ "steer",
+ "while the agent is running",
+ "share templates",
+ "share sheet",
+ ],
SettingsArchive: ["archive", "archived", "restore", "unarchive"],
SettingsProviderAccounts: [
"provider accounts",
diff --git a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx
index b9c48bd68a1f7..a96e59c71f1c3 100644
--- a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx
+++ b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx
@@ -160,6 +160,7 @@ const incomingShareInbox = new IncomingShareInbox({
? await resolvedPayloadsForFiles()
: [];
const draft = await buildIncomingShareDraft({
+ attachmentIdPrefix: Crypto.randomUUID(),
payloads,
resolvedPayloads,
fileReader: {
diff --git a/apps/mobile/src/features/sharing/incoming-share-model.test.ts b/apps/mobile/src/features/sharing/incoming-share-model.test.ts
index 3e798975d743c..e5ea7b1e208e0 100644
--- a/apps/mobile/src/features/sharing/incoming-share-model.test.ts
+++ b/apps/mobile/src/features/sharing/incoming-share-model.test.ts
@@ -7,6 +7,7 @@ import type { ResolvedSharePayload, SharePayload } from "expo-sharing";
import {
buildIncomingShareDraft,
+ decodeIncomingShareDraft,
hasIncomingShareContent,
isShareFileUriUnderOwnedRoots,
selectIncomingShareAttachments,
@@ -20,11 +21,17 @@ describe("incoming native shares", () => {
value: "file:///shared/Screenshot.png",
mimeType: "image/png",
};
+ const file: SharePayload = {
+ shareType: "file",
+ value: "file:///shared/report.pdf",
+ mimeType: "application/pdf",
+ };
const payloads: SharePayload[] = [
{ shareType: "text", value: "Please explain this error" },
{ shareType: "url", value: "https://example.com/issue/1" },
{ shareType: "text", value: "Please explain this error" },
image,
+ file,
];
const resolvedImage: ResolvedSharePayload = {
...image,
@@ -36,19 +43,38 @@ describe("incoming native shares", () => {
};
const removeOwnedFile = vi.fn(() => Promise.resolve());
- const result = await buildIncomingShareDraft({
- id: "share-1",
- createdAt: "2026-07-15T10:00:00.000Z",
- payloads,
- resolvedPayloads: [resolvedImage],
- fileReader: {
- readBase64: async () => "YWJj",
- removeOwnedFile,
- },
- });
+ const build = (attachmentIdPrefix: string) =>
+ buildIncomingShareDraft({
+ attachmentIdPrefix,
+ id: "share-1",
+ createdAt: "2026-07-15T10:00:00.000Z",
+ payloads,
+ resolvedPayloads: [
+ resolvedImage,
+ {
+ ...file,
+ contentUri: file.value,
+ contentType: "file",
+ contentMimeType: "application/pdf",
+ contentSize: 3,
+ originalName: "report.pdf",
+ },
+ ],
+ fileReader: {
+ readBase64: async () => "YWJj",
+ removeOwnedFile,
+ },
+ });
+
+ const result = await build("share-1");
+ const repeated = await build("second-handoff");
+ expect(
+ new Set([...result.attachments, ...repeated.attachments].map((attachment) => attachment.id))
+ .size,
+ ).toBe(4);
expect(result).toEqual({
- schemaVersion: 1,
+ schemaVersion: 2,
id: "share-1",
createdAt: "2026-07-15T10:00:00.000Z",
text: "Please explain this error\n\nhttps://example.com/issue/1",
@@ -62,9 +88,22 @@ describe("incoming native shares", () => {
dataUrl: "data:image/png;base64,YWJj",
previewUri: "data:image/png;base64,YWJj",
},
+ {
+ id: "share-1:file:4",
+ type: "file",
+ name: "report.pdf",
+ mimeType: "application/pdf",
+ sizeBytes: 3,
+ fileUri: "file:///shared/report.pdf",
+ },
],
warnings: [],
});
+ expect(decodeIncomingShareDraft(result)).toEqual(result);
+ expect(decodeIncomingShareDraft({ ...result, schemaVersion: 1 })).toEqual({
+ ...result,
+ schemaVersion: 1,
+ });
expect(removeOwnedFile).toHaveBeenCalledWith(image.value);
expect(hasIncomingShareContent(result)).toBe(true);
});
@@ -79,6 +118,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(() => Promise.resolve());
const result = await buildIncomingShareDraft({
+ attachmentIdPrefix: "share-2",
id: "share-2",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [image],
@@ -113,6 +153,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
const result = await buildIncomingShareDraft({
+ attachmentIdPrefix: "share-report",
id: "share-report",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [file],
@@ -155,6 +196,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async () => undefined);
const result = await buildIncomingShareDraft({
+ attachmentIdPrefix: "share-video",
id: "share-video",
createdAt: "2026-08-30T10:00:00.000Z",
payloads: [{ ...video, shareType: "video" }],
@@ -200,6 +242,7 @@ describe("incoming native shares", () => {
};
const result = await buildIncomingShareDraft({
+ attachmentIdPrefix: "share-empty",
id: "share-empty",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [file],
@@ -225,6 +268,7 @@ describe("incoming native shares", () => {
const readSize = vi.fn(async (uri: string) => (uri.startsWith("content:") ? null : 42));
const result = await buildIncomingShareDraft({
+ attachmentIdPrefix: "share-android-report",
id: "share-android-report",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [file],
@@ -263,6 +307,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
const result = await buildIncomingShareDraft({
+ attachmentIdPrefix: "share-empty-copy",
id: "share-empty-copy",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [file],
@@ -285,6 +330,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async () => undefined);
const result = await buildIncomingShareDraft({
+ attachmentIdPrefix: "share-no-copy",
id: "share-no-copy",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [{ shareType: "file", value: sourceUri, mimeType: "application/pdf" }],
@@ -307,6 +353,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
const result = await buildIncomingShareDraft({
+ attachmentIdPrefix: "share-copy",
id: "share-copy",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [{ shareType: "file", value: payloadUri, mimeType: "application/pdf" }],
@@ -389,6 +436,7 @@ describe("incoming native shares", () => {
const readBase64 = vi.fn(async () => "YWJj");
const result = await buildIncomingShareDraft({
+ attachmentIdPrefix: "share-3",
id: "share-3",
createdAt: "2026-07-15T10:00:00.000Z",
payloads,
@@ -434,6 +482,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async () => undefined);
const result = await buildIncomingShareDraft({
+ attachmentIdPrefix: "share-duplicates",
id: "share-duplicates",
createdAt: "2026-07-16T08:00:00.000Z",
payloads: [duplicate, duplicate],
@@ -461,6 +510,7 @@ describe("incoming native shares", () => {
};
const result = await buildIncomingShareDraft({
+ attachmentIdPrefix: "share-cleanup-failure",
id: "share-cleanup-failure",
createdAt: "2026-07-16T08:00:00.000Z",
payloads: [image],
@@ -487,6 +537,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
const result = await buildIncomingShareDraft({
+ attachmentIdPrefix: "share-persist-leak",
id: "share-persist-leak",
createdAt: "2026-07-16T08:00:00.000Z",
payloads: [file],
diff --git a/apps/mobile/src/features/sharing/incoming-share-model.ts b/apps/mobile/src/features/sharing/incoming-share-model.ts
index aa097713923b6..dbc88579335d3 100644
--- a/apps/mobile/src/features/sharing/incoming-share-model.ts
+++ b/apps/mobile/src/features/sharing/incoming-share-model.ts
@@ -17,7 +17,7 @@ import type { DraftComposerAttachment } from "../../lib/composerImages";
import { estimateBase64ByteSize } from "../../lib/base64";
export interface IncomingShareDraft {
- readonly schemaVersion: 1;
+ readonly schemaVersion: 1 | 2;
readonly id: string;
readonly createdAt: string;
readonly destination?: IncomingShareDestination;
@@ -37,7 +37,7 @@ const IncomingShareDestinationSchema = Schema.Struct({
});
export const IncomingShareDraftSchema = Schema.Struct({
- schemaVersion: Schema.Literal(1),
+ schemaVersion: Schema.Literals([1, 2]),
id: Schema.String,
createdAt: Schema.String,
destination: Schema.optional(IncomingShareDestinationSchema),
@@ -232,6 +232,7 @@ export async function buildIncomingShareDraft(input: {
readonly resolvedPayloads: ReadonlyArray;
readonly fileReader: IncomingShareFileReader;
readonly id: string;
+ readonly attachmentIdPrefix: string;
readonly createdAt: string;
}): Promise {
const attachments: DraftComposerAttachment[] = [];
@@ -332,7 +333,7 @@ export async function buildIncomingShareDraft(input: {
}
}
attachments.push({
- id: `${input.id}:file:${index}`,
+ id: `${input.attachmentIdPrefix}:file:${index}`,
type: "file",
name,
mimeType,
@@ -397,7 +398,7 @@ export async function buildIncomingShareDraft(input: {
}
const dataUrl = `data:${mimeType};base64,${base64}`;
attachments.push({
- id: `${input.id}:image:${index}`,
+ id: `${input.attachmentIdPrefix}:image:${index}`,
type: "image",
name: resolved?.originalName ?? fallbackName(uri, index, mimeType),
mimeType,
@@ -414,7 +415,7 @@ export async function buildIncomingShareDraft(input: {
}
return {
- schemaVersion: 1,
+ schemaVersion: 2,
id: input.id,
createdAt: input.createdAt,
text: sharedText(input.payloads),
diff --git a/apps/mobile/src/features/sharing/share-templates.test.ts b/apps/mobile/src/features/sharing/share-templates.test.ts
new file mode 100644
index 0000000000000..4b34b5e1df64b
--- /dev/null
+++ b/apps/mobile/src/features/sharing/share-templates.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ applyShareTemplate,
+ selectedShareTemplate,
+ shareTemplateLabel,
+ usableShareTemplates,
+} from "./share-templates";
+
+describe("applyShareTemplate", () => {
+ it.each([
+ {
+ name: "appends the shared text after a template without a placeholder",
+ prompt: "Review this for bugs:\n",
+ shared: "https://example.com/pr/1",
+ expected: "Review this for bugs:\n\nhttps://example.com/pr/1",
+ },
+ {
+ name: "preserves shared indentation and trailing newlines",
+ prompt: " {{shared}} ",
+ shared: " code\n",
+ expected: " code\n",
+ },
+ {
+ name: "fills every placeholder",
+ prompt: "Summarize {{shared}} and cite {{shared}}.",
+ shared: "the article",
+ expected: "Summarize the article and cite the article.",
+ },
+ {
+ name: "keeps replacement patterns in the shared text literally",
+ prompt: "Explain: {{shared}}",
+ shared: "cost is $& or $1",
+ expected: "Explain: cost is $& or $1",
+ },
+ {
+ name: "uses the template alone when only files were shared",
+ prompt: "Describe these images.\n\n{{shared}}",
+ shared: "",
+ expected: "Describe these images.",
+ },
+ {
+ name: "uses the template alone without a placeholder or shared text",
+ prompt: " Describe these images. ",
+ shared: "",
+ expected: "Describe these images.",
+ },
+ ])("$name", ({ prompt, shared, expected }) => {
+ expect(applyShareTemplate(prompt, shared)).toBe(expected);
+ });
+});
+
+describe("shareTemplateLabel", () => {
+ it.each([
+ { prompt: "\nReview this for bugs:\nMore detail", expected: "Review this for bugs:" },
+ { prompt: "Summarize {{shared}}", expected: "Summarize …" },
+ { prompt: "{{shared}}", expected: "…" },
+ { prompt: " ", expected: "Template" },
+ ])("labels $prompt as $expected", ({ prompt, expected }) => {
+ expect(shareTemplateLabel(prompt)).toBe(expected);
+ });
+});
+
+describe("usableShareTemplates", () => {
+ it("skips blank templates", () => {
+ expect(
+ usableShareTemplates([
+ { id: "a", prompt: "Review" },
+ { id: "b", prompt: " \n" },
+ ]),
+ ).toEqual([{ id: "a", prompt: "Review" }]);
+ });
+});
+
+describe("selectedShareTemplate", () => {
+ it.each([
+ { id: "same-content", createdAt: "2026-10-31T00:00:00Z", selected: true },
+ { id: "same-content", createdAt: "2026-10-31T00:01:00Z", selected: false },
+ { id: "other-content", createdAt: "2026-10-31T00:00:00Z", selected: false },
+ ])("matches the choice to handoff $id at $createdAt", ({ id, createdAt, selected }) => {
+ const choice = {
+ shareId: "same-content",
+ createdAt: "2026-10-31T00:00:00Z",
+ template: "Review {{shared}}",
+ };
+ expect(selectedShareTemplate({ id, createdAt }, choice)).toBe(selected ? choice : null);
+ });
+});
diff --git a/apps/mobile/src/features/sharing/share-templates.ts b/apps/mobile/src/features/sharing/share-templates.ts
new file mode 100644
index 0000000000000..8a33bba0b975d
--- /dev/null
+++ b/apps/mobile/src/features/sharing/share-templates.ts
@@ -0,0 +1,43 @@
+export interface ShareTemplate {
+ readonly id: string;
+ readonly prompt: string;
+}
+
+export const SHARE_TEMPLATE_PLACEHOLDER = "{{shared}}";
+
+/** Puts the shared text where the template says {{shared}}, or after the template. */
+export function applyShareTemplate(prompt: string, sharedText: string): string {
+ if (prompt.includes(SHARE_TEMPLATE_PLACEHOLDER)) {
+ // split/join, not replaceAll: shared text may contain `$&`-style replacement patterns.
+ const result = prompt.trim().split(SHARE_TEMPLATE_PLACEHOLDER).join(sharedText);
+ return sharedText ? result : result.trim();
+ }
+ const template = prompt.trim();
+ return sharedText ? `${template}\n\n${sharedText}` : template;
+}
+
+export function shareTemplateLabel(prompt: string): string {
+ const firstLine = prompt.trim().split("\n", 1)[0] ?? "";
+ return firstLine.split(SHARE_TEMPLATE_PLACEHOLDER).join("…").trim() || "Template";
+}
+
+export function usableShareTemplates(
+ templates: ReadonlyArray | undefined,
+): ReadonlyArray {
+ return (templates ?? []).filter((template) => template.prompt.trim().length > 0);
+}
+
+export interface ShareTemplateChoice {
+ readonly shareId: string;
+ readonly createdAt: string;
+ readonly template: string | null;
+}
+
+export function selectedShareTemplate(
+ share: { readonly id: string; readonly createdAt: string } | null,
+ choice: ShareTemplateChoice | null,
+): ShareTemplateChoice | null {
+ return share && choice?.shareId === share.id && choice.createdAt === share.createdAt
+ ? choice
+ : null;
+}
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
index f3488ebd3bfff..89d0df1a9c6ee 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -53,6 +53,7 @@ import {
import { SymbolView } from "../../components/ui/AppSymbol";
import { AppText as Text } from "../../components/ui/AppText";
+import { Chip } from "../../components/ui/Chip";
import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences";
import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
@@ -91,7 +92,14 @@ import {
resolveNewTaskWorkspaceLabel,
} from "./new-task-context-presentation";
import { incomingShareImports, useIncomingShare } from "../sharing/IncomingShareProvider";
+import {
+ selectedShareTemplate,
+ type ShareTemplateChoice,
+ shareTemplateLabel,
+ usableShareTemplates,
+} from "../sharing/share-templates";
import { idleShareFlagAtom, idleShareImportPhaseAtom } from "./incomingShareImport";
+import { mobilePreferencesAtom } from "../../state/preferences";
import { newTaskFlowKey } from "./new-task-flow-state";
import { appAtomRegistry } from "../../state/atom-registry";
@@ -266,6 +274,20 @@ export function NewTaskDraftScreen(props: {
const loadedBranchesProjectKeyRef = useRef(null);
const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false);
const incomingShare = props.incomingShareId ? getShare(props.incomingShareId) : null;
+ const preferences = useAtomValue(mobilePreferencesAtom);
+ const shareTemplates = AsyncResult.isSuccess(preferences)
+ ? usableShareTemplates(preferences.value.shareTemplates)
+ : AsyncResult.isFailure(preferences)
+ ? []
+ : null;
+ const [shareTemplateChoice, setShareTemplateChoice] = useState(null);
+ const chosenShareTemplate = selectedShareTemplate(incomingShare, shareTemplateChoice);
+ // Null templates means preferences are still loading, so the choice is not known yet.
+ const isIncomingShareAwaitingTemplate = Boolean(
+ incomingShare &&
+ chosenShareTemplate === null &&
+ (shareTemplates === null || shareTemplates.length > 0),
+ );
const requestedInitialProjectAvailable = Boolean(
props.initialProjectRef?.environmentId &&
props.initialProjectRef.projectId &&
@@ -304,7 +326,14 @@ export function NewTaskDraftScreen(props: {
);
const shareImportTarget = (() => {
if (!props.incomingShareId || flowKey === undefined || !flow.draftKey) return null;
- if (!selectedProject || !incomingShare || isIncomingShareAwaitingServerConfig) return null;
+ if (
+ !selectedProject ||
+ !incomingShare ||
+ isIncomingShareAwaitingServerConfig ||
+ isIncomingShareAwaitingTemplate
+ ) {
+ return null;
+ }
const initialEnvironmentId = props.initialProjectRef?.environmentId;
const initialProjectId = props.initialProjectRef?.projectId;
if (
@@ -324,6 +353,8 @@ export function NewTaskDraftScreen(props: {
projectId: String(selectedProject.id),
},
shareId: incomingShare.id,
+ createdAt: incomingShare.createdAt,
+ ...(chosenShareTemplate?.template ? { template: chosenShareTemplate.template } : {}),
};
})();
const shareImportPhase = useAtomValue(
@@ -697,7 +728,9 @@ export function NewTaskDraftScreen(props: {
: flow.workspaceMode === "worktree" && (!flow.selectedBranchName || !flow.isGitRepoKnown)
? "Choose a base branch before creating a worktree."
: null
- : "Wait for shared content to finish importing.";
+ : isIncomingShareAwaitingTemplate && !isImportingShare
+ ? "Choose a template for the shared content."
+ : "Wait for shared content to finish importing.";
const chooseProject = () => {
if (isComposerInteractionLocked) {
return;
@@ -724,6 +757,40 @@ export function NewTaskDraftScreen(props: {
onChooseEnvironment={() => openContextPicker("NewTaskEnvironment")}
/>
);
+ // A remounted route has no choice while its earlier import still runs; that import keeps its own.
+ const shareTemplatePicker =
+ incomingShare && shareTemplates && isIncomingShareAwaitingTemplate && !isImportingShare ? (
+
+
+ Use a template for the shared content?
+
+
+ {shareTemplates.map((template) => (
+
+ setShareTemplateChoice({
+ shareId: incomingShare.id,
+ createdAt: incomingShare.createdAt,
+ template: template.prompt,
+ })
+ }
+ />
+ ))}
+
+ setShareTemplateChoice({
+ shareId: incomingShare.id,
+ createdAt: incomingShare.createdAt,
+ template: null,
+ })
+ }
+ />
+
+
+ ) : null;
const shareUnavailableNotice = isIncomingShareUnavailable ? (
The shared content is no longer in the inbox. You can continue editing this task draft.
@@ -871,6 +938,7 @@ export function NewTaskDraftScreen(props: {
Your draft is saved on this device. Connect to an environment to choose its first model.
+ {shareTemplatePicker}
+ {shareTemplatePicker}
{projectClone && projectClone.phase !== "done" && selectedProject ? (
= []): IncomingShareDraft {
return {
- schemaVersion: 1,
+ schemaVersion: 2,
id: "share-1",
createdAt: "2026-01-01T00:00:00.000Z",
text: "shared text",
@@ -71,6 +75,7 @@ function importTarget(overrides: Partial = {}): ShareImportTa
draftKey: "draft-a",
destination,
shareId: "share-1",
+ createdAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
@@ -155,6 +160,50 @@ describe("incoming share imports", () => {
expect(deps.onWarnings).toHaveBeenCalledWith(["too big"]);
});
+ it("wraps each share handoff once in its chosen template", async () => {
+ const { drafts, inbox, imports, watch, deps } = setup({
+ mergeIntoDraft: async (draftKey, content) => {
+ const next = mergeComposerDraftContentState(Object.fromEntries(drafts), draftKey, content);
+ for (const [key, value] of Object.entries(next)) drafts.set(key, value);
+ return { skippedAttachmentCount: 0 };
+ },
+ });
+ const legacyDraft: ComposerDraft = {
+ text: "User edited the imported context",
+ attachments: [],
+ importedShareIds: ["share-1"],
+ };
+ drafts.set("draft-a", legacyDraft);
+ const legacyCreatedAt = "2025-12-31T00:00:00.000Z";
+ inbox.set("share-1", {
+ ...share(),
+ schemaVersion: 1,
+ createdAt: legacyCreatedAt,
+ destination,
+ });
+ const legacyTarget = importTarget({ createdAt: legacyCreatedAt });
+ watch(legacyTarget);
+ await imports.settled(legacyTarget);
+ expect(drafts.get("draft-a")).toEqual(legacyDraft);
+ for (const [createdAt, template] of [
+ ["2026-01-01T00:00:00.000Z", "Review {{shared}} for bugs."],
+ ["2026-01-02T00:00:00.000Z", "Summarize {{shared}}."],
+ ["2026-01-03T00:00:00.000Z", "Summarize {{shared}}."],
+ ] as const) {
+ inbox.set("share-1", { ...share(), createdAt });
+ const target = { ...importTarget({ template }), createdAt };
+ watch(target);
+ await imports.settled(target);
+ const draft = drafts.get("draft-a")!;
+ expect(draft.text).toContain(template.replace("{{shared}}", "shared text"));
+ drafts.set("draft-a", { ...draft, text: `${draft.text}\nuser edit` });
+ }
+ expect(drafts.get("draft-a")?.text).toBe(
+ "User edited the imported context\n\nReview shared text for bugs.\nuser edit\n\nSummarize shared text.\nuser edit\n\nSummarize shared text.\nuser edit",
+ );
+ expect(deps.consume).toHaveBeenCalledTimes(4);
+ });
+
it("runs once when its atom node is removed and rebuilt", async () => {
const { imports, deps } = setup();
const tasks: Array<() => void> = [];
diff --git a/apps/mobile/src/features/threads/incomingShareImport.ts b/apps/mobile/src/features/threads/incomingShareImport.ts
index 12373b1280bea..984736483e5b3 100644
--- a/apps/mobile/src/features/threads/incomingShareImport.ts
+++ b/apps/mobile/src/features/threads/incomingShareImport.ts
@@ -7,6 +7,7 @@ import type {
IncomingShareDraft,
selectIncomingShareAttachmentsForServer,
} from "../sharing/incoming-share-model";
+import { applyShareTemplate } from "../sharing/share-templates";
export type ShareImportPhase =
| "idle"
@@ -65,6 +66,9 @@ export interface ShareImportTarget {
readonly draftKey: string;
readonly destination: IncomingShareDestination;
readonly shareId: string;
+ readonly createdAt: string;
+ /** The share template prompt that wraps the shared text. */
+ readonly template?: string;
}
export interface ShareImportDeps {
@@ -90,6 +94,7 @@ export interface ShareImportDeps {
readonly text: string;
readonly attachments: ReadonlyArray;
readonly sourceShareId: string;
+ readonly sourceShareCreatedAt?: string;
},
) => Promise<{ readonly skippedAttachmentCount: number }>;
readonly selectAttachments: (
@@ -133,6 +138,8 @@ function keyOf(target: ShareImportTarget): string {
target.destination.environmentId,
target.destination.projectId,
target.shareId,
+ target.createdAt,
+ target.template ?? null,
]);
}
@@ -193,7 +200,7 @@ export function makeIncomingShareImports(deps: ShareImportDeps) {
async function runImport(key: string, entry: Entry): Promise {
const { signal } = entry.controller;
- const { shareId, destination, draftKey } = entry.target;
+ const { shareId, destination, draftKey, template } = entry.target;
// A phase atom node of a superseded import may still be cached; rebuilding
// it lets a route that switches back start over instead of showing the
// abandoned import's last phase.
@@ -219,9 +226,10 @@ export function makeIncomingShareImports(deps: ShareImportDeps) {
let skippedAttachmentCount: number;
try {
({ skippedAttachmentCount } = await deps.mergeIntoDraft(draftKey, {
- text: share.text,
+ text: template === undefined ? share.text : applyShareTemplate(template, share.text),
attachments: selected.attachments,
sourceShareId: shareId,
+ ...(share.schemaVersion === 2 ? { sourceShareCreatedAt: share.createdAt } : {}),
}));
} finally {
// The merge publishes before it persists, so read it back even when
diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts
index 43ac75e185411..b2d21fc38b307 100644
--- a/apps/mobile/src/persistence/mobile-preferences.ts
+++ b/apps/mobile/src/persistence/mobile-preferences.ts
@@ -19,6 +19,7 @@ import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
import type { FollowUpBehavior } from "../lib/followUpBehavior";
import { isMicrophoneKind, type MicrophoneEntry } from "../lib/microphonePriority";
import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
+import type { ShareTemplate } from "../features/sharing/share-templates";
import * as MobileDatabase from "./mobile-database";
import * as MobileSecureStorage from "./mobile-secure-storage";
import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage";
@@ -112,6 +113,7 @@ export interface Preferences {
readonly composerEnterBehavior?: ComposerEnterBehavior;
readonly followUpBehavior?: FollowUpBehavior;
readonly microphones?: ReadonlyArray;
+ readonly shareTemplates?: ReadonlyArray;
readonly projectGroupingEnabled?: boolean;
readonly projectGroupingMode?: SidebarProjectGroupingMode;
readonly planModeEnabled?: boolean;
@@ -192,6 +194,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
composerEnterBehavior?: ComposerEnterBehavior;
followUpBehavior?: FollowUpBehavior;
microphones?: ReadonlyArray;
+ shareTemplates?: ReadonlyArray;
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
planModeEnabled?: boolean;
@@ -308,6 +311,16 @@ function sanitizePreferences(parsed: Preferences): Preferences {
: [];
});
}
+ if (Array.isArray(parsed.shareTemplates)) {
+ preferences.shareTemplates = parsed.shareTemplates.flatMap((entry): ShareTemplate[] =>
+ typeof entry === "object" &&
+ entry !== null &&
+ typeof entry.id === "string" &&
+ typeof entry.prompt === "string"
+ ? [{ id: entry.id, prompt: entry.prompt }]
+ : [],
+ );
+ }
if (typeof parsed.projectGroupingEnabled === "boolean") {
preferences.projectGroupingEnabled = parsed.projectGroupingEnabled;
}
diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts
index 791383be9e7a3..8ed98c85b4e1f 100644
--- a/apps/mobile/src/state/use-composer-drafts.ts
+++ b/apps/mobile/src/state/use-composer-drafts.ts
@@ -381,6 +381,7 @@ export interface ComposerDraftContent {
readonly context?: OrchestrationMessageContext;
readonly attachments: ReadonlyArray;
readonly sourceShareId?: string;
+ readonly sourceShareCreatedAt?: string;
}
export interface ComposerDraftWorkspaceSelection {
@@ -1644,7 +1645,12 @@ export function mergeComposerDraftContentState(
content: ComposerDraftContent,
): Record {
const existing = normalizeDraft(current[draftKey]);
- if (content.sourceShareId && existing.importedShareIds?.includes(content.sourceShareId)) {
+ const receipt =
+ content.sourceShareId &&
+ (content.sourceShareCreatedAt
+ ? JSON.stringify([content.sourceShareId, content.sourceShareCreatedAt])
+ : content.sourceShareId);
+ if (receipt && existing.importedShareIds?.includes(receipt)) {
return current;
}
const attachmentIds = new Set(existing.attachments.map((attachment) => attachment.id));
@@ -1661,9 +1667,11 @@ export function mergeComposerDraftContentState(
);
const text = mergeComposerDraftText(existing.text, content.text);
const context = mergeReferencedComposerContext(text, existing.context, content.context);
- const importedShareIds = content.sourceShareId
- ? [...(existing.importedShareIds ?? []), content.sourceShareId]
- : existing.importedShareIds;
+ // Retain the content id for restored routes; the handoff receipt guards duplicate merges.
+ const importedShareIds =
+ content.sourceShareId && receipt
+ ? [...new Set([...(existing.importedShareIds ?? []), content.sourceShareId, receipt])]
+ : existing.importedShareIds;
if (
text === existing.text &&
attachments.length === existing.attachments.length &&
diff --git a/docs/user/composer.md b/docs/user/composer.md
index db41f6cc013fe..19ade1a6af068 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -48,6 +48,12 @@ photos are converted to JPEG there and when selected from the mobile photo
library; photos over the image limit are also resized to fit. On mobile, you can
also send files to T3 Code through another app's system share sheet.
+To wrap shared text and links in a prompt you reuse, add a template in
+**Settings → Chat → Share templates** on mobile. Write `{{shared}}` where the
+shared text goes; otherwise it goes after the template. When you have templates,
+a new share asks which one to use before it fills the draft. Shared files still
+attach.
+
See [images and videos](#images-and-videos-in-messages) for previewing and saving media.
## Send while the agent is working