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